docs(openspec): aurora-1850 — решения 28.10 по JIT и условию мержа, решения контроллера

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 20:41:41 +04:00
parent feee68d4e5
commit dd510d105d

View File

@ -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 живёт в своей функции; падение теста сразу указывает, где откат | Нет: только раскладка тестов |