Тяжёлые 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>
36 KiB
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):
- Роутер замеряет
t0и вызывает сервис, сервис вызывает репозиторий, репозиторий выполняетSELECT row_type, depth, sort_order, data FROM v3.<функция>(...). Для jsonb SQLAlchemy регистрирует в asyncpg кодек сjson.loads, поэтому каждая строкаdataразбирается в dict ещё до возврата изexecute. Этот разбор попадает в окноX-DB-Time-Ms. - Роутер ставит
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). - Аннотация возврата
-> 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)
- GET листов и сводов потребляет только наш фронт. Выгрузка Excel и WebSocket идут своими путями (через репозитории и сервисы) и не меняются. Старые методы репозиториев для них остаются.
- Два PR. A — готовый JSON из Postgres (
jsonb::text) без pydantic-модели на строку. Ответ по смыслу тот же, это доказывается сравнением ответов всех листов и сводов на стенде до и после. B — без null-полей вdata(jsonb_strip_nulls) и правка фронта, чтобы входящие правки соавторов по отсутствующим ключам не терялись. В B проверить, чтоupdateCellsиз AURORA-1838 (PR #223) для этого достаточно, и покрыть тестом. B стеком на A. - Ответ на правку ячейки (PATCH
/cell(s)и WebSocketcell_updated) продолжает отдаватьnullявно: очистка ячейки должна дойти до соавторов. - Отчёты проектов РФ (
projects.py,SheetResponse) — тем же хелпером, отдельным коммитом в A. - Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 %. «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок
X-DB-Time-Msдолжен сохраниться: на нём держится методикаperf/analysis/api_split.py. 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: фронт
-
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. - строка без блока
-
Отправка правки с отсутствующим значением (найдено при сверке).
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 не меняется). -
Аудит отличий
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. -
Потребители в
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), серия «до» переснимается на новой базе.
-
Сравнение ответов — новый скрипт
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(с дампом «до»). -
Время.
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 × до»;bytesFORM_2/AHR: «B < до»;- справочно
python_shareи новое полеwire_bytes.
-
api_split.py, PR A: добавить полеwire_bytes(r.num_bytes_downloaded, размер на проводе) и колонку в.md. Существующие поля и их смысл не меняются. -
Ссылка на
X-DB-Time-Msв docstringapi_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-Ms726 мс, остаток 87 мс. Эта задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше). Ожидаемое снижение — порядка 12–20 %, для 30 % нужно ≤ 569 мс. Основное время — SQLv_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 остаётся (её используют пути записи) |
Мёртвый код | Нет |