Andrey Epifancev b701a4f5ff docs(openspec): aurora-1848-sheet-json — proposal, spec, design
Тяжёлые GET листов, сводов и отчётов проектов: готовый JSON из Postgres
без pydantic-модели на строку (PR A) и без null-полей в data листа и
сводов с правкой фронта (PR B). Требования — sheet-read-api и
realtime-sheet-updates; дизайн, риски, тесты и сценарий замера — design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:24:23 +04:00

36 KiB
Raw Blame History

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 %. «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок X-DB-Time-Ms должен сохраниться: на нём держится методика perf/analysis/api_split.py.
  6. response_model в декораторах остаётся ради OpenAPI.

D1. Строка ответа собирается в SQL функцией json_build_object

Новый метод репозитория выполняет:

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:

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 его нужно оговорить.

Фронт (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. Замер и сравнение на стенде

Стенд не трогать до окончания ночного прогона. Замеры не идут параллельно с другими замерами и тестами на perf-pg: общий pg_stat_statements.

Данные: масштаб 1, одна и та же БД perf для «до» и «после» (восстановить из дампа s1 перед серией). Миграции «до» и «после» одинаковые: у A и B миграций нет. Если между замерами в test влилась миграция (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 и листа FORM_2/AHR: порог «B ≤ 0,7 × до»;
    • 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

  • [Порог 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 мс остатка. → Это вопрос человеку (см. отчёт автора дизайна). До ответа спека фиксирует согласованный порог. Замер даёт отдельной строкой полное время и разложение, чтобы решение принималось по цифрам.
  • [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

  • Порядок: PR A → merge в test → PR B (ветка от A, rebase на test после merge A и #223) → merge.
  • Миграций БД нет, деплой — обычный выкат 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 % проверяется на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе api_split.py» Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа Если порог нужен уже на A — B станет обязательным для приёмки A
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 остаётся (её используют пути записи) Мёртвый код Нет