From dd510d105d7c75a58353d72c98dcac5eec607964 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:41:41 +0400 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20aurora-1850=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2028.10=20=D0=BF?= =?UTF-8?q?=D0=BE=20JIT=20=D0=B8=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=BC=D0=B5=D1=80=D0=B6=D0=B0,=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../design.md | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/openspec/changes/aurora-1850-upd-form-cells-subtree/design.md b/openspec/changes/aurora-1850-upd-form-cells-subtree/design.md index fac0162..718be4b 100644 --- a/openspec/changes/aurora-1850-upd-form-cells-subtree/design.md +++ b/openspec/changes/aurora-1850-upd-form-cells-subtree/design.md @@ -60,6 +60,11 @@ 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 @@ -116,17 +121,22 @@ Добавляется tie-break `ORDER BY _sort, line_id NULLS FIRST`: при совпадении узел идёт раньше строки. Для OTCH9F добавляется `id` после `(expense_item_id, month)`. GET меняется только там, где порядок раньше был не определён. -### D7. JIT выключается в построителях и на пути правки +### 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. -- (б) `plan_cache_mode = force_custom_plan`. Добавляет 7–8 мс планирования на каждую правку и усиливает причину 2. -- (в) Глобальная настройка Postgres. Вне репозитория, прод не наш. +- (а) `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, не используются. @@ -162,8 +172,15 @@ - **Пустой фокус превратится в полный лист** (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: инкрементальные суммы — это уже другая задача. +- **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. @@ -176,7 +193,13 @@ ## Тестирование -Тесты интеграционные: `api/tests/integration/`, новый файл рядом с `test_upd_form_cells_full_rows.py`. Существующие тесты 1844 остаются и должны проходить. Тестовая БД — PG16 (`perf-pg`, отдельная БД `dfip_test`, см. черновик P3 §0). Контроллер проверяет также на 15.12. Время в тестах не проверяется. +Тесты интеграционные: `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(...)` и эталон. @@ -190,15 +213,14 @@ 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 (`. +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` остаётся. ## Замер до/после -Замер выполняет контроллер после реализации, не в ночной прогон. Код стенда `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`. +Замер выполняет контроллер после основных ночных замеров. В плане это точка ожидания: 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`, где он есть). @@ -234,7 +256,6 @@ | 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; скрытая зависимость должна ронять миграцию, а не удалять объекты | Падение миграции при неизвестной зависимости — разбор вручную | @@ -244,3 +265,7 @@ | 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 живёт в своей функции; падение теста сразу указывает, где откат | Нет: только раскладка тестов |