docs(plan): план профилирования
Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
337852c435
commit
b4b2d1bc8b
334
docs/superpowers/plans/2026-10-25-perf-profiling.md
Normal file
334
docs/superpowers/plans/2026-10-25-perf-profiling.md
Normal file
@ -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/<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: отчёт о проблемах производительности`.
|
||||
Loading…
x
Reference in New Issue
Block a user