# 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, )` по **скелету** листа. Скелет — ключи всех узлов `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(, '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 | Явная инструкция пользователя приоритетнее напоминания | Неверное имя модели в трейлере коммитов |