58 KiB
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)или WScell_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).
- RENT/UTILITY/SECURITY —
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)
v3._form_affected_rowsпереписывается так, чтобы не собирать весь лист. Существующие построители (v_form1_sheet_sections,v_form2_sheet_sectionsи вызывающие их функции) получают параметр фокуса по строкам. NULL означает прежнее поведение. Параллельной копии нет.sort_orderв ответе точный, то есть глобальныйROW_NUMBER.- Охват — только FORM_1 и FORM_2: AHR/CAP/OPER, сателлиты, AHR_LIMIT и OTCH9F (способ — по дизайну ниже). FORM_4 остаётся на полной сборке до AURORA-1846 (PR #232), критерий для FORM_4 переносится.
get_form2_ahr_satellite_deltaпереводится на тот же механизм. Тело берётся с мигрированной БД, с учётом 0037.- Корректность доказывает тест эквивалентности на базе
sheet_contract.py: каждая строка ответа PATCH побайтно равна строке того жеsort_orderиз полного GET. Покрываются FORM_1 и FORM_2 (все листы), первая и последняя строка, очистка, PATCH/cellsпо разным ветвям,sort_orderпосле POST и DELETE line. - Критерий: mean
upd_form_cell≤ 50 мс на FORM_2/AHR и 0 вложенных полных сборок листа на правку. Вторая точка снимается после grow (2092 строки). - AURORA-1845 сужена до кэша на соединение. После 1850 ручной ввод не должен создавать очередь — это проверяется замером coauthor на rate 5.
- Номер миграции — 0051, в стеке после 0050 из 1844. При rebase на 1831 и 1846 номер меняется.
- JIT выключается на уровне наших функций в миграции (
CREATE FUNCTION … SET jit = off): в построителяхv_form{1,2}_sheet_sections, в_form_affected_rowsи в дельте АХР (D7). Конфигурацию сервера Postgres не трогаем — это не зона команды (AURORA-1849). - Пороги производительности (≤ 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-функция дельты. - AHR/CAP/OPER →
-
get_form2_ahr_satellite_delta: CTEtarget_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). CTEtarget_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. Причины две.
- Запрос с фокусом в custom-плане может оценить стоимость выше
jit_above_costи потратить 200+ мс на компиляцию. Это больше всего бюджета в 50 мс. - План кэшируется один на оператор, общий для 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. Если обнаружится скрытая зависимость, миграция упадёт, а не удалит лишнее. Порядок:
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[]).- Затем
v3.v_form1_sheet_jsonb(integer, character varying, character varying, text[])иv3.v_form1_sheet_sections(integer, character varying, character varying, text[]). 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). Исполнитель может итерировать только на пути правки, по шагам:
SET plan_cache_mode = force_custom_planна_form_affected_rowsи дельте;- снять
SET jit = offс построителей, оставив его на_form_affected_rowsи дельте; - если не помогло — стоп и вопрос контроллеру с 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). В плане это отдельный шаг.
- Побайтная эквивалентность на уровне 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. - Тестовые листы содержат строки в нескольких ветвях дерева, чтобы были первая и последняя строки листа и предки без соседей.
- Для набора
- Эквивалентность через HTTP (
assert_patch_rows_match_sheet): PATCH/cellи/cellsна тех же листах, включая очистку (null), правку нечисловой колонки иsections. sort_orderпосле POST и DELETE line: добавить несколько строк, удалить строку в середине, править строку ниже неё; ответ сравнить с GET.- Совпадение ключа сортировки: в откатываемой сессии вставить
budget_lineс явнымid, равным id дочерней статьи той статьи, к которой привязана строка. Последовательность при этом не трогается. GET дважды даёт один и тот же порядок, узел идёт перед строкой, ответ на правку совпадает с GET. - Дельта АХР: после правки 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, если они есть на пути. - Пустой фокус:
get_form2_ahr_satellite_deltaдля несуществующегоsource_row_idвозвращает 0 строк. Вызов построителя с'{}'тоже даёт 0 строк. - Защита правки 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 (.
- Атомарность пакета — существующий
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.
- «До», 1154 строки. Код ветки 1844,
stand.sh reset,perfkit.gen.run --scale 1,perfkit.gen.verify --scale 1без grow, затем coauthor (около 7 мин). Ожидаемое значение — около 310 мс наupd_form_cell(одна сборка). - Снимок GET «до». Для всех листов FORM_1 и FORM_2 из манифеста сохранить JSON ответа
GET /form/{id}/sheet/{sheet}(сdirection, где он есть). - Переход на ветку 1850.
stand.sh migrate, перезапуск api, снова снимок GET. Ответы должны попарно совпасть (требование «GET листа не меняется»). Различие допустимо только в порядке строк с совпадающим ключом, и такие строки нужно перечислить. - Эквивалентность на данных стенда. 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. - 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). - «После», 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— очереди нет.
- «После», 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 |
Явная инструкция пользователя приоритетнее напоминания | Неверное имя модели в трейлере коммитов |