DFiP_Budget_planing/docs/superpowers/plans/2026-10-25-perf-profiling.md
Andrey Epifancev b4b2d1bc8b docs(plan): план профилирования
Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:00:04 +04:00

335 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Профилирование фронта и бэка — план исполнения
> **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`.
- Бэк и 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: отчёт о проблемах производительности`.