Andrey Epifancev f9d6a15f07 docs(openspec): aurora-1850 — рулинги контроллера R-1..R-4, R-14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:06:19 +04:00

58 KiB
Raw Blame History

Design: правка ячейки пересчитывает только затронутое поддерево (AURORA-1850, #235)

Зачем это нужно — в proposal.md, раздел Why. Требования — в specs/sheet-cell-edit/spec.md.

Context

Где мы сейчас: ветка стоит в стеке от feat/215-aurora-1844-upd-form-cells-full-rows, миграция 0050.

  • Цепочка вызовов при правке такая. HTTP PATCH .../cell(s) или WS cell_updated попадает в SheetRepository.update_cell(s) (api/src/repository/sheet_repository.py:70-116). Оттуда вызывается v3.upd_form_cell (0002_functions.sql:2478, SQL-обёртка). Далее — v3.upd_form_cells(..., p_user_id, ...): права, computed_field, синхронизация сателлитов (0031_form2_ahr_satellite_sync.sql:904-987). После неё — v3.upd_form_cells без пользователя (0050_upd_form_cells_full_rows.sql): проверка строк, _apply_form_cell, затем один вызов v3._form_affected_rows(p_form_id, p_sheet, p_ids, p_direction, p_sections).
  • _form_affected_rows (0050) во всех ветках собирает весь лист через v3.v_form_view и фильтрует результат. Фильтры по листам:
    • сателлиты — data.id ∈ p_ids;
    • AHR_LIMIT — путь по limit_template (_limit_template_path_set);
    • OTCH9F — то же (expense_item_id, equipment_name);
    • FORM_4 — ROOT, GROUP по программе и ITEM по проекту;
    • FORM_1 и FORM_2 AHR/CAP/OPER — агрегаты с header.expense_item_id ∈ _expense_item_path_set(eid изменённых строк) плюс INPUT с line_id ∈ p_ids;
    • при p_sections ключи исключённых разделов вырезаются (v_strip).
  • Сборка FORM_2 AHR/CAP/OPER идёт по цепочке v_form_view → v_form2_view (0033:1-373, переписана регуляркой в 0037) → v_form2_sheet_jsonb (0015:2534-2804, LANGUAGE sql, проекция строка в строку) → v_form2_sheet_sections (0033:469-1155, plpgsql). Внутри v_form2_sheet_sections:
    • tw — все узлы дерева статей листа для типа формы;
    • page — все строки формы на листе;
    • jp..jq4 и input_base — колонки INPUT;
    • agg и aq_q — суммы по expense_item_id по всем строкам листа;
    • tw_agg и tw_aq1..4 — суммы узла: LEFT JOIN agg ON eid = ANY(tw.desc_ids), причём desc_ids включает сам узел (0001_initial_schema.py:1063-1065);
    • _sort: для INPUT это tree_path || line_id, для узла — t.path.
  • sort_order вычисляется выше по цепочке: ROW_NUMBER() OVER (ORDER BY _sort_path) в v_form2_view (0033:21), для FORM_1 — в ветке v_form_view (0015:1478-1507). Набор ключей полного листа — все узлы tw плюс строки page ⋈ tw. Соединение внутреннее, поэтому строка статьи, не привязанной к типу формы, в лист не попадает.
  • FORM_1 устроена так же: v_form_view (ветка AHR/CAP/OPER) → v_form1_sheet_jsonb (0015:2268) → v_form1_sheet_sections (0033:1723-2470). Отличие в том, что page и все агрегатные CTE фильтруются по direction (bl.direction IS NULL OR = p_direction), а tw — нет.
  • Сателлитные листы, AHR_LIMIT и OTCH9F собираются прямо в v_form2_view. sort_order у них такой:
    • RENT/UTILITY/SECURITY — ROW_NUMBER по id;
    • AHR_LIMIT — limit_template.sort_order;
    • OTCH9F — ROW_NUMBER по (expense_item_id, month).
  • v3.get_form2_ahr_satellite_delta (0031:784-855, переписана регуляркой в 0037) вызывается в фоне из notify_ahr_about_satellite_change (websocket.py:798-903). Она собирает v_form_view(form, 'AHR', NULL, 'Support') целиком и фильтрует ROOT/GROUP/ITEM/SUB_ITEM по пути, а SUB_SUB_ITEM не берёт. Результат уходит на лист AHR как cell_updated или row_added.
  • Где тратится время (perf/report-data/20261026-s1/explain-manual/summary.md, form2_ahr_tw.jit_off; 2091 строка):
    • тело v_form2_sheet_sections — 240 мс;
    • основное — 4 × tw ⋈ aq_q вложенным циклом (546 узлов × 438 групп, около 100 мс);
    • соединения input_base (около 60 мс);
    • JIT по умолчанию добавляет ещё 210–230 мс (М12).
  • Фронт ищет строку ответа по (row_type, depth, sort_order) (cellUtils.js:76; так же в ветке AURORA-1838). Поэтому неточный sort_order молча испортит чужую строку.

Goals / Non-Goals

Goals:

  • Правка FORM_1 и FORM_2 (все редактируемые листы) не собирает лист целиком. Ответ по составу и содержимому байт в байт равен тому, что дала бы полная сборка с фильтром из 0050.
  • Один источник истины. Суммы, колонки INPUT и sort_order считаются тем же кодом, что и GET.
  • Фоновое обновление АХР после правки сателлита не собирает AHR целиком.

Non-Goals:

  • FORM_4: остаётся на полной сборке до слияния AURORA-1846.
  • add_budget_line и del_budget_line: их ответы по-прежнему строятся полной сборкой.
  • Ускорение GET листа.
  • Инкрементальные или материализованные суммы.
  • Пакетная обработка правок соединения (бывшая часть AURORA-1845).
  • Кэш пользователя и формы на соединение (AURORA-1845).
  • Изменения фронта.

Решения (согласовано 28.10)

  1. v3._form_affected_rows переписывается так, чтобы не собирать весь лист. Существующие построители (v_form1_sheet_sections, v_form2_sheet_sections и вызывающие их функции) получают параметр фокуса по строкам. NULL означает прежнее поведение. Параллельной копии нет. sort_order в ответе точный, то есть глобальный ROW_NUMBER.
  2. Охват — только FORM_1 и FORM_2: AHR/CAP/OPER, сателлиты, AHR_LIMIT и OTCH9F (способ — по дизайну ниже). FORM_4 остаётся на полной сборке до AURORA-1846 (PR #232), критерий для FORM_4 переносится.
  3. get_form2_ahr_satellite_delta переводится на тот же механизм. Тело берётся с мигрированной БД, с учётом 0037.
  4. Корректность доказывает тест эквивалентности на базе sheet_contract.py: каждая строка ответа PATCH побайтно равна строке того же sort_order из полного GET. Покрываются FORM_1 и FORM_2 (все листы), первая и последняя строка, очистка, PATCH /cells по разным ветвям, sort_order после POST и DELETE line.
  5. Критерий: mean upd_form_cell ≤ 50 мс на FORM_2/AHR и 0 вложенных полных сборок листа на правку. Вторая точка снимается после grow (2092 строки).
  6. AURORA-1845 сужена до кэша на соединение. После 1850 ручной ввод не должен создавать очередь — это проверяется замером coauthor на rate 5.
  7. Номер миграции — 0051, в стеке после 0050 из 1844. При rebase на 1831 и 1846 номер меняется.
  8. JIT выключается на уровне наших функций в миграции (CREATE FUNCTION … SET jit = off): в построителях v_form{1,2}_sheet_sections, в _form_affected_rows и в дельте АХР (D7). Конфигурацию сервера Postgres не трогаем — это не зона команды (AURORA-1849).
  9. Пороги производительности (≤ 50 мс, GET не больше +10 %, нет очереди на rate 5) — не условие мержа. PR вливается по зелёным тестам и доказанной корректности. Цифры замера записываются в PR, недобор оформляется follow-up-задачей. Корректность — жёсткое условие, и любое её нарушение означает стоп и эскалацию:
    • эквивалентность ответа на правку и полного GET (тесты п. 1–6);
    • совпадение снимка GET до и после миграции, кроме строк, порядок которых раньше был недетерминирован из-за совпадения ключа (D6);
    • защита правки 0037 (тесты п. 7).

Decisions

D1. Параметр фокуса и его семантика

У v_form2_sheet_sections, v_form2_sheet_jsonb, v_form2_view, v_form1_sheet_sections и v_form1_sheet_jsonb последним параметром добавляется p_focus_lines integer[] DEFAULT NULL. В новой v_form1_view (D3) он тоже есть.

  • NULL — полный лист, поведение прежнее.
  • Непустой массив — только строки фокуса и узлы их путей.
  • Пустой массив '{}' — пустой результат, ни в коем случае не полный лист. array_agg по пустому набору даёт NULL, поэтому вызывающие обязаны передавать COALESCE(..., '{}'). Это проверяется тестом (раздел «Тестирование», п. 6).

Смысл фокуса для листов AHR/CAP/OPER:

  • Узлы фокуса — _expense_item_path_set(eid строк фокуса) ∩ tw. eid берутся из budget_line без фильтра direction, как в 0050: правка строки чужого направления в FORM_1 возвращает предков, но не саму строку.
  • INPUT фокуса — строки фокуса, прошедшие обычный фильтр page (форма, лист, direction, соединение с tw).
  • Суммы. agg и aq_q (у FORM_1 — agg, aq1..aq4, atot) ограничиваются строками с expense_item_id ∈ ∪ desc_ids(узлы фокуса). Сумма узла определяется только его desc_ids, которые входят в это объединение, поэтому значение не меняется. tw_agg и tw_aq* строятся только по узлам фокуса.
  • jp..jq4, input_base и final_input строятся только по INPUT фокуса.

Отвергнутые варианты:

  • (а) Отдельная «лёгкая» функция сумм, копия формул. Отвергнута решением человека: два источника истины.
  • (б) Фильтр поверх полного v_form_view. Это текущее состояние: plpgsql-функция не принимает WHERE внутрь.
  • (в) Ветвление IF p_focus_lines IS NULL THEN <запрос> ELSE <копия запроса> внутри построителя. Это та же копия на 700 строк.

D2. sort_order считает построитель, по «скелету»

Построители *_sheet_sections и *_sheet_jsonb получают выходную колонку _sort_rank bigint. Её значение — ROW_NUMBER() OVER (ORDER BY _sort, <tie-break D6>) по скелету листа. Скелет — ключи всех узлов tw и всех строк page ⋈ tw без jsonb и без сумм: ровно тот набор ключей, который даёт полный лист. Ранг считается одинаково в обоих режимах, и строки вывода получают его соединением по ключу.

v_form2_view и v_form1_view берут sort_order из _sort_rank и больше не считают собственный ROW_NUMBER.

Скелет на 2092 строки стоит единицы миллисекунд: сортировка около 2,7 тыс. int[]. Это лишняя работа и для GET, но зато порядок считается одним кодом в обоих режимах.

Отвергнутые варианты:

  • (а) ROW_NUMBER в вызывающих функциях, как сейчас. В режиме фокуса он нумерует подмножество.
  • (б) Отдельный запрос count(*) ключей < k в _form_affected_rows. Он дублирует правила набора ключей (direction, соединение page ⋈ tw).

D3. FORM_1: новая v3.v_form1_view

Ветка FORM_1 AHR/CAP/OPER в v_form_view (0015:1478-1507, jsonb_build_object из 16 ключей) выносится в v3.v_form1_view(p_form_id, p_sheet, p_sections, p_direction, p_focus_lines). Она симметрична v_form2_view. v_form_view вызывает её с фокусом NULL, _form_affected_rows — с p_ids. Сигнатура v_form_view не меняется (CREATE OR REPLACE).

Отвергнутый вариант: _form_affected_rows сама собирает jsonb поверх v_form1_sheet_jsonb. Тогда у проекции строки было бы два места.

D4. Сателлиты, AHR_LIMIT, OTCH9F — внутри v_form2_view с тем же параметром

  • AHR_RENT, AHR_UTILITY, AHR_SECURITY. Ранг — ROW_NUMBER() OVER (ORDER BY id) по всем строкам формы на этом листе: только id, без соединения с vsp и без jsonb. Затем фильтр id = ANY(p_focus_lines), и jsonb строится только для отобранных строк. Здесь p_focus_lines — id деталей (как p_ids в 0050).
  • AHR_LIMIT. Вывод фильтруется по _limit_template_path_set(p_focus_lines). sort_order = lt.sort_order — статичный, от фокуса не зависит. Агрегат tree/leaf_amt/agg остаётся по всему шаблону: шаблон общий и его размер не зависит от числа строк формы. Сужать его можно, но не обязательно (на усмотрение исполнителя). Формула 0037 (v3._form2_ahr_limit_amount) сохраняется.
  • OTCH9F. base ограничивается разделами (expense_item_id, equipment_name) строк фокуса. Окна разделяются по (budget_form_id, expense_item_id, equipment_name), поэтому разделы целые и остатки те же. Ранг — ROW_NUMBER() OVER (ORDER BY expense_item_id, month, id) по всем строкам формы (D6) на лёгком скелете с тем же JOIN expense_item.
  • SMETA и прочие листы — без изменений: фокус игнорируется, а _form_affected_rows для них по-прежнему зовёт полный путь (такие листы не редактируются).

D5. _form_affected_rows и get_form2_ahr_satellite_delta

  • _form_affected_rows, ветки FORM_1 и FORM_2:

    • AHR/CAP/OPER → v_form1_view или v_form2_view с p_focus_lines = p_ids;
    • сателлиты, AHR_LIMIT, OTCH9F → v_form2_view с фокусом.

    Правила состава ответа (0050) при этом выполняет сам построитель. v_strip и ORDER BY sort_order остаются. Ветка FORM_4 без изменений. Функция помечается STABLE: только чтение, а её вызывает STABLE-функция дельты.

  • get_form2_ahr_satellite_delta: CTE target_lines сохраняется в том виде, как он стоит в БД после 0037, вместе с сопоставлением (ei.depth = 3 AND ei.item_id = …) OR ei.num_group_id = …. Финальный SELECT заменяется на SELECT * FROM v3._form_affected_rows(p_form_id, 'AHR', COALESCE((SELECT array_agg(line_id) FROM target_lines), '{}'), 'Support', NULL). CTE target_path больше не нужен. Сигнатура и LANGUAGE sql STABLE прежние, Python не меняется.

  • Изменение поведения дельты: теперь в неё входят предки уровня SUB_SUB_ITEM, а раньше фильтр их пропускал (0031:843). После 0033 у AHR есть статьи глубины 4, и их агрегат у соавторов оставался старым. Это исправление по контракту «все предки».

D6. Детерминированный порядок при совпадении ключа

Ключ INPUT tree_path || line_id совпадает с path дочернего узла, если line_id равен id дочерней статьи, а строка привязана к нелистовой статье. На AHR такое бывает: после 0033 у статей глубины 3 есть дети глубины 4, и строки синхронизации сателлитов привязаны к статьям глубины 3. Сейчас порядок при таком совпадении зависит от плана, а скелет и полный вывод могли бы упорядочить строки по-разному.

Добавляется tie-break ORDER BY _sort, line_id NULLS FIRST: при совпадении узел идёт раньше строки. Для OTCH9F добавляется id после (expense_item_id, month). GET меняется только там, где порядок раньше был не определён.

D7. JIT выключается в построителях и на пути правки (согласовано 28.10, решение 8)

SET jit = off ставится как атрибут функций _form_affected_rows, get_form2_ahr_satellite_delta, v_form2_sheet_sections и v_form1_sheet_sections. Причины две.

  1. Запрос с фокусом в custom-плане может оценить стоимость выше jit_above_cost и потратить 200+ мс на компиляцию. Это больше всего бюджета в 50 мс.
  2. План кэшируется один на оператор, общий для GET и правок в одном backend. Дешёвые custom-планы правок снижают среднюю стоимость custom-плана, и после этого GET может перестать переходить на generic-план. Тогда GET будет каждый раз платить JIT (+210–230 мс, М12).

По М12 JIT на этих запросах только вредит: 534 мс против 240 мс. Атрибут SET не мешает: plpgsql не инлайнится.

Почему это безопасно на проде:

  • jit — пользовательский параметр (контекст PGC_USERSET). Поэтому CREATE FUNCTION … SET jit = off выполняется правами владельца схемы, без суперпользователя.
  • Значение действует только во время выполнения функции, включая вложенные вызовы, и восстанавливается при выходе. Настройки сервера, роли и сессии не меняются, другие запросы приложения JIT по-прежнему используют по общей конфигурации.
  • Связь с AURORA-1849 («Прод-Postgres: согласовать work_mem и jit»): та задача о серверной или ролевой конфигурации, она остаётся за администраторами. Наш атрибут от её исхода не зависит и с ним не конфликтует. Если на проде JIT уже выключен, атрибут ничего не меняет. work_mem в этой задаче не трогаем.

Отвергнутые варианты:

  • (а) jit = off только на пути правки. Не закрывает причину 2. Это запасной ход, если GET регрессирует (см. Risks).
  • (б) plan_cache_mode = force_custom_plan. Добавляет 7–8 мс планирования на каждую правку и усиливает причину 2. Тоже запасной ход, только на пути правки.
  • (в) Настройка сервера Postgres. Не зона команды (решение 8, AURORA-1849).

D8. Тела функций в миграции — буквальные, снятые с мигрированной БД

Тела v_form2_view и get_form2_ahr_satellite_delta берутся из pg_get_functiondef на БД, мигрированной до 0050, и правятся вручную. Регулярные выражения поверх pg_get_functiondef, как в 0037, не используются.

Отвергнутый вариант — DO-блок с regexp_replace. Правка затрагивает десятки мест, а регулярка в 0037 уже показала хрупкость (ветка CONTINUE).

Защита от отката 0037 — тесты из раздела «Тестирование», п. 7.

D9. Порядок операторов в миграции 0051

Старые сигнатуры удаляются явно, без CASCADE. Если обнаружится скрытая зависимость, миграция упадёт, а не удалит лишнее. Порядок:

  1. DROP FUNCTION v3.v_form2_view(integer, character varying, text[]), затем v3.v_form2_sheet_jsonb(integer, character varying, text[]), затем v3.v_form2_sheet_sections(integer, character varying, text[]).
  2. Затем v3.v_form1_sheet_jsonb(integer, character varying, character varying, text[]) и v3.v_form1_sheet_sections(integer, character varying, character varying, text[]).
  3. CREATE в порядке «вызываемая раньше вызывающей», потому что тела LANGUAGE sql проверяются при создании:
    • *_sheet_sections;
    • *_sheet_jsonb;
    • v_form2_view, v_form1_view;
    • v_form_view (CREATE OR REPLACE);
    • _form_affected_rows (CREATE OR REPLACE);
    • get_form2_ahr_satellite_delta (CREATE OR REPLACE).

Без DROP функции с DEFAULT-параметрами дали бы неоднозначные перегрузки: вызов v_form2_view(a, b, c) из v_form_view подходит к обеим.

Ограничения формата:

  • api/alembic/sql_migration.py пропускает строки, начинающиеся с --, и ищет $tag$ построчно. Открывающий и закрывающий тег должны стоять на разных строках, закрывающий — в начале строки, как в существующих файлах.
  • SQL совместим с PostgreSQL 15.12: без any_value, у подзапросов в FROM есть алиасы.

Файлы: api/alembic/versions/0051_upd_form_cells_subtree.py (down_revision = "0050", run_sql_file_migration) и api/alembic/versions/sql/0051_upd_form_cells_subtree.sql. downgrade() пустой, как во всех миграциях.

Risks / Trade-offs

  • Суммы предков в режиме фокуса разойдутся с полной сборкой. Защита: параметризация вместо копии (D1); побайтная эквивалентность на всех строках тестовых листов («Тестирование», п. 1–2); та же проверка на данных стенда («Замер», шаг 4).

  • sort_order разойдётся с полным листом, и фронт молча испортит соседнюю строку. Защита: ранг по скелету одним кодом (D2), tie-break (D6), тесты после POST и DELETE line и тест на совпадение ключа.

  • Пустой фокус превратится в полный лист (NULL из array_agg). Последствия: дельта АХР отдаст весь лист, правка снова станет дорогой. Защита: семантика '{}' (D1), COALESCE в вызывающих, тест п. 6.

  • Откатится правка 0037 (формула лимитов, сопоставление статей глубины 4). Защита: D8 и тесты п. 7.

  • Регресс GET из-за общего кэша планов, лишнего скелета или выключенного JIT. Защита: D7; требование «GET листа не меняется»; замер GET до и после со смешанной нагрузкой («Замер», шаг 5).

  • Generic-план для запроса с фокусом окажется плохим. Фильтры фокуса стоят в базовых CTE (tw, page, agg/aq_q), которые материализуются, поэтому объём ниже по плану мал при любом плане.

  • Сумма ROOT растёт с размером листа: агрегат предка суммирует все строки под ним. Числовой SUM без jsonb на 2092 строках, по оценке, укладывается в 10–20 мс, а вложенные циклы tw ⋈ aq_q (около 100 мс) сужаются до 4–5 узлов. Инкрементальные суммы — это уже другая задача.

  • Регресс производительности (GET больше +10 %, правка больше 50 мс, очередь на rate 5) мерж не блокирует (решение 9). Исполнитель может итерировать только на пути правки, по шагам:

    1. SET plan_cache_mode = force_custom_plan на _form_affected_rows и дельте;
    2. снять SET jit = off с построителей, оставив его на _form_affected_rows и дельте;
    3. если не помогло — стоп и вопрос контроллеру с EXPLAIN (ANALYZE, BUFFERS) запроса правки и GET.

    Скелет sort_order (D2) без контроллера не откатывать. Оставшийся недобор фиксируется в PR и уходит в follow-up.

  • Нарушение корректности (расхождение эквивалентности, снимка GET или защиты 0037) — стоп и эскалация, никаких обходов (решение 9).

  • Права, аудит, синхронизация сателлитов не затронуты: это слой выше _form_affected_rows.

  • Зависимости. AURORA-1844 — шов и контракт, это база ветки. AURORA-1846 нужна только для будущего фокуса FORM_4. Номер миграции может конфликтовать с 1831 и 1846.

Migration Plan

  • PR идёт в ветку feat/215-aurora-1844-upd-form-cells-full-rows (стек, Gitea #235). После слияния 1844 в test ветка перебазируется на test.
  • Перенумерация. Если до слияния стека в test попадут 1831 (0050_project_technical_number_lock) или 1846 (0050_form4_sheet_sum), 1844 получит следующий свободный номер, а 1850 — номер после него. При rebase меняются имена файлов, revision, down_revision и ссылка на SQL-файл в .py. Содержимое миграции от номера не зависит: 1846 меняет только v_form4_sheet_jsonb, 1831 — add_project, пересечений по функциям нет.
  • Откат: downgrade() пустой. Откат — это новая миграция, которая восстанавливает тела из 0050 и 0037 (снять их через pg_get_functiondef до применения).
  • ARCHITECTURE.md: в абзац о _form_affected_rows добавить, что FORM_1 и FORM_2 собираются построителями с фокусом (p_focus_lines), sort_order берётся из скелета листа, FORM_4 — полная сборка до AURORA-1846.

Тестирование

Тесты интеграционные: api/tests/integration/, новый файл рядом с test_upd_form_cells_full_rows.py. Существующие тесты 1844 остаются и должны проходить. Время в тестах не проверяется.

Тестовое окружение:

  • Полный pytest гоняется на PG16: контейнер perf-pg, порт 55432, отдельная БД dfip_1850, создаётся командой docker exec perf-pg createdb -U perf dfip_1850. Запуск: DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1850; --init_db только на пустой БД. Пароли OPENBAO__TEST_* по умолчанию admin123 (api/tests/conftest.py).
  • БД perf (стенд замеров) не трогать никогда. Накатить туда 0051 раньше замера «до» нельзя.
  • Тела функций для D8 снимаются через pg_get_functiondef с тестовой БД dfip_1850 на ревизии 0050, до создания 0051.
  • Совместимость с PostgreSQL 15.12 проверяет контроллер (контейнер dfip-pg15, порт 55434). В плане это отдельный шаг.
  1. Побайтная эквивалентность на уровне SQL (новый хелпер в sheet_contract.py).
    • Для набора p_ids сравниваются SELECT row_type, depth, sort_order, data::text FROM v3._form_affected_rows(...) и эталон.
    • Эталон — фильтр 0050, применённый к полному v3.v_form_view с теми же параметрами: агрегаты по пути плюс INPUT по line_id; для сателлитов — по id, для AHR_LIMIT — по пути шаблона, для OTCH9F — по разделу. Эталон пишется в коде теста, в БД он не попадает.
    • Сравнение data::text побайтное: jsonb-текст канонический, поэтому видна и разница в масштабе numeric (30 и 30.00).
    • Проверка на каждой строке INPUT тестового листа по очереди, плюс пакеты из строк разных ветвей.
    • Покрытие: FORM_1 AHR (Support и Development), CAP (Support), OPER; FORM_2 AHR, CAP, OPER, AHR_LIMIT, AHR_RENT, AHR_UTILITY, AHR_SECURITY, OTCH9F. Строки OTCH9F вставляются через db_exec в fixed_asset_report (HTTP-ручки нет), минимум два месяца одного оборудования и второе оборудование той же статьи в том же месяце — так проверяется tie-break.
    • Тестовые листы содержат строки в нескольких ветвях дерева, чтобы были первая и последняя строки листа и предки без соседей.
  2. Эквивалентность через HTTP (assert_patch_rows_match_sheet): PATCH /cell и /cells на тех же листах, включая очистку (null), правку нечисловой колонки и sections.
  3. sort_order после POST и DELETE line: добавить несколько строк, удалить строку в середине, править строку ниже неё; ответ сравнить с GET.
  4. Совпадение ключа сортировки: в откатываемой сессии вставить budget_line с явным id, равным id дочерней статьи той статьи, к которой привязана строка. Последовательность при этом не трогается. GET дважды даёт один и тот же порядок, узел идёт перед строкой, ответ на правку совпадает с GET.
  5. Дельта АХР: после правки AHR_RENT (форма 4 фикстуры) результат SELECT * FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', id) (через db_exec в той же сессии) совпадает со строками GET AHR того же sort_order. В нём есть синхронизированная строка INPUT и предки всех уровней, включая SUB_SUB_ITEM, если они есть на пути.
  6. Пустой фокус: get_form2_ahr_satellite_delta для несуществующего source_row_id возвращает 0 строк. Вызов построителя с '{}' тоже даёт 0 строк.
  7. Защита правки 0037 — по одному тесту на каждую функцию:
    • v_form2_view (формула лимитов). GET AHR_LIMIT для строки LEAF «- принтеры» (limit_template.id = 13, код 204620030, засеяна в 0013_form2_limit.sql:1707) после правки qty_q1 даёт amount_q1 = qty_q1 × 0.57 × 10900 / 4. Тот же тест проверяет, что pg_get_functiondef новой v_form2_view содержит _form2_ahr_limit_amount.
    • get_form2_ahr_satellite_delta (сопоставление по num_group_id). Правка строки AHR_LIMIT, код шаблона которой равен num_group_id статьи AHR глубины 4, даёт непустую дельту АХР с синхронизированной строкой этой статьи. Если такого шаблона в справочнике нет, тест делает skip с причиной. Тот же тест проверяет, что pg_get_functiondef новой дельты содержит ei.num_group_id = lt.expense_item_code вне условия ei.depth = 3 AND (.
  8. Атомарность пакета — существующий test_bad_column_in_batch_fails_and_applies_nothing остаётся.

Замер до/после

Замер выполняет контроллер после основных ночных замеров. В плане это точка ожидания: PR открывается без цифр, цифры дописываются в PR позже. Пороги — не условие мержа (решение 9). Шаги 3 и 4 проверяют корректность: расхождение в них — стоп и эскалация. Код стенда perf/ не меняется. Сценарий — шаг coauthor (perf/e2e/scenarios/scroll-coauthor.spec.ts и бот perf/perfkit/coauthor.py), цель FORM_2/AHR/*, rate 1 и 5, масштаб 1, CPU 1x. Метрики берутся из sql/coauthor/sql_top.json, summary.md и строк db_time в perf/.run/api.log.

  1. «До», 1154 строки. Код ветки 1844, stand.sh reset, perfkit.gen.run --scale 1, perfkit.gen.verify --scale 1 без grow, затем coauthor (около 7 мин). Ожидаемое значение — около 310 мс на upd_form_cell (одна сборка).
  2. Снимок GET «до». Для всех листов FORM_1 и FORM_2 из манифеста сохранить JSON ответа GET /form/{id}/sheet/{sheet} (с direction, где он есть).
  3. Переход на ветку 1850. stand.sh migrate, перезапуск api, снова снимок GET. Ответы должны попарно совпасть (требование «GET листа не меняется»). Различие допустимо только в порядке строк с совпадающим ключом, и такие строки нужно перечислить.
  4. Эквивалентность на данных стенда. psql, только чтение. Для 50 случайных строк FORM_2/AHR и 20 строк каждого из листов CAP, OPER, AHR_RENT и FORM_1/AHR сравнить v3._form_affected_rows(form, sheet, ARRAY[id], dir, NULL) с эталонным фильтром по v3.v_form_view (тот же запрос, что в тесте п. 1). Расхождений должно быть 0.
  5. GET без регресса. В одном psql-соединении сделать 5 прогревочных вызовов, затем 20 раз чередовать SELECT count(*) FROM v3.v_form_view(<FORM_2 rf>, 'AHR', NULL, NULL) и SELECT count(*) FROM v3.upd_form_cell(...) в транзакции с ROLLBACK, измерить по \timing или pg_stat_statements. Средний GET не больше значения на коде 1844 + 10 % (то же измерение на шаге 1).
  6. «После», 1154 строки. coauthor, rate 1 и 5. Критерии:
    • mean_exec_time вызова upd_form_cell ≤ 50 мс;
    • вызовов v3.v_form_view с toplevel = false — 0;
    • calls у v3.v_form2_view(p_form_id, p_sheet, p_sections) равно числу GET листа, а не числу правок;
    • на rate 5 (coauthor_echoed + coauthor_errors) / coauthor_sent ≥ 0.95 — очереди нет.
  7. «После», 2092 строки. perfkit.gen.grow --scale 1 (около 4 мин), затем coauthor. Критерий: mean_exec_time ≤ 50 мс и тот же критерий очереди.

Отчёт: таблица «до» и «после» по шагам 1, 6, 7 с путями к sql_top.json и summary.md, результаты шагов 3–5. Общее время — около 45 мин. Точка «до» на 2092 строках не снимается (см. решения без обсуждения).

Оставлено исполнителю

  • Конкретная перестройка CTE внутри построителей: разделение tw/tw_focus, page/page_focus, размещение скелета. Ранг через ROW_NUMBER по скелету или эквивалентный способ — главное, одним выражением для обоих режимов.
  • Вычисление узлов фокуса и ∪ desc_ids в DECLARE/SELECT INTO перед RETURN QUERY, если это даёт лучший план.
  • Сужать ли агрегат шаблона AHR_LIMIT.
  • Имена тестовых хелперов и раскладка тестов по файлам.
  • Текст абзаца в ARCHITECTURE.md.

Decisions made without discussion

Дата Решение Обоснование Цена ошибки
2026-10-28 Capability sheet-cell-edit. В спецификацию как ADDED вошёл и контракт ответа 1844 (полные строки, явные null, sections, атомарность пакета) openspec/specs/ пуст; без контракта требование о sort_order и эквивалентности не к чему привязать Переименование capability при архивации; пересечение с change 1845 или 1848, если там заведут ту же область
2026-10-28 Параметр p_focus_lines integer[] DEFAULT NULL последним во всех построителях; NULL — полный лист, '{}' — пустой результат Совместимость существующих вызовов по позиции; явное различие «весь лист» и «ничего» Если вызывающая сторона передаст NULL вместо '{}', будет полная сборка, правильная по содержимому, но медленная; ловит тест п. 6
2026-10-28 sort_order считает построитель по скелету листа в обоих режимах (новая колонка _sort_rank); view перестают считать собственный ROW_NUMBER Один источник порядка для GET и правки, требование точного sort_order +1–3 мс к GET на скелет; при ошибке в скелете меняется и GET, что ловит снимок GET на стенде
2026-10-28 Tie-break ORDER BY _sort, line_id NULLS FIRST (узел раньше строки); для OTCH9F — id после (expense_item_id, month) Порядок при совпадении ключа сейчас не определён; без tie-break скелет и вывод могут разойтись Порядок строк в GET меняется только в таких случаях, возможно непривычно для пользователя
2026-10-28 Новая v3.v_form1_view: в неё вынесена ветка FORM_1 AHR/CAP/OPER из v_form_view Одна проекция строки FORM_1 для GET и правки Ещё одна функция в схеме; при ошибке переноса меняется GET FORM_1, что ловят снимок GET и тесты 1844
2026-10-28 Узлы фокуса берутся из budget_line без фильтра direction, а INPUT — с фильтром Так ведёт себя 0050: предки по всем строкам правки, INPUT только видимые в листе Если правильнее было не возвращать предков для строки чужого направления — лишние, но верные строки в ответе
2026-10-28 Сателлиты: фильтр по id и ROW_NUMBER по лёгкому скелету; AHR_LIMIT: фильтр вывода по пути шаблона, агрегат шаблона может остаться полным; OTCH9F: base по разделам изменённых строк Размер шаблона AHR_LIMIT не зависит от числа строк формы; окна OTCH9F замкнуты в разделе AHR_LIMIT остаётся O(размер шаблона) на правку, сейчас это доли мс
2026-10-28 Дельта АХР теперь включает предков уровня SUB_SUB_ITEM Контракт «все предки»; после 0033 у AHR есть статьи глубины 4, их агрегат у соавторов устаревал Если фронт АХР не ожидает таких строк, получит лишние строки; updateCells сливает по ключу, так что это безопасно
2026-10-28 _form_affected_rows помечается STABLE Функция только читает, вызывается из STABLE-функции дельты STABLE-функция видит снимок вызывающего оператора. Сейчас её зовёт отдельный оператор RETURN QUERY после _apply_form_cell, и правки видны. Если когда-нибудь её вызовут в одном операторе с правкой, ответ будет со старыми значениями; это ловят тесты эквивалентности
2026-10-28 Тела v_form2_view и get_form2_ahr_satellite_delta в 0051 — буквальные, снятые через pg_get_functiondef с БД после 0050, а не регулярка поверх функции Правка затрагивает десятки мест; регулярка хрупкая Если тело снято с БД не той версии, откатится 0037; ловят тесты п. 7
2026-10-28 DROP старых сигнатур без CASCADE в одной миграции, затем CREATE в порядке «вызываемая раньше» Неоднозначные перегрузки с DEFAULT; скрытая зависимость должна ронять миграцию, а не удалять объекты Падение миграции при неизвестной зависимости — разбор вручную
2026-10-28 Имя миграции 0051_upd_form_cells_subtree Номер из решения человека, имя по change id Переименование при rebase
2026-10-28 Эталон эквивалентности — фильтр 0050 поверх полного v_form_view в коде теста, сравнение data::text побайтно, проход по каждой строке INPUT тестового листа Требование «побайтно»; dict-сравнение в HTTP не видит масштаб numeric; полный проход закрывает первую и последнюю строку без ручного выбора Тесты дольше на несколько секунд
2026-10-28 Порог «GET не медленнее более чем на 10 %» и замер GET в одном соединении с чередованием правок Нужен численный критерий регресса; чередование проверяет общий кэш планов Порог 10 % может быть слишком мягким или жёстким при шуме стенда
2026-10-28 Отсутствие очереди на rate 5 проверяется как (echoed + errors) / sent ≥ 0.95, без задержки эха по времени Бот не пишет задержку эха, а perf/ сейчас не трогаем; при отсутствии очереди сервер отвечает почти на все отправленные правки Растущую задержку при почти полном ответе этот критерий не увидит; можно позже добавить в бот p50 и p95 задержки
2026-10-28 Точка «до» на 2092 строках не снимается; ориентир — 1,46 с из шага memory (две сборки) и около 0,73 с после 1844 по оценке Критерий абсолютный (≤ 50 мс); экономия около 30 мин на reset, gen и grow с кодом 1844 В отчёте нет измеренной точки «до» на большом листе, только оценка
2026-10-28 Снимок GET всех листов FORM_1 и FORM_2 до и после миграции на стенде и SQL-эквивалентность на 50+ строках стенда Тестовые данные малы; реальный объём и разброс статей есть только на стенде +10 мин к замеру
2026-10-28 Контроллер: при регрессе производительности (GET > +10 %, правка > 50 мс, очередь на rate 5) исполнитель итерирует только на пути правки: plan_cache_mode = force_custom_plan на _form_affected_rows/дельте; снятие SET jit = off с построителей при сохранении его на пути правки; если не помогает — стоп и вопрос контроллеру с EXPLAIN. Скелет sort_order (D2) без контроллера не откатывается Пороги не блокируют мерж (решение 9), но итерации не должны задевать GET и корректность; D2 — основа точного sort_order Лишние итерации исполнителя; недобор цифр уходит в follow-up вместо доработки в этом PR
2026-10-28 Контроллер: полный pytest на PG16 в контейнере perf-pg (порт 55432), отдельная БД dfip_1850; БД perf стенда не трогается никогда; тела функций для D8 снимаются через pg_get_functiondef с dfip_1850 на ревизии 0050; совместимость с PG 15.12 проверяет контроллер (dfip-pg15, порт 55434) отдельным шагом плана Изоляция от стенда замеров: 0051 в БД perf до замера «до» испортит базовую точку При ошибке в БД — пересоздать dfip_1850; если 0051 попадёт в perf, придётся заново делать reset и gen стенда (около 15 мин)
2026-10-28 Контроллер: замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания, PR открывается без цифр Стенд занят ночным прогоном; пороги не условие мержа Цифры в PR появляются позже мержа, если ревью пройдёт раньше
2026-10-28 Контроллер: тест защиты 0037 разнесён по функциям — формула лимитов через v_form2_view, сопоставление num_group_id через дельту Каждая из двух правок 0037 живёт в своей функции; падение теста сразу указывает, где откат Нет: только раскладка тестов
2026-10-28 План: шаблон dfip_1850_base (ревизия 0050 + фикстура) для быстрого сброса dfip_1850 и вспомогательная dfip_1850_snap для снимка GET; обе в perf-pg, БД perf не затрагивается Миграция с DROP без IF EXISTS не перезапускается на той же БД; сброс из шаблона быстрее --init_db Две лишние БД в контейнере; при ошибке — пересоздать из dfip_1850
2026-10-28 План: тела 8 функций с ревизии 0050 коммитятся в openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql (не миграция) D8 требует буквальных тел; файл — база для ревью (diff «до» и 0051) и для будущей миграции отката; архивируется вместе с change Лишний файл ~5 тыс. строк в change
2026-10-28 План: локальный снимок GET всех листов FORM_1/FORM_2 до и после 0051 на детерминированно засеянной dfip_1850_snap после каждой задачи с построителями Требование «GET листа не меняется» проверяется на стенде только после PR; локальная проверка ловит регресс GET раньше Данные посева меньше стенда; снимок стенда (шаг 3 замера) остаётся обязательным
2026-10-28 План: «0 вложенных полных сборок» в тестах проверяется через SET LOCAL track_functions = 'all' и pg_stat_get_xact_function_calls(v_form_view); без прав суперпользователя тест делает skip Единственный способ увидеть вложенный вызов plpgsql-функции из теста На роли без суперпользователя критерий проверяется только замером стенда (шаг 6)
2026-10-28 План: тест эквивалентности дополнительно сравнивает с эталоном вывод построителя в режиме фокуса (v_form2_view/v_form1_view с p_focus_lines), а не только _form_affected_rows Даёт красный тест до реализации (путь _form_affected_rows на 0050 эквивалентен эталону по построению) и локализует ошибку в построителе Тесты дольше на несколько секунд
2026-10-28 План: скелет sort_order сателлитов сохраняет те же JOIN v3.budget_line и JOIN v3.vsp, что и GET; соединение с vsp убирается только при доказанных NOT NULL и FK D4 говорит «без соединения с vsp», но в GET соединение внутреннее и может отбрасывать строки; тогда ранг сдвинется Соединение по первичному ключу почти бесплатно; если убрать его без доказательства — тихо неверный sort_order
2026-10-28 План: v_form1_view принимает только AHR/CAP/OPER, на других листах — RAISE EXCEPTION SMETA FORM_1 собирается v_form_view до вызова; явная ошибка лучше пустого результата Нет: функция внутренняя
2026-10-28 План: раскладка тестов — test_upd_form_cells_subtree.py (эквивалентность, sort_order, дельта, JIT) и test_upd_form_cells_subtree_0037_guard.py; SQL-хелперы в sheet_contract.py; HTTP-хелперы и фикстуры импортируются из test_upd_form_cells_full_rows.py Оставлено исполнителю (design.md); решено в плане, чтобы задачи 2, 8, 9 ссылались на одни имена Нет: только раскладка
2026-10-28 План: защита 0037 для дельты проверяет pg_get_functiondef регуляркой ei\.depth\s*=\s*3\s+AND\s*\( (вид до 0037), а не литералом В теле до 0037 между 3 и AND ( стоят переводы строки и отступы; литерал не поймал бы откат Нет
2026-10-28 Контроллер (R-1, R-10): скелет сателлитов сохраняет те же JOIN (budget_line, vsp), что GET (0033); JOIN vsp убирать только при доказанных NOT NULL и FK. Одобрено главным контроллером Рулинг уточняет D4: внутренний JOIN может отбрасывать строки и сдвигать ранг Тихо неверный sort_order сателлитов; ловят тесты эквивалентности и снимок GET
2026-10-28 Контроллер (R-2): роль perf в perf-pg и dfip-pg15 — суперпользователь, SET LOCAL track_functions доступен; тест «0 вложенных v_form_view» не пропускается. Skip этого теста считается провалом Иначе критерий «нет полной сборки на пути правки» остаётся непроверенным до замера Если на другой роли тест скипнется, критерий проверяется только замером стенда
2026-10-28 Контроллер (R-3): дополнительные БД dfip_1850_base и dfip_1850_snap в perf-pg одобрены; допустим только префикс dfip_1850*, БД perf не трогается Быстрый сброс и снимок GET (см. решения плана) Две лишние БД в контейнере
2026-10-28 Контроллер (R-4): before-0051.sql коммитится в каталог change База ревью и отката (D8) Лишний файл в change
2026-10-28 Контроллер (R-14): подпись коммитов Co-Authored-By: Claude Opus 5.5 по плану и правилам волны 2, хотя системное напоминание сессии называет Sonnet 5.5 Явная инструкция пользователя приоритетнее напоминания Неверное имя модели в трейлере коммитов