Свод 2: серверная постраничная загрузка (поиск, три фильтра, total, варианты фильтров) и компактный ответ; фронт догружает строки окном страниц; замеры памяти, времени до первых строк, байтов и шага скролла. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63 KiB
Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ
Мотивация и цифры — в proposal.md, требования — в specs/summary2-api/spec.md и specs/summary2-screen/spec.md. Ссылки file:line ниже даны по вершине ветки feat/236-aurora-1843-svod2-server-paging: это 399234a, вершина AURORA-1842.
Контекст: как устроено сейчас
API. GET /api/v1/svod/2/{sheet}/{year}:
- обработчик —
api/src/api/v1/summary.py:93-146. Он уже принимаетorg_ids,offset,limit(:99-100),sort_byиsort_direction; count = len(rows)(:143-146);- ответ собирается через pydantic-модель на каждую строку (
_rows_to_sheet_response,:28-36), аresponse_modelвыводится из аннотации-> BaseListResponse[SheetResponse].
Сервис SummaryService.get_svod2_rows (api/src/services/summary_service.py:31-53):
- через
_allowed_org_ids(:73-88) пересекает запрошенные организации с разрешёнными; - для администратора
Noneозначает «все»; - для остальных пустое пересечение даёт
[].
Репозиторий — api/src/repository/summary_repository.py:34-77, это SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов).
SQL. Актуальные версии v3.v_svod2_go и v3.v_svod2_rf лежат в api/alembic/versions/sql/0049_summary2_related_records.sql:6-303 и :305-595. Каждая собрана из CTE:
eligible_lines—DISTINCTпо 13 LEFT JOIN «наличия данных». GO берёт FORM_1 и FORM_4 (:71), RF — FORM_2 (:369). Значения вычисляются тут же:smeta_typeизei.sheet,org_name = ou.title;numbered_lines—row_number()поorg_name/item_idилиsection_code, затемform_type_code,form_id,line_id(:151-168);page AS MATERIALIZED—LIMIT p_limit OFFSET p_offset(:169-173);- сборка большого
jsonbтолько для строк страницы (:179-302).
Значит, стоимость страницы — это полный отбор плюс сборка jsonb только для этой страницы. v3.v_svod2_go_export (:597-902) — отдельная компактная функция для Excel GO, её не трогаем. Прежние перегрузки этих функций уже удалялись DROP FUNCTION IF EXISTS по сигнатуре: 0022_summary2_contract_rows.sql:11-13, :200-202.
Кто зовёт GET /svod/2/... и get_svod2_rows:
- фронт — только
Svod2PageчерезSvodContext.getSummary→SummaryApi.get(web/src/api/summary.js:4-7, безoffset/limit); - Excel RF —
export_service.py:236-245,offset=None,limit=None; - интеграционные тесты —
api/tests/integration/test_summary_api_smoke.py:44-140; - стенд:
perfkit.gen.verify(perf/perfkit/gen/verify.py:146-148),perfkit.bench(повторяет URL из сетевого лога),perf/analysis/api_split.py:51-52,perf/analysis/scale_compare.py:43-44.
Фронт. web/src/pages/Svod2Page/Svod2Page.jsx:
- состояние вкладок
tableStateBySheetдержитrowsцеликом (:21-22,:302,:345-348); loadSummary(:335-367) берёт ответ из кэшаSvodContext(web/src/pages/SvodPage/SvodContext.jsx:46-70). Ключ кэша — лист + год + организации, кэш очищается при размонтировании провайдера маршрута/svod/2(web/src/app/Routes.jsx:54-59).SvodContextиSvodFiltersобщие со сводом 1;- поиск —
matchesSearch(:283-294) по{...header, ...contract_summary, ...contract_detail}, фильтрация вfilteredRows(:387-391), debounce ввода 300 мс вSvodFilters.jsx:68-77; - три колоночных select-фильтра —
smeta_type,smeta_direction,org_name(:158-176) — используютenableFacetedValues(:400). Сортировка выключена (:411); - «Найдено N из M» (
:502-506) считает только поиск, колоночные фильтры не учитывает; - изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (
updateCurrentSheetFilters,:317-323); - данные без «Загрузить» не грузятся (
:448-455). «Все ССП/РФ» даётselectedOrganizationIds = [](:308-311), сервер подставляет все разрешённые; - виртуализация строк —
ROW_VIRTUALIZER_OPTIONS(:26-31:ROW_HEIGHT = 37,measureElementпоoffsetHeight). Виртуализация колонок из 1842 —:33-34,:407-408; - кнопки Excel-выгрузки свода 2 на фронте нет: по
web/srcнет ниexport/summary, ниsummary/2.
Стенд. perf/e2e/scenarios/scroll.spec.ts:
- для свода 2 кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (
:194-200); - ждёт
MIN_ROWSстрокtbody tr(waitRows); json_bytes_sheet— максимум байтов одного ответа поrespRe(:176-184);- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (
armStable/awaitStable,:113-150,:221-227); - времени до первых строк сценарий не пишет;
- сценария памяти для сводов нет:
memory.spec.tsих не открывает.
perf/analysis/explain_manual.py функции свода 2 не использует (запросы — только FORM_2/AHR и FORM_4/CAP, :59-81), поэтому смена сигнатуры его не ломает.
Тестовые данные. В api/tests/sqls/fixture.sql за 2026 год нет строк, проходящих отбор свода 2: единственная строка FORM_1 относится к 2027 году и без данных. Смоук-тесты свода 2 фактически проверяют пустой ответ.
Цели / не цели
Цели:
- память страницы свода 2 ограничена окном строк и не растёт с объёмом данных;
- первые строки появляются за время одной страницы, а не всего ответа;
- байты и Python-время на строку уменьшаются за счёт компактного вида;
- поиск и фильтры не обходят все строки в браузере;
- прежние потребители API не замечают изменения.
Не цели:
- менять отбор строк свода 2 (это O-5 / AURORA-1917);
- адаптировать Excel-выгрузку под экранные фильтры;
- серверная сортировка по клику на заголовок (на экране её нет);
- отказ от pydantic на строку (это AURORA-1848);
- полнотекстовый или триграммный индекс поиска;
- менять свод 1 и общий
SvodFilters.
Решения (согласовано 28.10)
- Вместо аналитики — реализация, варианты C + B черновика. Вариант A (только политика кэша на фронте) не нужен.
- Сервер. У существующих
offset/limitпоявляются серверный поиск (по тем же полям, чтоmatchesSearchсейчас), три фильтра (smeta_type,smeta_direction, организация черезorg_name/org_ids) и полеtotal.countсохраняет прежний смысл (длинаresult). - Отдельный лёгкий запрос вариантов для трёх фильтров (facets).
- Компактный ответ (только поля, которые экран читает или ищет) включается параметром, например
view=compact. Без новых параметров ответ байт-в-байт прежний. - В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет. Выгрузку под экранные фильтры не подгоняем.
- Фронт: та же таблица, строки догружаются страницами при прокрутке. В памяти — окно плюс запас, а не весь ответ. Поиск и фильтры уходят на сервер (debounce 300 мс уже есть), «Найдено N из M» строится из
total. КэшSvodContextперестаёт копить полные ответы. - SQL: новая сигнатура
v_svod2_go/v_svod2_rf→ новая миграция сDROPстарой иCREATEновой, совместимая с PG15.explain_manual.pyи сценарийscroll.spec.ts(клик «Все ССП/РФ» → «Загрузить») обновляются вместе с UX, чтобы замер оставался сопоставимым. - Три PR в стеке: сервер (SQL + API + тесты), фронт (догрузка, поиск, фильтры), замер и сценарии perf.
- AURORA-1917 (O-5) — отдельная задача. Её нельзя вливать между замерами «до» и «после» 1843.
- Метрики успеха: память страницы после GO «все» → RF «все» → GO (нужен новый сценарий
svod2-memory.spec.ts), время до первых строк, байты ответа, шаг прокрутки свода 2 не хуже результата 1842. Масштабы 1 и 2.
Решения по устройству
1. Контракт GET /api/v1/svod/2/{sheet}/{year}
-
Прежние параметры без изменений:
org_ids,offset(ge=0),limit(ge=1),sort_by,sort_direction. -
Новые параметры, все необязательные:
параметр тип ограничения viewLiteral["full","compact"] | Nonesearchstr | Nonemax_length=200, обрезается по краямsmeta_typeLiteral["АХР","КВ","Операц"] | Nonesmeta_directionLiteral["Поддержка","Развитие"] | None -
Новый режим включается, когда хотя бы один новый параметр не
None, в том числе явныйview=full. В новом режиме ответ —BaseListResponseплюсtotal: int(новая модель, напримерSvod2PageResponse). В прежнем режиме ответ тот жеBaseListResponse[SheetResponse], что и раньше.- Аннотация возврата сейчас задаёт
response_model, и модель отрезала быtotal. Поэтому у обработчика нужно явно задатьresponse_model=Union[...]илиresponse_model=Noneи вернуть нужную модель по режиму. - Обязателен тест «в прежнем режиме нет
total».
- Аннотация возврата сейчас задаёт
-
Значения фильтров — те же строки, что видит экран в
data.header(АХР,КВ,Операц;Поддержка,Развитие), а не коды БД. Так варианты из facets подставляются в запрос без перевода. -
Фильтр по организации не получает нового параметра: экран сужает
org_idsдо id, выбранного в колонке «ССП», а сервер, как и раньше, пересекает его с разрешёнными (_allowed_org_ids).- Альтернатива —
org_name(точное совпадение по названию, как сейчас на клиенте) — отвергнута. Названия не уникальны по схеме (org_unit.titleбезunique), а фильтр поbf.org_unit_idуже есть в SQL.
- Альтернатива —
-
total—count(*) OVER ()в той же выборке, что нумерует строки, доLIMIT/OFFSET. Если страница пуста приoffset > 0, сервис делает второй вызов сoffset=0, limit=1, чтобы узнатьtotal: это редкий путь, второго SQL на каждую страницу нет.
2. Эндпоинт вариантов фильтров
GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=... возвращает:
{
"success": true,
"message": null,
"result": {
"total": 5563,
"smeta_type": ["АХР", "КВ", "Операц"],
"smeta_direction": ["Поддержка", "Развитие"],
"org": [{"id": 12, "name": "…"}]
}
}
- Обёртка —
BaseSingleResponse[Svod2Facets]. - Варианты считаются по базовому множеству (год + организации) без поиска и колоночных фильтров,
totalэтого множества — это M в «Найдено N из M». - Права — тот же
_allowed_org_ids. Если пересечение пусто — нули и пустые списки. sheetпроходит ту же валидацию, что и в основном эндпоинте (404 на неизвестный).
3. SQL: новая миграция
Файлы. api/alembic/versions/00NN_svod2_server_paging.py + sql/00NN_svod2_server_paging.sql, down_revision — текущая голова. Номер — в разделе «Совместимость и порядок PR».
Удаление старых сигнатур. DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar); и то же для v3.v_svod2_rf. Тип результата меняется (добавляется столбец), поэтому CREATE OR REPLACE не пройдёт, а новая сигнатура без DROP дала бы перегрузку.
Общий отбор v3.svod2_lines(...). Новая функция:
- сигнатура
(p_year int, p_form_types varchar[], p_org_unit_ids int[], p_search text, p_smeta_type varchar, p_smeta_direction varchar); - возвращает
line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction; - тело —
eligible_linesиз 0049 без изменения условий отбора, параметризованное списком типов форм ({FORM_1,FORM_4}для GO,{FORM_2}для RF), плюс три новых предиката. Они пишутся как(p_x IS NULL OR …); smeta_direction— тот жеCASE bl.direction, что вheader(0049:197-201).
Функцию писать LANGUAGE sql STABLE без SET search_path (имена полностью квалифицированы v3.), одним SELECT, чтобы планировщик мог её встроить. Если встраивание не произошло (в EXPLAIN есть Function Scan on svod2_lines), это допустимо: множество строк ≤ десятков тысяч узких записей. Решение — #3 в разделе решений без обсуждения.
Поиск. Предикат:
strpos(lower(concat_ws(E'\x1f', <поля>)), lower(p_search)) > 0
- Поля: все ключи
header(section_code,item_id,num_group,name=COALESCE(bl.name, ei.name),expense_item_id,justification,internal_order,vsp_id,project_id,direction,smeta_direction,source_form,source_id,source_org_unit_id,org_name,smeta_type), все столбцыcontract_summaryи все столбцыcontract_detail, кромеline_id. Таблицы уже присоединены в отборе какpresence_contract_summaryиpresence_contract_detail(связь 1:1 по PKline_id,DISTINCTне размножается). - Разделитель
\x1fне даёт совпасть «через границу» двух значений;strposне требует экранировать%и_. - Числа приводятся
::text(numeric→ как в jsonb), даты —::text(ISO, как в JSON). - Регистр кириллицы:
lower()зависит отLC_CTYPEбазы. На стенде (en_US.utf8) работает. Интеграционный тест с «аРЕНДА» доказывает это на конкретной базе, риск — в разделе рисков.
Новые v3.v_svod2_go / v3.v_svod2_rf.
- Аргументы — прежние 6 +
p_search text DEFAULT NULL,p_smeta_type varchar DEFAULT NULL,p_smeta_direction varchar DEFAULT NULL,p_compact boolean DEFAULT false. - Результат — прежние 8 столбцов +
total_count bigint. Порядок прежних столбцов не меняется: репозиторий выбирает их по имени. numbered_linesстроится поверхv3.svod2_lines(...)с тем жеORDER BY, что в 0049, плюсcount(*) OVER () AS total_count.page,booking_dataи сборкаdataкопируются из 0049 без изменений, включая различия GO и RF (например,approvedу RF учитываетss.id).data=CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END.- Вызов прежними 6 аргументами (Excel RF, прежний режим API) работает за счёт значений по умолчанию и даёт те же строки,
sort_orderиdata.
Проекция v3.svod2_compact(data jsonb) RETURNS jsonb (LANGUAGE sql IMMUTABLE): jsonb_strip_nulls(jsonb_build_object(...)) по путям из требования «Компактное представление строки». Это те же пути, что export_keys в summary_repository.py:85-140, плюс line_id, header.source_form, header.source_id, которые нужны для getRowId (Svod2Page.jsx:397).
- Альтернатива — массив значений без ключей, как
v_svod2_go_export.row_values: меньше байтов, но фронту пришлось бы перекладывать строки в форму подvalueAt, а проверка «компакт = проекция полного» стала бы сложнее. Отвергнута (#4). - Альтернатива — отдельная сборка компактного
jsonbпрямо из таблиц: быстрее на страницу, но второй экземпляр сложной логикиbooking_amount/plan_revision_*(0049:248-284) с риском расхождения. Отвергнута: страница — это сотни строк, сборка полногоjsonbдля неё дешёвая.
Варианты фильтров v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[]). Возвращает строки (kind varchar, value varchar, org_unit_id int, n bigint):
kind ∈ {'total','smeta_type','smeta_direction','org'};- значения считаются по
v3.svod2_lines(p_year, <типы форм по листу>, p_org_unit_ids, NULL, NULL, NULL); NULL-значенияsmeta_typeиsmeta_directionне выдаются (#7);- форму ответа API собирает репозиторий.
PG15. Не использовать возможности PG16+: any_value, конструкторы SQL/JSON (JSON_OBJECT, JSON_ARRAY…), IS JSON, JSON_TABLE. У подзапросов в FROM должны быть алиасы. count(*) OVER (), jsonb_strip_nulls, concat_ws, strpos есть в PG15.
v3.v_svod2_go_export не трогается. У неё остаётся своя копия eligible_lines.
4. Сервис и репозиторий
SummaryRepository.get_svod2_rows— без изменений сигнатуры и SQL: прежний режим и Excel RF.- Новые методы:
SummaryRepository.get_svod2_page(...)— новые аргументы иtotal_count, возвращает(rows, total);SummaryRepository.get_svod2_facets(...);- в сервисе
SummaryService.get_svod2_page(...)иget_svod2_facets(...)с тем же_allowed_org_ids.
- Обработчик в новом режиме вызывает
get_svod2_page, в прежнем —get_svod2_rows. - Заголовок
X-DB-Time-Msи логdb_timeставятся в обоих режимах, в лог добавляются новые параметры. На этом заголовке держитсяapi_split.py.
5. Фронт: окно страниц в той же таблице
Константы. PAGE_SIZE = 300, окно — видимая страница ±1 (не больше 3 страниц в данных таблицы), кэш страниц — LRU не больше 5 страниц на вкладку (#5). Высота строки берётся из того же источника, что estimateSize виртуализатора:
- сейчас 37 px;
- после rebase на AURORA-1836 —
ROW_HEIGHT_REM * getRootFontSizePx()из еёcreateFixedRowVirtualizerOptions.
Окно требует фиксированной высоты строки. Пока 1836 не влита, measureElement должен возвращать константу.
Данные таблицы. Это не весь список, а срез окна [windowStart, windowEnd):
- загруженные строки окна плюс заглушки
{ __placeholder: true, index }на месте незагруженных страниц окна; - позиция среза задаётся отступами виртуализатора строк
rowVirtualizerOptions = { ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }; - MRT 3.2.1 ставит высоту
tbodyвrowVirtualizer.getTotalSize(), а строки сдвигает наvirtualRow.start. Обе величины учитываютpaddingStart/paddingEnd(@tanstack/virtual-core3.11.2), поэтому глобальная строкаiвсегда стоит наi * rowH, а полоса прокрутки соответствуетtotal; - объектов строк TanStack (
createRowсоздаёт десятки замыканий на строку) не больше размера окна, а неtotal; - объект
ROW_VIRTUALIZER_OPTIONS(:26-31) не править — это hunk AURORA-1836. Новые опции собираются в компоненте разворотом этого объекта.
Прокрутка.
muiTableContainerProps.onScroll, сглаженный черезrequestAnimationFrame, считаетfirstVisible = floor(scrollTop / rowH)и видимую страницу;- когда видимая страница меняется, пересчитываются окно и данные, а недостающие страницы окна запрашиваются (соседние страницы окна — это и есть упреждающая загрузка);
- если видимая область целиком в отступе (быстрый перенос ползунка), виртуализатор рисует строки края среза вне экрана, а пользователь видит пустое место, пока окно не сдвинется на следующем кадре и не придут страницы. Это ожидаемо.
Заглушки.
muiTableBodyRowPropsдля строки-заглушки ставитclassName='svod2-placeholder'иdata-svod2-placeholder;- правило в
SVOD2_TABLE_CONTAINER_SX(svod2Styles.js) делает текст ячеек прозрачным и даёт ровный фон. ТогдаCell-функции колонок не меняются: на заглушкеvalueAtдаётundefinedи'—', но его не видно; getRowId: у заглушкиph-${index}, у данных — прежний ключ.
Запросы. В web/src/api/summary.js:
SummaryApi.getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection }, { signal })— всегдаview=compact, пустые параметры не передаются;SummaryApi.getSvod2Facets(sheet, year, orgIds, { signal });SummaryApi.getостаётся для свода 1.
Состояние вкладки — хук загрузки страниц, например useSvod2Pages.js (без JSX). Держит:
queryKey(лист, год, организации, поиск, три фильтра);generation;total;facets;pages: Map(LRU);inflight: Map<page, AbortController>;scrollTop.
Чистая логика — окно, сборка среза с заглушками, LRU, параметры запроса из условий, «показывать ли Найдено» — выносится в web/src/pages/Svod2Page/svod2Paging.js и тестируется node --test. Ответ, пришедший с устаревшим generation, отбрасывается; при смене условий текущие запросы отменяются через AbortController.
Что вызывает запросы:
- «Загрузить» или «Обновить»: новый
generation, сброс страниц, прокрутка в начало, параллельноfacetsи страница 0; - смена поиска (после debounce в
SvodFilters) или колоночного фильтра при загруженных данных: новыйgeneration, сброс страниц, прокрутка в начало, только страница 0. Варианты фильтров не перезапрашиваются: они не зависят от поиска и фильтров; - смена года или организаций: как сейчас, подсказка «Нажмите «Загрузить»…» (
:448-455); - переключение вкладки: состояние другой вкладки (окно ≤ 5 страниц) сохраняется, возврат — без запроса.
Колоночные фильтры.
manualFiltering: true,enableFacetedValues: false;- у трёх колонок
filterSelectOptionsизfacets: строки дляsmeta_type/smeta_direction,{ label: name, value: String(id) }дляorg_name. Опции подмешиваются вcolumnsвuseMemoповерхgetColumnConfig(sheet), кэш конфигурации колонок по листу остаётся; - значение фильтра «ССП» →
org_ids = [id]вместо организаций из выбора «ССП/РФ» (они уже ограничивают варианты); - сброс колоночных фильтров при смене поиска, года или организаций (
:317-323) сохраняется.
«Найдено N из M». Показывается, если данные загружены, загрузка не идёт, total !== facets.total: Найдено ${total} из ${facets.total} строк в ru-RU.
Ошибки.
- первая страница или
facets— прежнийAlertс «Повторить» (:456-468); - следующие страницы —
toast.error, страница остаётся незагруженной и запрашивается снова, когда опять попадёт в окно.
Если total изменился между страницами (кто-то правит формы): берётся последний total текущего generation, срез и отступы пересчитываются. Возможный сдвиг строк между страницами лечит «Обновить» (риск ниже).
SvodContext не меняется. Свод 2 просто перестаёт звать getSummary/invalidateSummary, поэтому полные ответы в кэше не копятся. Кэш остаётся только у свода 1.
Запасной вариант (#6). Если paddingStart/paddingEnd в MRT 3.2.1 ведут себя некорректно (проверка — первая задача фронт-PR на живом GO «все»), допускается массив длины total с общей заглушкой на незагруженных местах. Память тогда растёт как O(total) объектов строк TanStack, критерий памяти проверяется замером. Переход на запасной вариант записывается в этот файл, в раздел решений без обсуждения.
6. Стенд perf/ (PR 3)
perf/e2e/scenarios/scroll.spec.ts, только kind === 'svod2'. Код должен работать и со сборкой «до»: заглушек и новых запросов там нет, метрики просто вырождаются.
-
waitRowsсчитает строкиtbody tr:not([data-svod2-placeholder]). -
Новая метрика
first_rows_ms— от клика «Загрузить» доMIN_ROWSстрок с данными. -
Байты:
json_bytes_sheetсохраняет смысл «максимум одного ответа» (у «после» это самая большая страница);- новый
json_bytes_total— сумма всех ответов поrespReза тест; - новый
json_bytes_first— сумма до появления первых строк.
respReсвода 2 расширяется на/facets. -
Новая метрика
placeholder_frame_share— доля кадров прокрутки, в которых в видимой области контейнера есть заглушка (сэмплер наrequestAnimationFrameв странице или колбэк шага вlib/scroll.ts). Рядом пишетсяpages_requested. -
Тест фильтра: после
fill('1')ждать ответ запроса сsearch=(до 5 с; если запроса нет — это сборка «до» с клиентским поиском), затемawaitStable. Новая метрикаfilter_result_ms— от ввода до стабилизации. -
Подписи «ССП/РФ», «Все ССП/РФ», «Загрузить» не меняются (UX сохранён). Клики
:194-200остаются.
Новый perf/e2e/scenarios/svod2-memory.spec.ts.
- Роль dfip, свежий контекст на повтор,
PERF_REPEATS(по умолчанию 3). - Шаги:
/svod/2, GO «Все ССП/РФ» → «Загрузить» → строки →heapUsed(perf/e2e/lib/cdp.ts:45) → точкаgo;- вкладка RF, «Все ССП/РФ» → «Загрузить» → строки → точка
rf; - по
PERF_SVOD2_MEM_SCROLL=1(по умолчанию выключено, на масштабе 1 включить) — прокрутка RF до конца и обратно → точкаrf_scrolled; - вкладка GO → строки видны → точка
final.
- Метрики:
heap_used_mbпо точкам,nodes(domCounters),first_rows_msобеих загрузок. Записьscenario: "svod2-memory", агрегируетperfkit.aggregate.
perf/analysis/api_split.py.
- Прежние строки (полные GO/RF) остаются: это проверка «прежний режим не медленнее».
- Добавляются:
- GO/RF
?view=full&offset=0&limit=300; - GO/RF
?view=compact&offset=0&limit=300; - GO/RF
/facets; - RF
?view=compact&offset=0&limit=300&search=1.
- GO/RF
perf/analysis/explain_manual.py. Новые записи:
svod2_go_page—SELECT … FROM v3.v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, true);svod2_rf_page;svod2_rf_search(p_search='1');svod2_go_facets.
Варианты — по умолчанию и .rerun_default. Они показывают, сколько стоит отбор строк в каждой странице.
perf/analysis/scale_compare.py (:43-44): разделить ключи на «GET свод 2 {sheet}» (прежний полный), «… страница» (в query есть limit) и «… facets».
perfkit.bench. После 1843 сетевой лог содержит десятки URL страниц, и бенч повторит каждый. На масштабе 1 это около 50 URL × 20 повторов × ~0,3 с ≈ 5 мин; на масштабе 2 — в ~4 раза больше. Ограничивать через --only и --repeats, описать в perf/README.md.
Новый perf/analysis/svod2_paging_check.py — сверка на данных стенда, только чтение. Под dfip и admin для GO и RF:
- склейка всех компактных страниц совпадает по
line_idи порядку с прежним полным ответом и равна его проекции; totalравен длине полного;- для поисков (
аренда,АРЕНДА,1, подстрока названия организации) и для каждого значения трёх фильтровtotalи состав совпадают с фильтром по полному ответу на Python, повторяющим требование «Серверный поиск»; /facetsсовпадает с вычисленным по полному ответу.
Запускается перед замером «после».
Совместимость и порядок PR
Стек. test ← … ← AURORA-1835 (PR #217) ← AURORA-1842 (PR #227) ← 1843:
- PR 1
feat/236-…— сервер; - PR 2 — фронт;
- PR 3 — perf.
Каждый PR зелёный сам по себе:
- после PR 1 старый фронт работает (прежний режим не изменился);
- PR 2 использует только API из PR 1;
- PR 3 меняет только
perf/.
Номер миграции. В ветке голова — 0049. Номер 0050 уже заняли AURORA-1831 (0050_project_technical_number_lock), AURORA-1844 (0050_upd_form_cells_full_rows) и AURORA-1846 (0050_form4_sheet_sum), между собой они перенумеруются при слиянии. 1843 берёт рабочий номер 0053_svod2_server_paging (после 0050–0052 этих трёх). При каждом rebase PR 1 нужно:
- проверить
alembic heads(должна быть одна голова); - поставить номер «голова + 1»;
- переименовать
.pyи.sql; - поправить
revision,down_revisionи имя файла вrun_sql_file_migration; - перенацелить
test_summary2_related_records_sql.pyна новое имя.
Два head ломают alembic upgrade head, а при DEBUG=false — и старт приложения.
AURORA-1836 (PR #222, от test). Правит Svod2Page.jsx:
:26-31:ROW_HEIGHT→2.75rem,createFixedRowVirtualizerOptions, overscan 3;muiTableBodyRowProps.style.height.
1843 эти строки не трогает. Новые опции виртуализатора собираются в компоненте из ROW_VIRTUALIZER_OPTIONS, а у muiTableBodyRowProps 1843 добавляет класс и атрибут заглушки. Это функция-объединение: при rebase слить со style.height из 1836.
Высота строки для окна должна идти из того же источника, что estimateSize. После rebase — из 1836, иначе отступы разойдутся с позициями строк: 37 против 44 px.
Если 1836 вольётся в test раньше замера, базу «до» переснять на ребейзнутой вершине 1842.
AURORA-1848 (тяжёлые GET без pydantic, ещё не начата) переписывает тот же get_summary2 на готовый JSON из SQL. Договорённость:
- кто вливается вторым, сохраняет оба режима 1843;
- для страниц (сотни строк) выигрыш 1848 мал;
- для прежнего режима (Excel RF не через HTTP, бенч, verify) 1848 вправе менять упаковку только с сохранением JSON по смыслу, как требует сама 1848.
Требование «байт-в-байт» из 1843 относится к состоянию до 1848.
AURORA-1917 (O-5). Исправление отбора RF по is_ssp уменьшит ответ RF и число строк. Его нельзя вливать между замером «до» и «после» 1843: либо до базового замера (тогда «до» переснимается), либо после «после». Если 1917 придёт раньше, правка отбора ляжет в v3.svod2_lines (одно место) и в v_svod2_go_export.
Выкатка и откат. Миграция только заменяет функции и добавляет новые, данных не трогает. downgrade() в проекте пустые: откат PR 1 — новой миграцией, которая восстанавливает тела из 0049. Откат PR 2 безопасен сам по себе.
Тестирование (что доказывает корректность)
PR 1, api/tests/unit:
- валидация параметров через
TestClientбез БД (по образцуtest_query_validation.py): 422 наsearch> 200, на неизвестныеsmeta_type/smeta_direction, на прежние невалидныеoffset/limit/sort_*; - сервис:
_allowed_org_idsприменяется к страницам и вариантам; пустое пересечение даёт[]/0 без вызова SQL; второй вызов заtotalпри пустой странице иoffset > 0(моки репозитория, по образцуtest_export_service_query_params.py); - выбор режима: без новых параметров зовётся
get_svod2_rows, и в ответе нетtotal; test_summary2_related_records_sql.pyперенацелить на новую миграцию: отбор изv3.svod2_linesдля GO (FORM_1,FORM_4) и RF (FORM_2), те же проверки, что для 0049. Добавить: поиск находит поcontract_summary,contract_detailиheader; разделитель не даёт совпасть через границу значений; фильтрыsmeta_type/smeta_direction.
PR 1, api/tests/integration.
- Фикстура (рядом с
client) засевает в той же откатываемой сессии данные свода 2 за год, которого нет вfixture.sql:- 3 организации (одна с не уникальным
title); - FORM_1, FORM_4 и FORM_2;
- все три
smeta_type, оба направления иdirection = NULL; contract_summaryиcontract_detailс кириллицей разного регистра;- строки с
plan_q1 = 0; - пользователь-исполнитель с одной организацией.
- 3 организации (одна с не уникальным
- Оракул — прежний полный ответ:
- склейка страниц
limit=2равна полному списку,totalверен на каждой странице и за концом; search(аРЕНДА, подстрока контрагента, цифра) и каждый фильтр совпадают с фильтром полного ответа на Python по требованию «Серверный поиск»;view=compact— проекцияview=fullбезnull, нули на месте;/facetsсовпадает с вычисленным по полному;- исполнитель не видит чужую организацию ни в страницах, ни в вариантах;
- прежний режим — ключи как раньше и без
total; существующие тестыtest_summary_api_smoke.pyпроходят без правок.
- склейка страниц
- Весь набор
pytest(зелёный только целиком): PG16 на стенде (отдельная БДbudget_test, см. P4 «Стенд для тестов») плюс проверка контроллером на PG15.12.
PR 1, байт-в-байт на данных стенда. До и после миграции снять GET /svod/2/{GO,RF}/{year} без параметров (admin и dfip, «все» и одна организация, offset=0&limit=50) и Excel RF. Сравнить sha256 распакованных тел; для xlsx — openpyxl-значения всех ячеек.
PR 2, node --test web/src/pages/Svod2Page/*.test.js:
- окно по
scrollTop/rowH/total(край начала, края конца,total < PAGE_SIZE,total = 0); - сборка среза: число заглушек на последней неполной странице, отступы
paddingStart/paddingEnd, сумма =total * rowH; - LRU не держит больше 5 страниц и не выбрасывает страницы окна;
- параметры запроса: пустые не передаются, фильтр «ССП» заменяет
org_ids,view=compactвсегда; - отбрасывание ответа устаревшего
generation; - «показывать ли Найдено»;
- существующий
svod2Styles.test.jsзелёный.
PR 2, руками на стенде (GO и RF, admin и dfip):
- «Все» и выбор организаций, «Загрузить»/«Обновить»;
- перенос ползунка в середину и в конец (номера строк по порядку, последняя строка у нижнего края);
- поиск, три фильтра, их сочетание, «Найдено N из M»;
- быстрый ввод с последующим выбором фильтра (без мигания старого результата);
- переключение вкладок, единицы, «Перейти к задачам»;
- горизонтальная прокрутка (закреплённые колонки, полосы, группы);
- ошибка страницы (остановить API во время прокрутки);
- скриншоты «до/после» первого экрана и трёх горизонтальных позиций.
PR 3.
- Сценарии сначала прогнать с
PERF_REPEATS=1на сборке «до» и «после», чтобы метрики писались, аincompleteне появлялось. svod2_paging_check.py— зелёный на масштабах 1 и 2.
Замер до/после
Базы:
- «до» — вершина AURORA-1842 (
399234aили её ребейзнутая версия, если 1836 вольётся раньше); - «после» — вершина PR 3.
Код сценариев в обоих прогонах один и тот же — из PR 3. Для «до» — временный worktree на вершине 1842 и git checkout <ветка PR 3> -- perf/ в нём, без коммита.
Порядок на одних данных:
perf/stand.sh reset→perfkit.gen.run --scale 1(~12 мин) →verify. Своды мерить доgrow:growдоращивает AHR, который попадает в своды.- Скопировать
results/manifest-s1.jsonв оба worktree; при желании снятьpg_dump -Fc. - «До»: в worktree 1842 + perf из PR 3 поднять
api,web-build,web. - Остановить
api/web. - «После»: в worktree PR 3
alembic upgrade headповерх тех же данных, поднятьapi,web-build,web, прогнатьsvod2_paging_check.py, затем замеры. - То же для масштаба 2:
reset+ генерация ~45–70 мин.
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836 (или переснять «до» после rebase).
Команды (из perf/e2e, затем cd .. && .venv/bin/python -m perfkit.aggregate results/<run>):
| что | команда | время на сторону (оценка) |
|---|---|---|
| скролл + время до первых строк | PERF_RUN_ID=1843-<before|after>-s<N> PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter; на масштабе 2 ещё PERF_MAX_STEPS=10000 (RF не помещается в 5 000 шагов, REPORT раздел 6) |
s1: «до» ~35 мин, «после» ~15 мин; s2: ×3–4 |
| фильтр | тот же запуск с --grep filter |
~5 мин |
| память | PERF_RUN_ID=1843-mem-<before|after>-s<N> PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 только на s1] npx playwright test scenarios/svod2-memory.spec.ts |
s1 ~10 мин, s2 ~20–30 мин «до» |
| байты и время API | .venv/bin/python analysis/api_split.py --scale <N> |
~3–5 мин |
| планы SQL | .venv/bin/python analysis/explain_manual.py --scale <N> --out results/1843-explain-s<N> |
~3 мин |
Пороги — требования в specs/:
| метрика | порог |
|---|---|
heap_used_mb в точке final |
s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 |
first_rows_ms |
≤ 0,5 × «до», GO и RF, s1 и s2 |
bytes(compact)/bytes(full) той же страницы |
≤ 0,40 |
| первая страница + facets | ≤ 5 % прежнего полного ответа |
| прежний полный запрос | медиана полного времени ≤ 1,10 × «до» |
main_thread_ms_per_step (s1) |
≤ 1,10 × «до» (результат 1842); рядом placeholder_frame_share |
Если placeholder_frame_share > 5 %, цену шага «после» считать заниженной: заглушки дешевле строк. Разобрать, успевает ли догрузка (explain_manual: стоимость страницы), и при необходимости увеличить упреждение (окно ±2).
Результаты записать в PR 3 и в отчёт задачи: таблица «до/после» по масштабам, ссылки на results/….
Риски / компромиссы
- [Каждая страница заново выполняет полный отбор строк — 13 LEFT JOIN +
DISTINCTпо году] → Время страницы ≈ время отбора, а не сборкиjsonb.explain_manual(svod2_*_page) показывает его отдельно. Упреждающая загрузка соседних страниц прячет задержку при обычной прокрутке. Если страница на масштабе 2 дольше ~1 с, следующий шаг — переписать отбор наEXISTS-полусоединения с тем же смыслом (сверка — тот же unit-тест отбора иsvod2_paging_check.py) отдельным коммитом PR 1. - [Без новых параметров поиск и фильтры добавляют работу и замедляют прежний ответ] → Предикаты вида
(p IS NULL OR …). Порог +10 % вapi_splitловит регресс. - [
lower()не переводит кириллицу приLC_CTYPE=Cна проде] → Интеграционный тест с «аРЕНДА» падает на такой базе. Обход —lower(x COLLATE "und-x-icu"), если PG собран с ICU. УзнатьLC_CTYPEпрод-базы до выкатки PR 1 (вопрос к эксплуатации, ответ не меняет дизайн). - [Данные меняются между запросами страниц: строки сдвигаются, на стыке страниц строка может повториться или пропасть] → Свод — просмотр, «Обновить» даёт согласованный снимок.
totalобновляется по последнему ответу. Ключ строки (source_form-source_id-line_id) не даёт React смешать строки. - [Сдвиг среза отступами в MRT ведёт себя иначе, чем ожидается: прыжки, пустоты] → Проверка в первой задаче PR 2. Запасной вариант
#6— массив длиныtotal. - [Окно опирается на фиксированную высоту строки, а 1836 меняет её с 37 px на 2,75 rem] → Единый источник высоты. Тест суммы отступов. Чек-лист rebase выше.
- [Замер шага «после» занижен заглушками] → Метрика
placeholder_frame_share, порог 5 % (см. «Замер»). - [Тест фильтра свода 2 «стабилизируется» до ответа сервера (первая мутация — индикатор загрузки)] → Ожидание ответа с
search=передawaitStable. - [Варианты фильтров не учитывают поиск: можно выбрать значение, которое с поиском даёт 0 строк] → Таблица покажет «Нет данных для выбранных параметров». Сейчас варианты учитывают поиск, это небольшое изменение UX (
#7). - [Поиск по техническим id (
source_id,vsp_id,expense_item_id) находит «лишнее», например по «1»] → Так ищет и сейчас, поведение сохранено. Сужение полей поиска — отдельный вопрос бизнеса. - [
perfkit.benchповторяет каждый URL страницы] →--only/--repeats, описание в README.
Что оставлено исполнителю
- Имена моделей ответа, методов сервиса и репозитория, хука и файла чистой логики (предложенные выше — ориентир).
- Как именно вернуть
totalпри пустой странице (второй вызов илиLEFT JOINк счётчику) — при условии одного SQL на обычную страницу. - Оформление заглушки (фон, «бегущая» полоса или ровный цвет) — при прозрачном тексте и той же высоте.
- Где сэмплировать
placeholder_frame_share: в пробе страницы или колбэком шагаscrollThrough. - Точные значения
PAGE_SIZE(200–500) и окна (±1…±2), если замер покажет выигрыш. Изменение записать в решения без обсуждения.
Decisions made without discussion
-
2026-10-28 — новый режим API включается наличием любого из новых параметров, в том числе явного
view=full. Полеtotalесть только в новом режиме. Обоснование: требование «без новых параметров ответ байт-в-байт прежний».limit— прежний параметр, привязатьtotalк нему значило бы изменить прежний ответ. Цена ошибки: если нуженtotalдля прежнего полного вида, его даётview=full— дёшево. -
2026-10-28 — фильтр «ССП» передаётся сужением
org_idsдо id организации, а не параметромorg_name. Значенияsmeta_type/smeta_directionв API — подписи с экрана (АХР,Поддержка…), а не коды БД. Обоснование:org_unit.titleне уникален,org_idsуже есть и проверяется на права. Подписи совпадают сdata.headerи вариантами — перевод не нужен. Цена ошибки: при переименовании подписей в SQL нужно править валидацию API — одна строка. -
2026-10-28 — общий отбор строк выносится в
v3.svod2_lines(одна копия условий для страниц GO/RF и вариантов), безSET search_path, чтобы функция могла встраиваться.v_svod2_go_exportсохраняет свою копию. Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами. Цена ошибки: если встраивание не срабатывает и отбор заметно дорожает, вернуть копии в каждую функцию — час работы. Сравнение —explain_manual. -
2026-10-28 — компактный вид — та же вложенная форма
data, только нужные пути, без ключейnull(jsonb_strip_nulls). Строится проекцией полногоjsonbв SQL (v3.svod2_compact), а не массивом значений и не отдельной сборкой из таблиц. Обоснование: фронт читает те же пути (valueAt);nullи отсутствие ключа отображаются одинаково (formatValue,textColumn); у свода 2 нет WS-слияния, которое зависело бы от явныхnull(зависимость из 1848 — только уRealtimeTable). Проверка «компакт = проекция полного» тривиальна. Ожидаемо ~1–1,5 КБ на строку против ~5,5 КБ. Цена ошибки: если 40 % не набирается, переход на массив значений меняет толькоsvod2_compactи маппинг на фронте. -
2026-10-28 —
PAGE_SIZE = 300, окно — видимая страница ±1, LRU не больше 5 страниц на вкладку. Обоснование: 300 строк — около 8 экранов и примерно 0,3–0,45 МБ компактного JSON. На шаг прокрутки 200 px граница страницы приходится примерно раз в 55 шагов, упреждения на соседнюю страницу хватает при ответе < 2–3 с. В памяти ≤ 1 500 строк данных и ≤ 900 строк в таблице. Цена ошибки: подбор констант по замеру, правка двух чисел. -
2026-10-28 — окно реализуется срезом данных и отступами
paddingStart/paddingEndвиртуализатора строк. Запасной вариант — массив длиныtotalс заглушками. Обоснование: только срез даёт память O(окна) и для объектов строк TanStack, а не только для данных. Накопительная бесконечная прокрутка отвергнута: после прокрутки до конца в памяти снова весь ответ, что противоречит решению 28.10. Цена ошибки: если срез не заработает с MRT 3.2.1, запасной вариант дороже по памяти (десятки МБ объектов строк на масштабе 2) — порог памяти может не выполниться. Тогда это вопрос к человеку. -
2026-10-28 — варианты фильтров считаются по году и организациям без учёта поиска и других фильтров.
NULL-значения в варианты не попадают. Обоснование: один запрос вариантов на «Загрузить» вместо запроса на каждое изменение. M в «Найдено N из M» — тот же ответ. Цена ошибки: если бизнесу нужны «перекрёстные» варианты,facetsполучает те жеsearch/фильтры, а фронт перезапрашивает его при изменениях — полдня. -
2026-10-28 — поиск ищет и в тех полях
contract_summary(counterparty,reference,deadline,comment), которые на клиенте сейчас фактически не ищутся: спред{...contract_summary, ...contract_detail}(Svod2Page.jsx:284-288) перекрывает их одноимёнными полямиcontract_detail. Числа сравниваются по тексту из БД (1234.50), а не по JS-записи (1234.5). Обоснование: «по тем же полям» понимается как «по тем же группам полей»; повторять перекрытие — значит повторять ошибку. Цена ошибки: поиск находит немного больше строк, чем раньше; сузить — одна строка SQL. -
2026-10-28 — «Найдено N из M» считает поиск и колоночные фильтры вместе (раньше — только поиск). Обоснование: N приходит с сервера одним
totalпо всем условиям, отдельно «только поиск» пришлось бы считать вторым запросом. Цена ошибки: подпись иначе ведёт себя при одном колоночном фильтре без поиска. Вернуть прежнее — дополнительныйtotalбез фильтров. -
2026-10-28 — пороги успеха:
- память s1 ≤ 30 % «до», s2 ≤ 1,25 × s1;
- время до первых строк ≤ 50 % «до»;
- компакт ≤ 40 % полного;
- первая страница + варианты ≤ 5 % полного ответа;
- прежний режим не медленнее +10 %;
- шаг прокрутки ≤ 1,10 × результата 1842;
- заглушки в кадре ≤ 5 %.
Обоснование: оценки по REPORT (М2, М9, М10). Перечисленные пороги консервативны, ожидаемый эффект больше. 10 % — шум при n=2. Цена ошибки: порог окажется недостижим или слишком слаб — пересмотр по фактическим цифрам с человеком, код не меняется.
-
2026-10-28 — стенд: код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой
perf/.json_bytes_sheetсохраняет смысл «максимум одного ответа», суммарные байты — новые метрики. Обоснование: сопоставимость метрик и прежних отчётов. Цена ошибки: при расхождении кода сценариев «до» нужно переснять — около часа на масштабе 1. -
2026-10-28 — рабочий номер миграции
0053; окончательный — «головаtest+ 1» при rebase. Обоснование: 0050 занят тремя PR, их порядок неизвестен. Цена ошибки: переименование двух файлов и правка тестов. -
2026-10-28 — интеграционные тесты засевают свои данные свода 2 в откатываемой сессии, а не полагаются на
fixture.sql. Обоснование: в фикстуре нет ни одной строки, проходящей отбор свода 2 за 2026 год. Тесты соskipпри отсутствии данных ничего не доказывали бы. Цена ошибки: нет.