docs(openspec): aurora-1848-sheet-json — пороги, порядок PR, окружение тестов

Порог: свод 2 GO −30 %, лист FORM_2/AHR −10 % и меньший размер; пороги
не условие мержа, корректность (сравнение exact/strip-nulls) — жёсткое.
Решения контроллера: план в две фазы, ветка B стеком на A, archive после
merge B, база сравнения, замеры контроллером, pytest на PG16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 20:41:01 +04:00
parent b701a4f5ff
commit 3c5487c458
3 changed files with 39 additions and 13 deletions

View File

@ -52,8 +52,10 @@
2. Два PR. **A** — готовый JSON из Postgres (`jsonb::text`) без pydantic-модели на строку. Ответ по смыслу тот же, это доказывается сравнением ответов всех листов и сводов на стенде до и после. **B** — без null-полей в `data` (`jsonb_strip_nulls`) и правка фронта, чтобы входящие правки соавторов по отсутствующим ключам не терялись. В B проверить, что `updateCells` из AURORA-1838 (PR #223) для этого достаточно, и покрыть тестом. B стеком на A.
3. Ответ на правку ячейки (PATCH `/cell(s)` и WebSocket `cell_updated`) продолжает отдавать `null` явно: очистка ячейки должна дойти до соавторов.
4. Отчёты проектов РФ (`projects.py`, `SheetResponse`) — тем же хелпером, отдельным коммитом в A.
5. Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 %. «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок `X-DB-Time-Ms` должен сохраниться: на нём держится методика `perf/analysis/api_split.py`.
5. Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 % (для листа уточнено в п. 7). «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок `X-DB-Time-Ms` должен сохраниться: на нём держится методика `perf/analysis/api_split.py`.
6. `response_model` в декораторах остаётся ради OpenAPI.
7. Порог времени уточнён (заменяет порог для листа из п. 5). Свод 2 GO — полное время после B ниже минимум на 30 %. Лист FORM_2/AHR — минимум на 10 %, и распакованный размер меньше прежнего. Остальное ускорение листа — в AURORA-1847.
8. Пороги производительности не условие мержа. PR вливается по зелёным тестам и доказанной корректности (сравнение ответов exact / strip-nulls), цифры — в описании PR, недобор — в follow-up. Корректность — жёсткое условие.
### D1. Строка ответа собирается в SQL функцией `json_build_object`
Новый метод репозитория выполняет:
@ -138,6 +140,13 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
Существующие тесты: `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_db_functions_integration.py:79-93` должны проходить в A без изменений. В B `test_summary2_sheets_smoke` проверяет наличие ключей `header.smeta_direction` и блоков `data`. Если у первой строки эти значения `null`, тест меняется так: проверять ключи только у строк, где значение не `null`, или проверять набор ключей по эталону со `strip_nulls`. Это ожидаемое изменение контракта, в PR его нужно оговорить.
**Окружение тестов бэка.** Полный `pytest` гоняется на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1848`.
- Создать: `docker exec perf-pg createdb -U perf dfip_1848`. Пересоздать: `dropdb` + `createdb`, затем `--init_db`.
- БД `perf` не трогать.
- Пароли `OPENBAO__TEST_*` по умолчанию `admin123`.
- Команда: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848 <venv>/bin/pytest --init_db -q -p no:cacheprovider -W ignore`.
- Совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер. Новый SQL этого change — `json_build_object`, `jsonb_strip_nulls`, `::text` — есть в PG 9.5+.
**Фронт (B):** `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`.
**Ручная проверка (B, чек-лист PR):**
@ -150,9 +159,9 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
Сценарий соавтора на стенде — после ночного прогона: `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0`.
### D7. Замер и сравнение на стенде
Стенд не трогать до окончания ночного прогона. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`.
Замеры на стенде выполняет контроллер после основных ночных замеров. В плане это точка ожидания. PR A и B открываются без цифр, цифры дописываются в описание PR после замера. Пороги не блокируют мерж (решение человека 8), а сравнение ответов (п. 1 ниже) — обязательное доказательство корректности до мержа. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`.
Данные: масштаб 1, одна и та же БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией). Миграции «до» и «после» одинаковые: у A и B миграций нет. Если между замерами в `test` влилась миграция (1846, 1844), серия «до» переснимается на новой базе.
Данные: масштаб 1, одна и та же БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией). База сравнения для порогов — `test` на момент замера «до» A. Миграции «до» и «после» одинаковые: у A и B миграций нет. Если до замера «после» в `test` влилась миграция или правка SQL листа (AURORA-1847, 1846, 1844), серия «до» переснимается на новой базе.
1. **Сравнение ответов** — новый скрипт `perf/analysis/compare_responses.py` (PR A):
- `dump --scale 1 --out <dir>` обходит по `results/manifest-s1.json` все листы (все `owner`, с `direction` где есть, плюс один вариант с `sections` для FORM_2/AHR), своды 1 (`FORM_1..4`, `MAIN`) и 2 (`GO`, `RF`) за год манифеста, отчёты и сводные проектов. Запросы идут под ролью из манифеста (`rf` для своих листов, `dfip` для сводов), ответы сохраняются как `.json.gz` по имени запроса;
@ -160,7 +169,8 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
Порядок: `dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` (с дампом «до»).
2. **Время.** `cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/<tag>-1848-<before|A|B>/analysis` три раза: на базовом коммите, на A, на B. API перезапускается на каждом коммите. Метрики:
- `total_ms` свода 2 GO и листа FORM_2/AHR: порог «B ≤ 0,7 × до»;
- `total_ms` свода 2 GO: порог «B ≤ 0,7 × до»;
- `total_ms` листа FORM_2/AHR: порог «B ≤ 0,9 × до»;
- `total_ms` всех запросов набора: «A ≤ 1,05 × до»;
- `bytes` FORM_2/AHR: «B < до»;
- справочно `python_share` и новое поле `wire_bytes`.
@ -171,7 +181,7 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
## Risks / Trade-offs
- **[Порог 30 % для FORM_2/AHR, вероятно, недостижим этой задачей]** По замеру 26.10: полное время 813 мс, `X-DB-Time-Ms` 726 мс, остаток 87 мс. Эта задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше). Ожидаемое снижение — порядка 12–20 %, для 30 % нужно ≤ 569 мс. Основное время — SQL `v_form_view` (М7, AURORA-1847). Для свода 2 GO порог реален: ≈ 240 мс разбора + большая часть 339 мс остатка. → Это вопрос человеку (см. отчёт автора дизайна). До ответа спека фиксирует согласованный порог. Замер даёт отдельной строкой полное время и разложение, чтобы решение принималось по цифрам.
- **[Порог для FORM_2/AHR]** По замеру 26.10: полное время 813 мс, `X-DB-Time-Ms` 726 мс, остаток 87 мс. Задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше), ожидаемое снижение — порядка 12–20 %. Поэтому человек 28.10 снизил порог для листа до −10 % плюс уменьшение размера, а остальное отдал AURORA-1847. Для свода 2 GO порог −30 % реален: ≈ 240 мс разбора и большая часть 339 мс остатка. → Пороги не блокируют мерж. Недобор — follow-up с цифрами разложения.
- **[`jsonb::text` пишет `": "` и `", "`]** В A распакованный размер может вырасти примерно на 10–14 %, gzip уровня 1 обрабатывает больше байт. → Порог A — «не медленнее 5 %». Уменьшение размера даёт только B (критерий Jira).
- **[Ответ в обход `response_model` не валидируется]** Ошибка формы ответа не будет поймана FastAPI. → Тесты эквивалентности со старым путём на всех девяти эндпоинтах, проверка OpenAPI, сравнение на стенде.
- **[Заголовок теряется при возврате `Response`]** → Заголовок ставится на возвращаемый объект, тест на всех эндпоинтах, `api_split.py` падает на `nan` (сценарий в спеке).
@ -185,7 +195,10 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
## Migration Plan
- Порядок: PR A → merge в `test` → PR B (ветка от A, rebase на `test` после merge A и #223) → merge.
- Один план superpowers в две фазы, A и B, под issue #233.
- Ветка A — `feat/233-aurora-1848-sheet-json-no-pydantic`. Ветка B — та же с суффиксом `-b`, стеком на ветке A.
- Порядок: PR A → merge в `test` → PR B (rebase на `test` после merge A и #223) → merge. B вливается только после A и PR #223.
- `openspec archive aurora-1848-sheet-json` — только после merge B. Иначе в `openspec/specs/` попадут требования PR B (без null-полей, фронт) без кода.
- Миграций БД нет, деплой — обычный выкат API и фронта. B выкатывается одновременно с фронтом B, потому что это одна сборка (фронт отдаёт API).
- Откат: revert PR B возвращает null-поля, фронт B совместим и с ними. Revert PR A возвращает модели. Данные не затрагиваются.
@ -200,7 +213,7 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
| 2026-10-28 | «Отчёты проектов РФ (projects.py, SheetResponse)» понято как все шесть GET в `projects.py` с `BaseListResponse[SheetResponse]`: report, rf-rollup, project summary, plan/fact/detail-summary | Все объявляют `SheetResponse` и строятся одинаково | Низкая: лишние 2–3 эндпоинта в отдельном коммите, их легко исключить |
| 2026-10-28 | В PR B null убираются только у листа и сводов 1/2; отчёты проектов отдают null как раньше | Человек отнёс отчёты проектов к A; их ответ мал (1,5 МБ), а проверять фронт проектов отдельно — лишний объём | Недополученный выигрыш на отчётах проектов; можно сделать follow-up |
| 2026-10-28 | `jsonb_strip_nulls` применяется ко всем строкам ответа, включая META | Единое правило; фронт читает из META только `editable[].column` | Низкая: если что-то читает null-поля META, исключить её одним условием |
| 2026-10-28 | Порог 30 % проверяется на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе `api_split.py`» | Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа | Если порог нужен уже на A — B станет обязательным для приёмки A |
| 2026-10-28 | Пороги времени (−30 % свод 2 GO, −10 % FORM_2/AHR) проверяются на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе `api_split.py`» | Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа | Низкая: пороги не блокируют мерж (решение человека 8) |
| 2026-10-28 | Критерий Jira «размер ответа FORM_2/AHR уменьшился» включён в спеку как требование к B | Он есть в приёмке Jira, человек его не отменял | Низкая |
| 2026-10-28 | В `api_split.py` добавляется поле `wire_bytes` (размер на проводе), прежние поля не меняются | Эффект B на сжатый размер иначе не виден | Низкая |
| 2026-10-28 | Новый скрипт `perf/analysis/compare_responses.py` (dump/compare) в PR A | Доказательство эквивалентности на всех листах и сводах стенда требует повторяемой команды | Низкая |
@ -210,3 +223,8 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
| 2026-10-28 | B правит потребителей GET в `perf/perfkit` (`[...]` → `.get`) для полей, которые могут исчезнуть | Генератор и бот стенда — наши инструменты замера; падение сорвёт повторный прогон | Низкая |
| 2026-10-28 | В B тест `test_summary2_sheets_smoke` ослабляется для ключей с `null`, если он на них падает | Это прямое следствие нового контракта | Низкая |
| 2026-10-28 | `_rows_to_sheet_response` в `summary.py` удаляется, в `forms.py` остаётся (её используют пути записи) | Мёртвый код | Нет |
| 2026-10-28 | Один план superpowers в две фазы (A, B); ветка B — стеком на ветке A, с суффиксом `-b`, под issue #233; B вливается после A и PR #223 (решение контроллера) | Одна задача Jira и одна issue; B зависит от кода A | Средняя: при долгом ревью A ветка B требует повторных rebase |
| 2026-10-28 | `openspec archive` — только после merge B (решение контроллера) | Иначе в `openspec/specs/` попадут требования PR B без кода | Низкая: архив откладывается до B |
| 2026-10-28 | База сравнения для порогов — `test` на момент замера «до» A; если AURORA-1847 (или другая правка SQL/миграция) вольётся раньше замера «после», «до» переснимается на новой базе (решение контроллера) | Иначе в разницу попадёт чужое ускорение или замедление | Лишняя серия замеров (~10 мин) |
| 2026-10-28 | Замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания; PR открываются без цифр, цифры дописываются (решение контроллера) | Стенд занят ночным прогоном, замеры нельзя вести параллельно (`pg_stat_statements`) | Цифры появляются в PR позже ревью кода |
| 2026-10-28 | Полный pytest — на PG16 (`perf-pg:55432`, БД `dfip_1848`, пароли `OPENBAO__TEST_*` = `admin123`); совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер (решение контроллера) | `test` пока не накатывается на PG15 (AURORA-1922); новый SQL change совместим с PG 9.5+ | Низкая: несовместимость с PG15 ловит контроллер до мержа |

View File

@ -30,6 +30,8 @@ Jira: AURORA-1848 (эпик AURORA-1834). Gitea: #233. Источник цифр
- В ответах GET листа, свода 1 и свода 2 у объектов в `data` нет полей со значением `null`, на любой глубине (`jsonb_strip_nulls`). **BREAKING** для потребителя, который отличает «ключ есть и равен null» от «ключа нет». Единственный такой потребитель — наш фронт. Выгрузка Excel и WebSocket идут своими путями и не меняются.
- Фронт: входящая правка соавтора (WebSocket `cell_updated`, эхо PATCH) применяется и к ключу, которого нет в загруженной строке. В AURORA-1838 (PR #223) `updateCells` уже вставляет отсутствующий ключ. B проверяет, что этого достаточно, и закрепляет поведение тестом.
**Критерии приёмки (согласовано 28.10).** Свод 2 GO — полное время ниже минимум на 30 % после B. Лист FORM_2/AHR — минимум на 10 %, и распакованный размер меньше. Пороги не условие мержа: PR вливается по зелёным тестам и доказанной корректности (сравнение ответов на стенде), цифры — в PR, недобор — в follow-up.
**Что НЕ меняется**
- Ответы на правку — PATCH `/cell`, `/cells`, POST/DELETE `/line`, WebSocket `cell_updated` и правки FORM_3 — по-прежнему отдают `null` явно: очистка ячейки должна дойти до соавторов.
- Выгрузка Excel и WebSocket читают данные через прежние методы репозиториев и сервисов. Эти методы остаются без изменений.
@ -51,4 +53,4 @@ Jira: AURORA-1848 (эпик AURORA-1834). Gitea: #233. Источник цифр
- Бэк: `api/src/api/v1/forms.py`, `summary.py`, `projects.py` (только GET), `api/src/repository/sheet_repository.py`, `summary_repository.py`, `project_repository.py`, `api/src/services/sheet_service.py`, `summary_service.py`, `project_service.py` (новые методы рядом со старыми), новый хелпер ответа в `api/src/api/v1/`, тесты в `api/tests/unit` и `api/tests/integration`.
- Фронт (PR B): `web/src/components/RealtimeTable/utils/cellUtils.js` (если #223 не покрывает всё), тесты `cellUtils.test.js`.
- `perf/`: `analysis/api_split.py` (новое поле), новый `analysis/compare_responses.py`. В PR B — потребители GET в `perf/perfkit`, если они обращаются к ключам, которые стали отсутствовать.
- Связь с задачами: AURORA-1838 (PR #223) — B без неё не мержится. AURORA-1844/1850 — контракт строк ответа на правку с явными `null`, этот change его не трогает. AURORA-1846 (PR #232) меняет содержимое `data` FORM_4, этот change — упаковку; общий участок — фикстура `db_session` в `api/tests/integration/conftest.py`. AURORA-1847 — SQL листа FORM_2/AHR, от него зависит, достижим ли порог времени для этого листа (см. design.md, «Риски»).
- Связь с задачами: AURORA-1838 (PR #223) — B без неё не мержится. AURORA-1844/1850 — контракт строк ответа на правку с явными `null`, этот change его не трогает. AURORA-1846 (PR #232) меняет содержимое `data` FORM_4, этот change — упаковку; общий участок — фикстура `db_session` в `api/tests/integration/conftest.py`. AURORA-1847 — SQL листа FORM_2/AHR: дальнейшее ускорение листа сверх порога этого change (−10 % и меньший размер) — там.

View File

@ -101,26 +101,32 @@
- **THEN** у `GET /api/v1/form/{form_id}/sheet/{sheet}` схема ответа 200 ссылается на ту же компоненту, что и до изменения
### Requirement: Время ответа тяжёлых GET
На стенде `perf/` (масштаб 1, те же данные, напрямую на API, медиана 10 повторов после прогрева) после PR B полное время ответа SHALL снизиться не менее чем на 30 % относительно версии до изменения для:
- `GET /api/v1/svod/2/GO/{year}` (все организации, роль dfip),
- `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 (роль rf).
На стенде `perf/` (масштаб 1, те же данные, напрямую на API, медиана 10 повторов после прогрева) после PR B полное время ответа SHALL снизиться относительно версии до изменения:
- `GET /api/v1/svod/2/GO/{year}` (все организации, роль dfip) — не менее чем на 30 %;
- `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 (роль rf) — не менее чем на 10 %. Дальнейшее ускорение этого листа — предмет AURORA-1847 (SQL листа).
После PR A полное время ни одного запроса из набора `api_split.py` SHALL NOT вырасти больше чем на 5 % от версии до изменения. Доля Python (`(полное − X-DB-Time-Ms) / полное`) — справочная метрика, порогом она не является.
Пороги этого требования и требования «Размер ответа листа FORM_2/AHR уменьшается» — критерии приёмки задачи, а не условие мержа PR. PR вливается по зелёным тестам и доказанной корректности (требования «Значения ответа совпадают с прежними» и «Ответ листа и сводов без null-полей»: сравнение ответов на стенде в режимах exact и strip-nulls). Корректность — жёсткое условие. Цифры замера дописываются в описание PR, недобор порога оформляется follow-up задачей.
#### Scenario: Замер свода 2 GO
- **WHEN** `perf/analysis/api_split.py --scale 1 --repeats 10` запускается на версии до изменения и после PR B на одних и тех же данных
- **THEN** медиана `total_ms` свода 2 GO «после» не больше 0,7 × медианы «до»
#### Scenario: Замер листа FORM_2/AHR
- **WHEN** тот же замер для листа FORM_2/AHR
- **THEN** медиана `total_ms` «после» не больше 0,7 × медианы «до»
- **THEN** медиана `total_ms` «после» не больше 0,9 × медианы «до»
#### Scenario: PR A не замедляет
- **WHEN** тот же замер на версии после PR A
- **THEN** медиана `total_ms` каждого запроса набора не больше 1,05 × медианы «до»
#### Scenario: Недобор порога
- **WHEN** тесты зелёные, сравнение ответов на стенде прошло, а замер показал недобор порога
- **THEN** PR вливается, цифры фиксируются в описании PR, недобор оформляется follow-up задачей
### Requirement: Размер ответа листа FORM_2/AHR уменьшается
После PR B распакованный размер ответа `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 на стенде (масштаб 1) SHALL быть меньше, чем до изменения (критерий Jira AURORA-1848).
После PR B распакованный размер ответа `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 на стенде (масштаб 1) SHALL быть меньше, чем до изменения (критерий Jira AURORA-1848; вместе с −10 % времени — критерий по этому листу, согласовано 28.10).
#### Scenario: Сравнение размеров
- **WHEN** `api_split.py --scale 1` запускается до изменения и после PR B