From b4b2d1bc8bb77cae712884ee3aa0401482cbb59e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:00:04 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #202 Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-25-perf-profiling.md | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-25-perf-profiling.md diff --git a/docs/superpowers/plans/2026-10-25-perf-profiling.md b/docs/superpowers/plans/2026-10-25-perf-profiling.md new file mode 100644 index 0000000..74d870b --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-perf-profiling.md @@ -0,0 +1,334 @@ +# Профилирование фронта и бэка — план исполнения + +> **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//`, агрегатор сводит их в `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//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 содержит ` 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//.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 ` пишет `summary.json` и `summary.md` в `perf/report-data//`. + +- [ ] **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//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 ` печатает три наклона. +- [ ] **Step 5: Commit** — `perf: сценарий памяти`. + +### Task 9: Замеры бэка — запросы сценариев, pg_stat_statements, EXPLAIN + +**Модель:** sonnet + +**Files:** +- Modify: `perf/e2e/lib/results.ts` — сохранять сетевой лог (`page.on('requestfinished')`: метод, путь, статус, `timing`, размер тела) в `perf/results//network.jsonl` во всех сценариях Task 5–8 (через общую фикстуру). +- Create: `perf/perfkit/bench.py` — CLI `python -m perfkit.bench --repeats 20`: берёт уникальные `GET` и `PATCH`/`POST` (только идемпотентные PATCH ячеек с тем же значением) из `network.jsonl`, повторяет их через httpx под той же ролью. Пишет записи `scenario: "api"`, `target: " "`, метрики `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/.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//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//`. +- [ ] **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: отчёт о проблемах производительности`.