DFiP_Budget_planing/docs/superpowers/plans/2026-10-25-perf-profiling.md
Andrey Epifancev bc52212811 perf: REPORT, README, design.md — правки по финальному ревью
- REPORT: правки FORM_4 — вывод по коду, не замер (I-1); цена фильтра свода
  по записям основного прогона, hotspots — верхняя граница (I-2); М6 поднята
  до P1, пояснение состава топ-5 (I-4); формулировки кадров, малых листов,
  admin-контроля, М8, время прогона (M-1, M-2, M-9, M-11); раздел 6 дополнен,
  новый раздел 6а с находками при подготовке стенда (M-10, M-12, T7.3)
- README: контроль 4x с отдельным тегом и предупреждением (I-3), команды
  modal-renders и netlog-sizes (M-4), гипотезы в «Как читать» (T8.7)
- design.md: R-7, правило приоритетов и топ-5, решения волны исправлений
- план: заметка о замене CPU [1,4,6] решением человека (M-12)

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:34:58 +04:00

34 KiB
Raw Permalink Blame History

Профилирование фронта и бэка — план исполнения

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Воспроизводимый стенд в perf/ и отчёт perf/REPORT.md с доказанными проблемными местами по скроллу, модалкам, памяти и бэку (issue #202).

Architecture:

  • Postgres 16 в docker (с pg_stat_statements), бэк — uvicorn из api/ в одном процессе.
  • Prod-сборка фронта собирается из web/ собственным конфигом perf/web/rsbuild.perf.config.ts в perf/.build/ и раздаётся rsbuild preview на :8080 с прокси /api (включая WS) на :8000.
  • Python-пакет perf/perfkit генерирует данные через HTTP-API, гоняет бота-соавтора и замеры бэка; Playwright-проект perf/e2e снимает метрики фронта через CDP.
  • Все сценарии пишут записи единого формата в perf/results/<run_id>/, агрегатор сводит их в summary.json и markdown-таблицы для отчёта.

Tech Stack: Python 3.12 (httpx, asyncpg, websockets, argon2-cffi, pytest), Node 24 + @playwright/test (Chromium), docker compose, Postgres 16, Rsbuild (из web/node_modules).

Spec: openspec/changes/perf-profiling/design.md (+ proposal.md)

Global Constraints

  • Не менять api/src, api/alembic, web/src, web/rsbuild.config.ts, web/package.json, api/requirements*.txt, api/web. Всё новое — только в perf/ и строки в корневом .gitignore.
  • Не исправлять код приложения, даже очевидные баги: находка идёт в отчёт.
  • Фронт мерить только на prod-сборке (rsbuild build), режимы CPU — [1, 4, 6] через Emulation.setCPUThrottlingRate.
    • Заменено решением человека 2026-10-25: основные замеры без троттлинга (CPU 1×); CPU 4× — только контрольный прогон топ-3–4 находок, 1 повтор (openspec/changes/perf-profiling/design.md, «Decisions made with the human»). Упоминания [1,4,6] и CPU 6 ниже по плану — исходная редакция.
  • Бэк и Postgres — без лимитов CPU и памяти. В отчёте пометка: «быстрое железо, прод ≈ 1 vCPU / 4 GB».
  • Postgres 16, схема v3, миграции — cd api && alembic upgrade head, бэк с DEBUG=true, DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/perf.
  • Порты: Postgres 55432, API 8000, фронт 8080.
  • Основная роль замеров — EXECUTOR_RF (role_id 3) с открытым окном этапа на правку. Контрольная — ADMIN (1), только скролл на масштабе 1.
  • Масштабы данных: 1 (базовый) и 2 (удвоенный).
  • Long task — задача длиннее 50 мс (PerformanceObserver типа longtask). Тяжёлый кадр — интервал между requestAnimationFrame больше 50 мс.
  • Сырые артефакты (*.json trace, *.heapsnapshot, perf/results/**, perf/.build/, perf/.venv/, perf/e2e/node_modules/) — в .gitignore. Коммитятся код стенда, perf/README.md, perf/REPORT.md и perf/report-data/<run_id>/summary.json.
  • Отчёт и README — на русском.
  • Каждая цифра отчёта получается командой из perf/README.md.

Review Focus

  • Prod-сборка с прокси: WS /api/v1/ws/... через :8080 должен работать; иначе таблица не загрузится, а сценарии «зелёные» на пустой странице. Проверяется smoke в Task 1 и ассертом «в таблице ≥ N видимых строк» перед каждым замером в Task 5.
  • Окно этапа закрыто на дату замера: EXECUTOR_RF видит лист read-only, и замер идёт не на той конфигурации. Генератор ставит окна opens_at = now - 1 day, closes_at = now + 30 days; verify в Task 3 проверяет это.
  • Скролл не дошёл до конца (виртуализация или ленивая подгрузка): сценарий обязан проверить, что scrollTop + clientHeight ≥ scrollHeight - 2 в конце, иначе запись получает "incomplete": true. Тест в Task 5.
  • Перекрёстное влияние прогонов (кэш браузера, открытые WS, накопленная память): каждый замер скролла и модалок — в новом browser context. Проверяется в Task 4 тестом изоляции контекста.
  • Неверный токен в WS-боте: бот молча не шлёт правки, и сценарий 1a измеряет «без соавтора». Бот обязан дождаться эха cell_updated хотя бы на первую правку, иначе падает. Тест в Task 6.

Task 1: Стенд — Postgres, миграции, админ, бэк, prod-сборка фронта с прокси

Модель: sonnet

Files:

  • Create: perf/docker-compose.yml — сервис pg из postgres:16, порт 55432:5432, POSTGRES_USER/PASSWORD/DB=perf, command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all, volume perf-pg.
  • Create: perf/stand.sh — подкоманды up | migrate | bootstrap | api | web-build | web | smoke | down | all.
  • Create: perf/stand/bootstrap.py — CREATE EXTENSION IF NOT EXISTS pg_stat_statements; админ admin/admin (admin@perf.local, role_id 1, argon2-хеш); идемпотентно.
  • Create: perf/stand/smoke.py.
  • Create: perf/web/rsbuild.perf.config.ts — mergeRsbuildConfig поверх web/rsbuild.config.ts: output.distPath.root = perf/.build/prod или perf/.build/profiling при PERF_PROFILING=1; при PERF_PROFILING=1 alias react-dom$ → react-dom/profiling; server.port 8080, server.proxy /api → http://localhost:8000 с ws: true.
  • Create: perf/requirements.txt — httpx, asyncpg, websockets, argon2-cffi, pytest.
  • Create: perf/README.md — раздел «Стенд».
  • Modify: .gitignore — строки из Global Constraints.

Interfaces:

  • Produces:

    • perf/stand.sh all — поднимает всё, бэк и фронт в фоне, PID в perf/.run/*.pid, логи в perf/.run/*.log.
    • venv perf/.venv (зависимости api/requirements-dev.txt + perf/requirements.txt).
    • Переменные PERF_API=http://localhost:8000, PERF_WEB=http://localhost:8080, PERF_DB_DSN=postgresql://perf:perf@localhost:55432/perf.
    • Админ admin/admin.
  • Step 1: Написать perf/stand/smoke.py, который проверяет:

    • GET $PERF_API/healthz → 200;
    • POST $PERF_API/api/v1/auth/login {"username":"admin","password":"admin"} → 200 и access_token;
    • GET $PERF_WEB/ → 200, HTML содержит <script;
    • GET $PERF_WEB/api/v1/users/me с Bearer → 200 (прокси);
    • WS ws://localhost:8080/api/v1/ws/export открывается (прокси WS).

    Печатает SMOKE OK или первую ошибку и выходит с кодом 1.

  • Step 2: Прогнать perf/stand.sh smoke до создания стенда Expected: FAIL (соединение отклонено).

  • Step 3: Реализовать compose, stand.sh, bootstrap.py, rsbuild.perf.config.ts

    • web-build: cd web && npm ci && npx rsbuild build -c ../perf/web/rsbuild.perf.config.ts, затем то же с PERF_PROFILING=1.
    • web: npx rsbuild preview -c ../perf/web/rsbuild.perf.config.ts.
    • api: cd api && DATABASE_URL=… DEBUG=true ../perf/.venv/bin/python run_server.py.
  • Step 4: Прогнать perf/stand.sh all && perf/stand.sh smoke Expected: SMOKE OK. git status не показывает изменений вне perf/ и .gitignore.

  • Step 5: Commit — perf: стенд для профилирования (postgres, api, prod-сборка фронта).

Task 2: perfkit — формат манифеста и результатов, агрегатор

Модель: sonnet

Files:

  • Create: perf/perfkit/__init__.py, perf/perfkit/manifest.py, perf/perfkit/results.py, perf/perfkit/aggregate.py.
  • Test: perf/tests/test_manifest.py, perf/tests/test_aggregate.py, perf/pytest.ini (pythonpath = .).

Interfaces:

  • Produces:

    • perfkit.manifest: dataclasses Sheet(form_id:int, form_type:str, sheet:str, direction:str|None, year:int, url:str, rows:int), ProjectReport(project_id:int, year:int, report_type:str, url:str, rows:int), Page(name:str, url:str), User(username:str, password:str, role_id:int), Manifest(scale:int, users:dict[str,User], sheets:list[Sheet], project_reports:list[ProjectReport], svods:list[Page], modal_pages:list[Page]); load(path) -> Manifest, dump(m, path). Путь: perf/results/manifest-s{scale}.json. Ключи users: "admin", "rf", "rf_coauthor", "dfip". URL листа — /table/form/{form_id}/form-type/{form_type}/{sheet}/{direction or "null"}/{year}. Реальное значение сегмента для «нет direction» исполнитель берёт из web/src и фиксирует в коде.
    • perfkit.results: запись результата (одна JSON-строка) — {"run_id","scenario","target","role","scale","cpu_rate","repeat","metrics":{…},"artifacts":[…],"incomplete":bool,"ts"}. Функция append(run_dir, record) пишет в perf/results/<run_id>/<scenario>.jsonl. Node-сторона (Task 4) пишет тот же формат.
    • perfkit.aggregate: summarize(run_dir) -> dict группирует по (scenario, target, role, scale, cpu_rate) и считает для каждой числовой метрики median, p95, min, max, n. CLI: python -m perfkit.aggregate <run_dir> пишет summary.json и summary.md в perf/report-data/<run_id>/.
  • Step 1: Тесты

    • test_manifest_roundtrip — dump и load дают равный объект.
    • test_summarize_median_p95: метрика long_task_ms со значениями [10,20,30,40,100] → median == 30, p95 == 100 (nearest-rank), n == 5.
    • test_summarize_groups_by_cpu_rate — записи с cpu_rate 1 и 6 дают две группы.
    • test_incomplete_records_counted_separately — incomplete:true не попадает в статистику; в группе есть incomplete_n.
  • Step 2: cd perf && .venv/bin/pytest tests -q → FAIL (нет модулей).

  • Step 3: Реализовать модули по интерфейсам.

  • Step 4: cd perf && .venv/bin/pytest tests -q → PASS.

  • Step 5: Commit — perf: формат манифеста и результатов, агрегатор.

Task 3: Генератор данных через HTTP-API

Модель: sonnet

Files:

  • Create: perf/perfkit/gen/__init__.py, perf/perfkit/gen/plan.py, perf/perfkit/gen/run.py (CLI python -m perfkit.gen.run --scale N), perf/perfkit/gen/verify.py (CLI python -m perfkit.gen.verify --scale N).
  • Test: perf/tests/test_gen_plan.py.

Interfaces:

  • Consumes: стенд (Task 1), perfkit.manifest (Task 2).
  • Produces:
    • plan_for_scale(scale:int) -> GenPlan (dataclass с полями org_units_ssp:int, org_units_rf:int, vsp_per_rf:int, extra_lines_per_sheet:int, projects_per_rf:int, fill_ratio:float).
    • Манифест perf/results/manifest-s{scale}.json.

Генератор всё создаёт через API под admin, в таком порядке:

  1. оргединицы (POST /api/v1/org-unit/);
  2. пользователи rf, rf_coauthor (role 3, оба на одну РФ) и dfip (role 2) с паролем perf (POST /api/v1/users/, PUT /users/dfip-many-ssp/);
  3. ВСП (POST /api/v1/dict/info);
  4. формы FORM_1, FORM_2, FORM_4 за 2026 год по оргединицам (POST /api/v1/form/) — строки из шаблона создаёт сама БД;
  5. дополнительные строки (POST /form/{id}/sheet/{sheet}/line);
  6. заполнение ячеек долей fill_ratio (PATCH .../cells пачками);
  7. этапы с окнами opens_at = now - 1 day, closes_at = now + 30 days для ролей EXECUTOR_RF и EXECUTOR_DFIP на всех листах (POST /api/v1/stages/form/{id});
  8. проекты РФ с годами и отчётами (FORM_3) и этапы проектов.

Значения plan_for_scale(1) исполнитель выбирает так, чтобы самый большой лист у rf имел ≥ 1000 строк, а своды были заполнены по всем оргединицам. Обоснование — комментарием в plan.py, оно попадёт в отчёт.

  • Step 1: Тесты

    • test_plan_scale2_doubles_volume: у plan_for_scale(2) поля org_units_rf, vsp_per_rf, extra_lines_per_sheet, projects_per_rf равны удвоенным значениям из plan_for_scale(1), fill_ratio тот же.
    • test_plan_rejects_zero_scale — ValueError.
  • Step 2: pytest → FAIL.

  • Step 3: Реализовать plan.py и run.py. Повторный запуск на непустой БД падает с понятным сообщением «сначала perf/stand.sh reset». Для этого в Task 1 нужна подкоманда reset: drop volume + up + migrate + bootstrap; добавить её в этом таске.

  • Step 4: Реализовать verify.py. Он проверяет в БД (asyncpg, PERF_DB_DSN):

    • число форм, строк и проектов соответствует плану;
    • у rf самый большой лист ≥ 1000 строк;
    • на каждом листе из манифеста есть этап с opens_at < now() < closes_at для роли EXECUTOR_RF;
    • каждый URL манифеста отвечает 200 через $PERF_WEB.

    Печатает VERIFY OK.

  • Step 5: perf/stand.sh reset && .venv/bin/python -m perfkit.gen.run --scale 1 && .venv/bin/python -m perfkit.gen.verify --scale 1 → VERIFY OK; pytest → PASS.

  • Step 6: Commit — perf: генератор данных через API.

Task 4: Каркас замеров фронта (Playwright + CDP)

Модель: sonnet

Files:

  • Create: perf/e2e/package.json (@playwright/test, точная версия), perf/e2e/playwright.config.ts (Chromium, workers: 1, fullyParallel: false, без ретраев, timeout 10 мин).
  • Create: perf/e2e/lib/manifest.ts — чтение манифеста Task 2.
  • Create: perf/e2e/lib/auth.ts — loginAs(context, user): токен через API, затем в localStorage под ключами, которые читает web/src/app/context/AuthProvider.jsx; ключи исполнитель берёт оттуда.
  • Create: perf/e2e/lib/cdp.ts — throttle(page, rate), startTrace(page), stopTrace(page, path), gc(page), domCounters(page), heapUsed(page), heapSnapshot(page, path).
  • Create: perf/e2e/lib/probe.ts — init-script, встраиваемый в страницу: PerformanceObserver('longtask') и счётчик кадров через requestAnimationFrame; window.__perf.start() / window.__perf.stop() возвращают {long_task_n, long_task_ms, frame_n, heavy_frame_n, frame_p50_ms, frame_p95_ms, duration_ms}.
  • Create: perf/e2e/lib/results.ts — append(record) в формате Task 2. run_id из PERF_RUN_ID, по умолчанию YYYYMMDD-HHMM.
  • Create: perf/e2e/fixtures/busy.html — страница, которая по кнопке крутит синхронный цикл 120 мс.
  • Test: perf/e2e/tests/harness.spec.ts.

Interfaces:

  • Produces: перечисленные функции с этими именами. Каждая следующая e2e-задача импортирует их из ../lib/*.

  • Step 1: Тесты в harness.spec.ts

    • probe detects long task: на busy.html нажатие кнопки между start и stop даёт long_task_n >= 1 и long_task_ms >= 100.
    • throttle slows script: время того же цикла при throttle(page, 4) ≥ 2× времени при throttle(page, 1).
    • fresh context isolation: localStorage, выставленный в одном контексте, не виден в новом.
    • results format: append пишет строку, которую perfkit.results из Task 2 читает (проверка через python -m perfkit.aggregate на временном каталоге даёт n == 1).
  • Step 2: cd perf/e2e && npx playwright test tests/harness.spec.ts → FAIL.

  • Step 3: Реализовать lib/*.

  • Step 4: Тот же прогон → PASS.

  • Step 5: Commit — perf: каркас замеров фронта.

Task 5: Сценарии скролла — листы форм, отчёты проектов, своды

Модель: sonnet

Files:

  • Create: perf/e2e/lib/scroll.ts — scrollThrough(page, containerSelector, axis: 'y'|'x', stepPx=200) -> {complete: boolean, steps: number}: колесо мыши шагами по 200 px, после каждого шага ждать requestAnimationFrame, вниз до конца и обратно. complete — правило из Review Focus.
  • Create: perf/e2e/scenarios/scroll.spec.ts.
  • Test: perf/e2e/tests/scroll-lib.spec.ts на perf/e2e/fixtures/long-list.html (div 20 000 px).

Interfaces:

  • Consumes: Task 4 lib, манифест (sheets, project_reports, svods).
  • Produces: записи scenario: "scroll", target — "{form_type}/{sheet}/{direction}", "project/{report_type}" или "svod/{name}"; метрики probe плюс axis, rows_visible_before, json_bytes_sheet (размер ответа загрузки листа из сетевого лога). Trace на каждый первый повтор — в artifacts.

Матрица:

  • роль rf × все листы и отчёты проектов × оси y и x × CPU [1,4,6] × 3 повтора;
  • роль admin × листы × ось y × CPU [1,4,6] × 1 повтор, только масштаб 1;
  • своды (свод 1 по каждому sheet, свод 2 GO и RF, /projects/summaries) под admin и dfip × ось y × CPU [1,4,6] × 3 повтора, плюс одна смена фильтра на CPU 6 с замером probe от клика до стабилизации (2 кадра без long task).

Перед замером: дождаться ≥ 20 видимых строк таблицы, иначе тест падает (Review Focus). Селектор контейнера скролла исполнитель берёт из DOM material-react-table.

  • Step 1: Тесты scroll-lib.spec.ts: на long-list.html scrollThrough даёт complete === true; на странице, где контейнер обрезан до 5000 px при maxSteps = 10, — complete === false.
  • Step 2: Прогон → FAIL.
  • Step 3: Реализовать scroll.ts и scroll.spec.ts. Сценарий параметризуется PERF_SCALE и фильтром PERF_TARGETS (glob по target) для быстрых прогонов.
  • Step 4: npx playwright test tests/scroll-lib.spec.ts → PASS. PERF_SCALE=1 PERF_TARGETS='FORM_1/*' npx playwright test scenarios/scroll.spec.ts → записи без incomplete; python -m perfkit.aggregate строит таблицу.
  • Step 5: Commit — perf: сценарии скролла.

Task 6: Бот-соавтор и сценарий «скролл при соавторе»

Модель: sonnet

Files:

  • Create: perf/perfkit/coauthor.py — CLI python -m perfkit.coauthor --form-id N --sheet S [--direction D] --rate 1|5 --duration SEC.
  • Create: perf/e2e/scenarios/scroll-coauthor.spec.ts.
  • Test: perf/tests/test_coauthor.py (интеграционный, помечен @pytest.mark.stand; в pytest.ini — markers = stand: нужен поднятый стенд).

Interfaces:

  • Consumes: манифест (users.rf_coauthor, sheets), Task 4/5 lib.
  • Produces: записи scenario: "scroll_coauthor", metrics + coauthor_rate, coauthor_sent, coauthor_echoed.

Протокол — api/src/api/v1/websocket.py:

  • подключение ws://localhost:8000/api/v1/ws/form/{id}/sheet/{sheet}?direction=;
  • первое сообщение {"event":"user_login","data":{"token":…}};
  • правка — событие cell_updated (формат data взять из обработчика __update_cell и из того, что шлёт фронт в web/src/components/RealtimeTable/hooks/useWebSocket.js).

Бот правит случайные редактируемые ячейки листа в колонках, открытых этапом для EXECUTOR_RF. Перед правкой шлёт cell_edit_start, после — cell_edit_end.

  • Step 1: Тест test_coauthor_first_edit_echoed: бот с --rate 1 --duration 3 на первом листе FORM_1 получает эхо cell_updated своей первой правки и завершается кодом 0. С неверным токеном завершается кодом ≠ 0 за ≤ 5 с (Review Focus).
  • Step 2: .venv/bin/pytest -m stand tests/test_coauthor.py → FAIL.
  • Step 3: Реализовать бота и сценарий:
    • бот стартует как дочерний процесс на время скролла;
    • роль rf, масштаб 1, CPU 6, rate 1 и 5, 3 повтора;
    • цели — самый большой лист каждой формы.
  • Step 4: Тест → PASS. Сценарий на FORM_1 даёт записи с coauthor_echoed > 0.
  • Step 5: Commit — perf: бот-соавтор и скролл при соавторе.

Task 7: Модалки — перечень, время открытия, что перерисовывается

Модель: sonnet

Files:

  • Create: perf/e2e/modals.json — перечень модалок: {id, page: "sheet"|"task"|"project"|"projects"|"admin_*"|"svod", source: "web/src/...:line", open: {selector, action}, priority: 1|2}. Источник — поиск по web/src (Dialog, Modal, common/Modal) по страницам форм, задачи и проекта (priority 1) и админки (priority 2). Для модалки без воспроизводимого пути открытия ставится "skip_reason".
  • Create: perf/e2e/lib/modal.ts — measureOpen(page, opener) -> {click_to_visible_ms, long_task_n, long_task_ms}: отметка performance.now() в обработчике pointerdown (capture); MutationObserver ловит появление видимого [role=dialog]; конец — следующий requestAnimationFrame после появления.
  • Create: perf/e2e/lib/reactProfile.ts — для profiling-сборки: через __REACT_DEVTOOLS_GLOBAL_HOOK__ (init-script с минимальным хуком onCommitFiberRoot) считает число зафиксированных fiber-рендеров по displayName за окно замера. Возвращает топ-20 {component, renders, self_ms}.
  • Create: perf/e2e/scenarios/modals.spec.ts.
  • Test: perf/e2e/tests/modal-lib.spec.ts на fixtures/modal.html: кнопка открывает role=dialog через setTimeout(150).

Interfaces:

  • Consumes: Task 4 lib, манифест (modal_pages, sheets).
  • Produces: записи scenario: "modal", target: modal.id, метрики measureOpen; для profiling-прогона — scenario: "modal_renders", metrics.renders_top (массив). Кроме того, сводная таблица perf/report-data/<run_id>/modals-inventory.md (id, source, priority, skip_reason).

Матрица:

  • priority 1 × роль rf (admin, если модалка доступна только ему) × CPU [1,4,6] × 5 повторов;

  • модалки страницы листа — дополнительно при открытом самом большом листе;

  • priority 2 — CPU 6 × 3 повтора;

  • profiling-сборка (сервер на PERF_WEB_PROFILING, perf/stand.sh web-profiling на :8081) — каждая модалка priority 1 × CPU 1 × 1 повтор.

  • Step 1: Тест: measureOpen на modal.html даёт click_to_visible_ms в диапазоне [150, 400]; на кнопке без модалки падает по таймауту 5 с с сообщением, где есть id модалки.

  • Step 2: Прогон → FAIL.

  • Step 3: Составить modals.json. Реализовать lib, сценарий и подкоманду web-profiling в stand.sh.

  • Step 4: Тест → PASS. Сценарий на priority 1 при CPU 6 даёт записи для всех модалок без skip_reason.

  • Step 5: Commit — perf: замеры открытия модалок.

Task 8: Память при длительной работе

Модель: sonnet

Files:

  • Create: perf/e2e/scenarios/memory.spec.ts.
  • Create: perf/perfkit/memtrend.py — slope_per_cycle(values: list[float]) -> float (наклон МНК); CLI печатает наклон по heap_used_mb, nodes, js_event_listeners из записей memory.
  • Test: perf/tests/test_memtrend.py.

Interfaces:

  • Consumes: Task 4 lib, манифест. Правки — через UI (двойной клик по ячейке и ввод числа), чтобы шли через фронтовый WS.
  • Produces: записи scenario: "memory", по одной на цикл: metrics: {cycle, heap_used_mb, documents, nodes, js_event_listeners, ws_open}. ws_open — счётчик открытых WebSocket, его ведёт init-script с обёрткой над window.WebSocket. Heap snapshots на циклах 0, 10, 20 — в artifacts.

Один цикл:

  1. открыть самый большой лист FORM_1;
  2. проскроллить вниз;
  3. отредактировать 3 ячейки через UI;
  4. перейти на /tasks;
  5. открыть самый большой лист FORM_2;
  6. проскроллить;
  7. вернуться на /tasks;
  8. gc, затем снять метрики.

Всего 20 циклов, роль rf, CPU 1 (память от троттлинга не зависит), масштаб 1, одна SPA-сессия без перезагрузки страницы.

  • Step 1: Тесты: slope_per_cycle([10,11,12,13]) == 1.0; slope_per_cycle([5,5,5]) == 0.0; меньше двух точек → ValueError.
  • Step 2: pytest → FAIL.
  • Step 3: Реализовать memtrend.py и сценарий.
  • Step 4: pytest → PASS. Сценарий даёт 21 запись (цикл 0 — базовая точка) и 3 heap snapshot. python -m perfkit.memtrend <run_dir> печатает три наклона.
  • Step 5: Commit — perf: сценарий памяти.

Task 9: Замеры бэка — запросы сценариев, pg_stat_statements, EXPLAIN

Модель: sonnet

Files:

  • Modify: perf/e2e/lib/results.ts — сохранять сетевой лог (page.on('requestfinished'): метод, путь, статус, timing, размер тела) в perf/results/<run_id>/network.jsonl во всех сценариях Task 5–8 (через общую фикстуру).
  • Create: perf/perfkit/bench.py — CLI python -m perfkit.bench <run_dir> --repeats 20: берёт уникальные GET и PATCH/POST (только идемпотентные PATCH ячеек с тем же значением) из network.jsonl, повторяет их через httpx под той же ролью. Пишет записи scenario: "api", target: "<METHOD> <path-template>", метрики ms и bytes. Шаблон пути — числа заменены на {id}.
  • Create: perf/perfkit/sqlstats.py — reset() и dump(run_dir, top=15): топ по total_exec_time и mean_exec_time из pg_stat_statements в sql_top.json. Для топ-5 по total_exec_time, где текст — вызов v3.*-функции или SELECT, выполняется EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) с параметрами из сетевого лога, результат в explain/<n>.json. DML не EXPLAIN'ится.
  • Test: perf/tests/test_bench.py.

Interfaces:

  • Consumes: сетевой лог, манифест (пользователи), PERF_DB_DSN.

  • Produces: api-записи, sql_top.json, explain/*.json в каталоге прогона.

  • Step 1: Тесты:

    • test_path_template: "/api/v1/form/123/sheet/AHR/cells" → "/api/v1/form/{id}/sheet/AHR/cells";
    • test_select_requests_skips_non_idempotent: POST .../line не попадает в набор повторов.
  • Step 2: pytest → FAIL.

  • Step 3: Реализовать. sqlstats.reset() вызывается в globalSetup Playwright, dump — в globalTeardown.

  • Step 4: pytest → PASS. После прогона сценария скролла по FORM_1 есть network.jsonl, sql_top.json и ≥ 1 explain/*.json; bench пишет api-записи.

  • Step 5: Commit — perf: замеры бэка.

Task 10: Полные прогоны, статический разбор, отчёт

Модель: opus

Files:

  • Create: perf/run-all.sh — масштаб 1: reset + gen + все сценарии + bench + memtrend + aggregate; то же для масштаба 2 (без admin-контроля и profiling-прогона).
  • Create: perf/REPORT.md, perf/report-data/<run_id>/summary.json, summary.md, modals-inventory.md.
  • Modify: perf/README.md — полный порядок: стенд, данные, прогоны, где результаты.

Interfaces:

  • Consumes: всё выше.

  • Step 1: perf/run-all.sh проходит целиком на масштабах 1 и 2. Записей с incomplete нет, а если есть — в отчёте раздел «не проверено» с причиной.

  • Step 2: Разбор:

    • traces первых повторов: топ функций по self time в long tasks при скролле;
    • render counts модалок;
    • retainers растущих объектов в heap snapshots (циклы 10 и 20);
    • sql_top и EXPLAIN;
    • сравнение масштабов 1 и 2;
    • для эндпоинтов, где медиана api.ms больше суммарного времени их SQL из sql_top.json в 2 и более раза, — профиль py-spy record бэка на повторе bench по этому эндпоинту (py-spy ставится в perf/.venv).

    Каждую горячую точку свести к file:line в web/src или api/.

  • Step 3: Статический разбор кода по найденным точкам: нестабильные значения контекстов, пересоздание колонок и конфигов на рендер, слушатели, таймеры и WS без очистки, console.log в горячих обработчиках. Пометка «подтверждено замером: да/нет».

  • Step 4: Написать perf/REPORT.md по структуре дизайна (резюме и топ-5, методика и окружение, включая версию Chromium и оговорки про Яндекс.Браузер и прод-ресурсы, обоснование масштаба из plan.py, таблица мест, подробности, не проверено). Каждая цифра — со ссылкой на файл в perf/report-data/<run_id>/.

  • Step 5: Проверка: каждая строка таблицы мест имеет file:line, доказательство и приоритет; git status чист вне perf/ и .gitignore; в api/src, web/src, api/alembic нет изменений (git diff origin/test --stat -- api/src web/src api/alembic пуст).

  • Step 6: Commit — perf: отчёт о проблемах производительности.