245 lines
48 KiB
Markdown
245 lines
48 KiB
Markdown
# Design: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей (AURORA-1848, #233)
|
||
|
||
Мотивация и цифры — в proposal.md («Why»). Требования — в `specs/sheet-read-api/spec.md` и `specs/realtime-sheet-updates/spec.md`. Здесь описано, как их выполнить.
|
||
|
||
## Context
|
||
|
||
Ветка `test` @ 2a28748.
|
||
|
||
**Путь ответа сейчас** (FastAPI 0.111, pydantic 2.11, SQLAlchemy 2 + asyncpg):
|
||
1. Роутер замеряет `t0` и вызывает сервис, сервис вызывает репозиторий, репозиторий выполняет `SELECT row_type, depth, sort_order, data FROM v3.<функция>(...)`. Для jsonb SQLAlchemy регистрирует в asyncpg кодек с `json.loads`, поэтому каждая строка `data` разбирается в dict ещё до возврата из `execute`. Этот разбор попадает в окно `X-DB-Time-Ms`.
|
||
2. Роутер ставит `response.headers["X-DB-Time-Ms"]` и пишет лог `db_time`. Затем строит `SheetResponse` на каждую строку: `_rows_to_sheet_response` в `forms.py:27-35` и `summary.py:29-37`, `_rows_to_payload` в `projects.py:60-64` (dict, но всё равно через `response_model`).
|
||
3. Аннотация возврата `-> BaseListResponse[SheetResponse]` становится `response_model`. FastAPI валидирует результат, сериализует его, `JSONResponse` делает `json.dumps`, `GZipMiddleware(minimum_size=1000, compresslevel=1)` сжимает (`main.py:248`).
|
||
|
||
**Затронутые обработчики:**
|
||
|
||
| эндпоинт | роутер | сервис → репозиторий → SQL |
|
||
|---|---|---|
|
||
| GET `/form/{id}/sheet/{sheet}` | `forms.py:150-202` `get_sheet` | `SheetService.get` → `SheetRepository.get` (`sheet_repository.py:33-68`) → `v3.v_form_view(form, sheet, sections[, direction])` |
|
||
| GET `/svod/1/{sheet}/{year}` | `summary.py:50-90` | `SummaryService.get_svod1_rows` (проверка орг. `_allowed_org_ids`) → `SummaryRepository.get_svod1_rows` → `v3.v_summary_view` |
|
||
| GET `/svod/2/{sheet}/{year}` | `summary.py:93-146` | `get_svod2_rows` → `v3.v_svod2_go` / `v3.v_svod2_rf` (row_type и depth — константы в SELECT) |
|
||
| GET `/projects/{id}/report/{year}/{type}` | `projects.py:242-277` | `ProjectService.get_report_rows` (`resolve_report_id`) → `v3.v_form3_report_jsonb` |
|
||
| GET `/rf-rollup/{branch}/{year}` | `projects.py:280-312` | `get_rf_rollup_rows` → `v3.v_form3_rf_rollup_jsonb` |
|
||
| GET `/projects/{id}/summary` | `projects.py:315-338` | `get_project_summary_rows` → `v3.v_project_summary_jsonb` |
|
||
| GET `/projects/{plan,fact,detail}-summary` | `projects.py:142-226` + `_get_projects_summary_response` `:117-139` | `get_*_summary_rows` → `v3.v_project_{plan,fact,detail}_summary_jsonb` |
|
||
|
||
У всех функций столбец `data` имеет тип `jsonb`.
|
||
|
||
**Кто ещё читает те же данные** и не должен измениться:
|
||
- выгрузка Excel: `export_service.py:219, 236` — `SummaryService.get_svod1_rows/get_svod2_rows`; `:438` — `SheetService.get`; `:484, :561` — `ProjectService.get_report_rows`;
|
||
- WebSocket (`websocket.py`) целые листы через эти методы не читает. Он вызывает пути записи (`update_cell`, `add_line`, …), и они здесь не меняются;
|
||
- инструменты `perf/` читают GET листа: `perfkit/coauthor.py:52, :73`, `perfkit/gen/grow.py:45-67`, `perfkit/gen/run.py:248, 304, 467, 504-506`.
|
||
|
||
**Фронт.** Начальная загрузка листа и отчёта проекта — `useRealtimeData.js:36-58`, в обоих случаях потом используется `buildRowHierarchy`. Входящие правки идут через `updateCells` (`cellUtils.js:46-100`). На `test` правка вливается в блок `data[key]`, только если `row.data[key] !== undefined` (`:79`), иначе она молча теряется. PR #223 (AURORA-1838, открыт, не в `test`) переписывает `updateCells`: отсутствующий ключ вставляется (`mergeIntoRow`, ветка `else if (hasData)`), а глубже `mergeImmutable` и раньше создавал недостающие узлы.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
- Тело тяжёлых GET собирается из текста, который вернул Postgres, без разбора в dict и без модели на строку (PR A).
|
||
- `data` листа и сводов без null-полей, фронт не теряет правки соавторов (PR B).
|
||
- Доказательства: сравнение ответов до и после на стенде, тесты эквивалентности, замер `api_split.py`.
|
||
|
||
**Non-Goals:**
|
||
- Менять SQL-функции, данные в `data` (это делает AURORA-1846) и SQL листа FORM_2/AHR (это AURORA-1847).
|
||
- Постраничная загрузка свода 2 и листов (М10), смена уровня gzip, переход на orjson или другой сериализатор для остальных эндпоинтов.
|
||
- Менять ответы путей записи и сообщения WebSocket, выгрузку Excel.
|
||
- Убирать null из отчётов проектов (только PR A).
|
||
|
||
## Decisions
|
||
|
||
### Решения человека (согласовано 28.10)
|
||
1. GET листов и сводов потребляет только наш фронт. Выгрузка Excel и WebSocket идут своими путями (через репозитории и сервисы) и не меняются. Старые методы репозиториев для них остаются.
|
||
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 % (для листа уточнено в п. 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`
|
||
Новый метод репозитория выполняет:
|
||
```sql
|
||
SELECT json_build_object(
|
||
'row_type', row_type, 'depth', depth,
|
||
'data', data, -- PR B: jsonb_strip_nulls(data)
|
||
'sort_order', sort_order)::text
|
||
FROM v3.<функция>(<те же аргументы, что в старом методе>)
|
||
```
|
||
Для свода 2 `row_type` и `depth` — те же константы, что в старом SELECT (`'INPUT'`, `3`).
|
||
- `json_build_object` (тип `json`) сохраняет порядок ключей обёртки строки. Аргумент jsonb он выводит своим текстовым представлением, то есть `data` сериализуется один раз.
|
||
- `::text` даёт тип `text`, и кодек `json.loads` у asyncpg не срабатывает.
|
||
- Порядок строк — порядок вывода функции, без `ORDER BY`, как сейчас.
|
||
|
||
Отвергнуто:
|
||
- `jsonb_build_object(...)::text`: пересобирает `data` во внутреннее представление jsonb и сортирует ключи обёртки (`data, depth, row_type, sort_order`). Это лишняя работа на 30–50 МБ.
|
||
- `format('{"row_type":%s,...}', ...)`: хрупко (экранирование строк, `NULL` → пустая подстановка), а выигрыш только в пробелах.
|
||
- `string_agg` всего ответа в одну строку в SQL: выигрыш на тысячах строк ничтожен, а `count` и порядок придётся выражать отдельно.
|
||
- Сериализация в Python через orjson без моделей: остаётся `json.loads` в драйвере, и нужна новая зависимость.
|
||
|
||
### D2. Новые методы рядом со старыми
|
||
- Репозитории: `SheetRepository.get_json`, `SummaryRepository.get_svod1_json` / `get_svod2_json`, `ProjectRepository.get_report_json`, `get_rf_rollup_json`, `get_project_summary_json`, `get_{plan,fact,detail}_summary_json`. Параметр `strip_nulls: bool = False` добавляется в PR B только к методам листа и сводов. Возврат — `list[str]`.
|
||
- Сервисы: методы `*_json` с теми же проверками, что у старых (`_allowed_org_ids`, `_allowed_summary_branch_ids`, `resolve_report_id`, `get_instance`, `AccessDeniedException`, ранний `[]` при пустом списке организаций).
|
||
- Старые методы (`get`, `get_svod1_rows`, `get_svod2_rows`, `get_report_rows`, …) **не меняются ни в одной строке**: их используют выгрузка и сравнительные тесты.
|
||
- Дублирование FROM-части устраняет исполнитель: вынести построение FROM-фрагмента и параметров в приватную функцию, которую вызывают и старый, и новый метод. Текст SQL старого метода после выноса должен остаться эквивалентным. Валидация `SheetRepository.get` (`:40-49`, `SheetValidationError`) обязательна и в `get_json`.
|
||
|
||
### D3. Хелпер ответа и роутеры
|
||
Новый модуль `api/src/api/v1/json_response.py`:
|
||
```python
|
||
def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response:
|
||
body = b'{"success":true,"message":null,"result":[' + ",".join(rows).encode() + b'],"count":' + str(len(rows)).encode() + b"}"
|
||
return Response(content=body, media_type="application/json", headers=headers)
|
||
```
|
||
Точная форма кода — на усмотрение исполнителя. Инварианты: обёртка ровно как у `BaseListResponse` при успехе (`success`, `message`, `result`, `count`), UTF-8, `media_type="application/json"` (без `charset`, как у `JSONResponse`).
|
||
|
||
Роутеры (`get_sheet`, `get_summary`, `get_summary2`, шесть GET в `projects.py`):
|
||
- аннотация возврата меняется на `Response`, а `response_model=BaseListResponse[SheetResponse]` переносится в декоратор `@router.get(...)`. Схема OpenAPI остаётся прежней (требование «Схема OpenAPI не меняется»);
|
||
- **`X-DB-Time-Ms` ставится на возвращаемый `Response`** (`headers=`). Заголовки, записанные в инъецированный параметр `response: Response`, FastAPI переносит только при возврате не-Response значения. Параметр `response` из сигнатуры убрать;
|
||
- окно замера то же: от `t0` до возврата строк из сервиса. Лог `db_time` с прежними полями;
|
||
- проверки до запроса (404 формы и листа, `_validate_sheet_query_params`, недопустимые `sections`, список листов свода) не меняются;
|
||
- `_rows_to_sheet_response` в `forms.py` остаётся: её используют PATCH, POST и DELETE. В `summary.py` она становится неиспользуемой и удаляется. `_rows_to_payload` в `projects.py` остаётся для правок FORM_3.
|
||
|
||
`StreamingResponse` не используется (см. «Decisions made without discussion»).
|
||
|
||
### D4. PR B: `jsonb_strip_nulls` для листа и сводов
|
||
- В `get_json`, `get_svod1_json`, `get_svod2_json` выражение `data` заменяется на `jsonb_strip_nulls(data)`. Роутеры листа и сводов вызывают методы со `strip_nulls=True`. Отчёты проектов — нет.
|
||
- Семантика PG 15: функция рекурсивно удаляет поля объектов со значением `null`, в том числе в объектах внутри массивов. Элементы массивов, равные `null`, остаются, пустые объекты `{}` остаются. Строка META (`editable[].closes_at = null`) тоже теряет null-поля, фронт читает из неё только `editable[].column` (`useRealtimeData.js:49-50`).
|
||
- Обёртка строки (`row_type`, `depth`, `sort_order`) не трогается: `sort_order` может остаться `null`.
|
||
|
||
### D5. PR B: фронт
|
||
1. **`updateCells`.** Предусловие B — PR #223 смержен в `test`, и ветка B включает его (rebase на `test` после merge A и #223). B проверяет, что ветка `else if (hasData)` в `mergeIntoRow` и `mergeImmutable(undefined, …)` вставляют отсутствующий блок и отсутствующий лист, и добавляет тесты в `web/src/components/RealtimeTable/utils/cellUtils.test.js`:
|
||
- строка без блока `q1`, приходит `{q1: {m1: 5}}` → `row.data.q1.m1 === 5`;
|
||
- блок `q1` есть, но без `m1`, приходит `{q1: {m1: 5}}` → значение вставлено, остальные поля `q1` сохранены;
|
||
- строка с `q1.m1 = 5`, приходит `{q1: {m1: null}}` → `row.data.q1.m1 === null`;
|
||
- отсутствующий скалярный ключ верхнего уровня `data` (например, `line_id`) вставляется.
|
||
|
||
Если какой-то тест падает на версии #223, правка вносится в `cellUtils.js` в B.
|
||
2. **Отправка правки с отсутствующим значением (найдено при сверке).** `EditCell.handleSave` (`EditCell.jsx:117-148`) при выходе из редактирования всегда вызывает `onChange(normalizeNumericInput(currentValue))`, а `RealtimeContext.updateCell` (`RealtimeContext.js:304-330`) кладёт значение в `data.value`. Для отсутствующего ключа `currentValue === undefined`, `JSON.stringify` опускает `value`, и сервер падает на `event_data["value"]` (`websocket.py:472`, для FORM_3 — `:703`): пользователь получит ошибку там, где раньше уходил `null`. Исправление на фронте: в `updateCell` нормализовать `undefined` в `null` (`value: value === undefined ? null : value`), чистую функцию вынести в `.js` и покрыть тестом. Сервер не трогаем (решение 1: WebSocket не меняется).
|
||
3. **Аудит отличий `null` от `undefined`** на страницах листа, отчёта проекта, свода 1 и свода 2. При сверке проверено: `validation.js:94-209`, `Cell.jsx:149-156`, `tableColumns.jsx:88`, `SummaryTable.jsx:25, 71-72`, `Svod2Page.jsx:38-39, 354-365`, `SvodPage.jsx:53-54`, `RealtimeTable.jsx:48-57`, `rowUtils.js` — все обрабатывают и `undefined`. Сортировка на клиенте в сводах выключена (`enableSorting: false`). Проверок `in` / `hasOwnProperty` по `data` нет. Исполнитель повторяет поиск (`=== null`, `!== null`, `typeof … === 'object'`, `in `, `hasOwnProperty`, `Object.keys/entries` по `data`) по `web/src` на ветке B, потому что за время работы в `test` могли влиться другие PR, и фиксирует результат в описании PR.
|
||
4. **Потребители в `perf/`.** `perfkit/gen/grow.py:67` (`r["data"]["header"]["project_id"]` для ITEM) и аналогичные обращения через `[...]` к полям, которые могут быть `null`, заменить на `.get(...)`, чтобы генератор стенда не падал на ответах B.
|
||
|
||
### D6. Тесты
|
||
**Unit (`api/tests/unit/test_json_list_response.py`, PR A):**
|
||
- тело хелпера разбирается `json.loads` в `{"success": True, "message": None, "result": [...], "count": n}`;
|
||
- пустой список, одна строка, кириллица и экранированные символы в строке;
|
||
- `media_type`, переданные заголовки.
|
||
|
||
**Интеграционные (`api/tests/integration/test_heavy_get_json.py`, PR A, дополняются в B).** Эталон — старый путь в той же сессии: `client.portal.call(...)` поверх `SheetRepository(db_session).get(...)`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, превращённый в `[{"row_type", "depth", "sort_order", "data"}]`. Так же устроен тест AURORA-1846, см. ниже про `db_session`.
|
||
- лист: FORM_1 AHR Support (форма 1 фикстуры), лист без `direction`, вариант с `sections`. `response.json()["result"] == эталон` (A) или `== strip_nulls(эталон)` (B). `count == len(result)`;
|
||
- свод 1 по всем `VALID_SUMMARY_SHEETS`, свод 2 GO/RF без параметров, с `org_ids`, с `offset/limit`, с `sort_by/sort_direction`;
|
||
- отчёты проектов (A): проект создаётся в тесте, как в `test_projects_api_smoke.py`, сравниваются report, project summary и plan/fact/detail-summary. В B — проверка, что `null` в отчётах остались;
|
||
- заголовок `X-DB-Time-Ms` есть и разбирается как число на всех девяти эндпоинтах;
|
||
- роль: `isp_tokens` получает свод только по своим организациям, как и через старый путь;
|
||
- OpenAPI: у девяти путей схема ответа 200 ссылается на ту же компоненту, что в эталонном снимке. Снимок снимается на базовом коммите и кладётся в тест как литерал (имя компоненты);
|
||
- B: PATCH ячейки со значением `null` → в строке ответа ключ присутствует со значением `null` (требование «Ответы на правку отдают null явно»);
|
||
- рекурсивный эталон `strip_nulls` на Python пишется в тесте так же, как работает PG: удаляются поля объектов, `null` в массивах остаются, `{}` остаётся.
|
||
|
||
Сравнение чисел: эталон приходит через `json.loads` драйвера (float), новый ответ — через `response.json()` (float). Равенство точное.
|
||
|
||
Фикстура `db_session`. AURORA-1846 (PR #232) добавляет её в `api/tests/integration/conftest.py` (`client` начинает зависеть от `db_session`). Если к началу работы #232 не в `test`, внести в A **тот же самый хунк**: при одинаковых правках git сливает без конфликта.
|
||
|
||
Существующие тесты: `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):**
|
||
- на FORM_2/AHR второй пользователь заполняет пустую ячейку, первый видит значение без перезагрузки;
|
||
- то же с очисткой;
|
||
- вход в пустую ячейку и выход без ввода — без ошибки;
|
||
- свод 2: пустые ячейки показывают «—», поиск работает;
|
||
- отчёт проекта не изменился.
|
||
|
||
Сценарий соавтора на стенде — после ночного прогона: `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0`.
|
||
|
||
### D7. Замер и сравнение на стенде
|
||
Замеры на стенде выполняет контроллер после основных ночных замеров. В плане это точка ожидания. PR A и B открываются без цифр, цифры дописываются в описание PR после замера. Пороги не блокируют мерж (решение человека 8), а сравнение ответов (п. 1 ниже) — обязательное доказательство корректности до мержа. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`.
|
||
|
||
Данные: масштаб 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` по имени запроса;
|
||
- `compare <before> <after> --mode exact|strip-nulls` сравнивает `json.loads` ответов. В режиме `strip-nulls` сравниваются `after` и рекурсивно очищенный `before`, но только для листов и сводов, отчёты проектов сравниваются точно. Вывод — число совпавших и список расхождений с путём до первого отличия, код возврата ≠ 0 при расхождении.
|
||
|
||
Порядок: `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: порог «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`.
|
||
3. **`api_split.py`, PR A:** добавить поле `wire_bytes` (`r.num_bytes_downloaded`, размер на проводе) и колонку в `.md`. Существующие поля и их смысл не меняются.
|
||
4. Ссылка на `X-DB-Time-Ms` в docstring `api_split.py` (номера строк `forms.py:184` и др.) обновляется на новые места.
|
||
|
||
Оценка (не замер): разбор jsonb драйвером синтетически ≈ 8 мс/МБ. Это ≈ 60 мс из 726 мс `X-DB-Time-Ms` листа FORM_2/AHR и ≈ 240 мс из 718 мс свода 2 GO.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **[Порог для 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` (сценарий в спеке).
|
||
- **[Потеря правок соавторов в B]** → Предусловие #223, тесты `cellUtils.test.js`, ручной сценарий. B не мержится раньше #223.
|
||
- **[`value` пропадает из сообщения WebSocket в B]** → Нормализация `undefined → null` в `updateCell` (D5.2) и тест.
|
||
- **[Скрытые проверки `=== null` на фронте]** → Аудит (D5.3) с перечнем проверенного, ручной чек-лист.
|
||
- **[В dev-сборке TanStack Table пишет `console.warn` для `undefined` во вложенном `accessorKey`]** В prod-сборке этого нет. Для `null`-блоков предупреждение появлялось и раньше. → Не исправляем, отмечаем в PR B.
|
||
- **[Выгрузка или WebSocket случайно переходят на новые методы]** → Старые методы не меняются. В ревью — дифф `export_service/` и `websocket.py` должен быть пустым (кроме D5.2, это фронт). Интеграционные тесты выгрузки остаются зелёными.
|
||
- **[Пиковая память]** Тело ответа 30–50 МБ (масштаб 2 — до ~200 МБ) собирается одним `bytes`, плюс список строк того же объёма. Это меньше, чем дерево dict, pydantic-модели и строка `json.dumps` сейчас. → Приемлемо, стриминг не нужен.
|
||
- **[Конфликты с параллельными PR]** `conftest.py` с #232 (одинаковый хунк), `cellUtils.js` с #223 (B после #223). Миграций нет, конфликта номеров 0050 у этого change нет.
|
||
|
||
## Migration Plan
|
||
|
||
- Один план 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 возвращает модели. Данные не затрагиваются.
|
||
|
||
## Decisions made without discussion
|
||
|
||
| Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|
|
||
| 2026-10-28 | Строка собирается `json_build_object(...)::text`, а не `jsonb_build_object` и не `format()` (D1) | Порядок ключей обёртки сохраняется, `data` сериализуется один раз, нет ручного экранирования | Низкая: замена выражения в одном SQL-фрагменте |
|
||
| 2026-10-28 | Ответ собирается из списка строк (по строке на ряд) в Python, а не одной `string_agg` в SQL | `count` и порядок как сейчас, проще эквивалентность; разница в стоимости ничтожна при тысячах строк | Низкая |
|
||
| 2026-10-28 | Обычный `Response`, без `StreamingResponse` | Сессия БД не держится открытой на время отправки; GZipMiddleware и Content-Length работают как раньше; память ниже текущей | Средняя только на масштабе 2+ (пиковая память); переделка локальна в хелпере |
|
||
| 2026-10-28 | Имена новых методов `*_json` и параметр `strip_nulls` в репозиториях листа и сводов | Явная пара «старый метод для выгрузки / новый для GET»; одно место включения B | Низкая: переименование |
|
||
| 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 % свод 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 | Доказательство эквивалентности на всех листах и сводах стенда требует повторяемой команды | Низкая |
|
||
| 2026-10-28 | Фикстура `db_session` вносится тем же хунком, что в AURORA-1846, если #232 ещё не в `test` | Тесту эквивалентности нужен старый путь в той же сессии; одинаковый хунк сливается без конфликта | Низкая: ручное слияние conftest |
|
||
| 2026-10-28 | PR B не мержится раньше PR #223 (AURORA-1838); при задержке #223 B ждёт, а не дублирует `updateCells` | Две реализации `updateCells` дадут конфликт и двойное ревью | Задержка B до #223 |
|
||
| 2026-10-28 | Пустое значение правки нормализуется в `null` на фронте (`updateCell`), сервер WebSocket не меняется (D5.2) | Человек зафиксировал «WebSocket не меняется»; дефект проявляется только из-за B | Если есть другой клиент WebSocket, он сохранит старое поведение (его нет) |
|
||
| 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 ловит контроллер до мержа |
|
||
| 2026-10-28 | (план) Ветка B вливает ветку PR #223 (`feat/212-aurora-1838-ws-immutable-update`) merge-коммитом в начале фазы B, а не ждёт merge #223 в `test`; после merge #223 и A ветка B перебазируется на `test` | Человек задал «между фазами без остановки», а тесты фронта B без `updateCells` из #223 красные; merge-коммит не дублирует код #223 и уходит из диффа при rebase | Средняя: PR B до rebase показывает дифф #223; если #223 изменится на ревью — повторный merge или rebase B |
|
||
| 2026-10-28 | (план) Фаза B — в отдельном worktree `.claude/worktrees/aurora-1848-b`, а не переключением ветки в worktree A | Правки по ревью PR A (H-n) идут в A параллельно с работой над B | Низкая: лишний worktree, удаляется при закрытии |
|
||
| 2026-10-28 | (план) Красный шаг TDD для GET — тест «GET не вызывает старый метод» (monkeypatch старого метода репозитория на исключение); тесты эквивалентности со старым путём зелёные и до правки, они — страховка | Эквивалентность по определению не отличает старый код от нового; без такого теста шаг «падает» невозможен | Низкая |
|
||
| 2026-10-28 | (план) Общий модуль `api/src/repository/row_json.py` (`ROW_COLUMNS`, `row_json_select`, `fetch_row_texts`); `strip_nulls` добавляется в `row_json_select` в фазе B | Одно место для выражения строки (D1) и для включения B (D4) во всех репозиториях | Низкая: перенос функции |
|
||
| 2026-10-28 | (план) Нормализация `undefined → null` вынесена в `web/src/components/RealtimeTable/utils/messageUtils.js` (`buildCellUpdatedMessage`) | `RealtimeContext.js` импортирует React и не тестируется `node --test`; D5.2 требует чистую функцию в `.js` | Низкая |
|
||
| 2026-10-28 | (план) `compare_responses.py` не снимает `rf-rollup`: в манифесте стенда нет `branch_id`; эндпоинт покрыт интеграционным тестом эквивалентности | Требование «все листы, своды, отчёты проектов по манифесту» выполнимо только для того, что есть в манифесте | Низкая: регрессия rf-rollup на данных стенда не видна сравнению |
|
||
| 2026-10-28 | (план) В режиме `strip-nulls` `compare_responses.py` чистит только `data` строк (обёртка, `sort_order`, `message` не трогаются); `dump` пишет статусы в `index.json`, `compare` сравнивает и статусы | Спека: «из `data` которого рекурсивно удалены поля со значением null»; ошибки тоже должны совпадать | Низкая |
|
||
| 2026-10-28 | (план) Финальное ревью opus — по одному на каждую ветку фазы: A до PR A, B (без коммитов #223) до PR B | Два PR ревьюются и мержатся раздельно | Низкая: два прогона opus вместо одного |
|
||
| 2026-10-28 | (план) Проверка PG15 главным контроллером не блокирует открытие PR, блокирует merge; в PR A пишется `Part of #233`, `Closes #233` — в PR B | Замеры и PG15 у главного контроллера, PR открываются без них; issue закрывается только всем объёмом (A+B) | Низкая |
|
||
| 2026-10-28 | R-1 (design #1): тест экранирования листа вставляет `budget_line` (form 4, `name` = строка с кавычкой, обратным слэшем, переводом строки, табом и кириллицей) вместо `UPDATE justification` из плана; сравнение по декодированному значению `data.header.name` | В фикстуре у FORM_2 нет строк `budget_line`, `UPDATE` ничего не меняет; в `data` листа `name` попадает в `header.name` | Низкая: слабее покрытие экранирования на других полях `data` |
|
||
| 2026-10-28 | R-2 (design #2): фикстура `svod2_plan_line` (в откатываемой сессии теста) вместо запасного `POST .../line` из плана | `POST` на FORM_2 даёт 400 или строку с нулевым планом, которую свод 2 пропускает; без данных сравнения свода 2 пусты | Низкая: привязка к идентификаторам фикстуры `fixture.sql` |
|
||
| 2026-10-28 | R-3 (design #3): для `rf-rollup` вместо сравнения с старым путём — тест фиксации ответа об ошибке; `_add_line` в тестах проектов не реализован | `v3.v_form3_rf_rollup_jsonb` на `test` обращается к несуществующей колонке `r.year` и всегда отдаёт 400 (O-3, вне объёма, миграции запрещены); POST строки проекта даёт 400/409 и рвёт транзакцию теста. Исправляет прежнее утверждение выше о покрытии `rf-rollup` интеграционным тестом эквивалентности: успешный путь `rf-rollup` не покрыт ничем до исправления O-3 | Средняя: регрессия успешного пути `rf-rollup` невидна; follow-up на исправление `r.year` и перевод теста в эквивалентность |
|
||
| 2026-10-28 | R-4 (design #4): мок в `test_api_split.py` использует `stream=httpx.ByteStream(gz)` вместо `content=gz` | С `content=` httpx не считает `num_bytes_downloaded`, `wire_bytes` был бы 0 | Нет: только тест |
|
||
| 2026-10-28 | R-5 (design #5): после ревью задачи 7 в `compare_responses.py` `dump` принимает готовый `httpx.Client`, пишет `index.json` в `finally` и записывает сетевые ошибки как `error: <Exc>`; `compare` сравнивает не-JSON тела побайтно | Ночной `dump` на стенде идёт без присмотра; падение на одном запросе не должно терять остальные | Низкая |
|