# 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 номер меняется. ## 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, )` по **скелету** листа. Скелет — ключи всех узлов `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 выключается в построителях и на пути правки `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 = off` только на пути правки. Не закрывает причину 2. - (б) `plan_cache_mode = force_custom_plan`. Добавляет 7–8 мс планирования на каждую правку и усиливает причину 2. - (в) Глобальная настройка Postgres. Вне репозитория, прод не наш. ### 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`), которые материализуются, поэтому объём ниже по плану мал при любом плане. Если замер покажет обратное, следующий шаг — `SET plan_cache_mode = force_custom_plan` только на `_form_affected_rows`, с повторным замером. - **Сумма ROOT растёт с размером листа**: агрегат предка суммирует все строки под ним. Числовой `SUM` без jsonb на 2092 строках, по оценке, укладывается в 10–20 мс, а вложенные циклы `tw ⋈ aq_q` (около 100 мс) сужаются до 4–5 узлов. Если вторая точка (2092 строки) превысит 50 мс, исполнитель останавливается и эскалирует с профилем EXPLAIN: инкрементальные суммы — это уже другая задача. - **Права, аудит, синхронизация сателлитов** не затронуты: это слой выше `_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 остаются и должны проходить. Тестовая БД — PG16 (`perf-pg`, отдельная БД `dfip_test`, см. черновик P3 §0). Контроллер проверяет также на 15.12. Время в тестах не проверяется. 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**: - GET AHR_LIMIT для строки LEAF «- принтеры» (`limit_template.id = 13`, код `204620030`, засеяна в `0013_form2_limit.sql:1707`) после правки `qty_q1` даёт `amount_q1 = qty_q1 × 0.57 × 10900 / 4`; - если в справочнике есть `limit_template` с кодом, равным `num_group_id` статьи AHR глубины 4, правка такой строки AHR_LIMIT даёт непустую дельту АХР; если такого шаблона нет, тест делает `skip` с причиной; - отдельный тест проверяет, что `pg_get_functiondef` новых `v_form2_view` и `get_form2_ahr_satellite_delta` содержит `_form2_ahr_limit_amount` и `ei.num_group_id = lt.expense_item_code` без `ei.depth = 3 AND (`. 8. **Атомарность пакета** — существующий `test_bad_column_in_batch_fails_and_applies_nothing` остаётся. ## Замер до/после Замер выполняет контроллер после реализации, не в ночной прогон. Код стенда `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(, '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 | `SET jit = off` на `_form_affected_rows`, `get_form2_ahr_satellite_delta`, `v_form2_sheet_sections` и `v_form1_sheet_sections` (затрагивает и GET) | JIT съедает бюджет 50 мс и через общий кэш планов может начать срабатывать на каждом GET; по М12 JIT на этих запросах только вредит | Если на проде JIT на GET полезен, GET станет медленнее; ловит шаг 5 замера | | 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 мин к замеру |