perf: полный прогон run-all.sh и правки по ревью

- perf/run-all.sh: масштабы 1 и 2, шаги reset -> gen -> verify -> stand ->
  preflight -> scroll -> coauthor -> grow -> modals -> modals_profiling ->
  memory -> memtrend -> [control4x] -> bench -> aggregate; лог и
  run-all.status.json, возобновление (PERF_STEPS, PERF_SKIP_RESET),
  сухой режим с оценкой длительности, PERF_S2_REPEATS/PERF_S2_TARGETS,
  контроль CPU 4x (PERF_CONTROL_TARGETS).
- Основная метрика: main_thread_ms и main_thread_ms_per_step в скролле
  (CDP TaskDuration), frame_excess_ms; в summary.md первыми строками.
- SQL-статистика по шагам: PERF_SQLSTATS_TAG -> sql/<шаг>/ (sqlstats --out).
- netlog: тела /auth/ не пишутся, поля password/token/secret вырезаются.
- scroll.spec: уникальные имена целей (лист ССП с суффиксом /ssp, год в
  отчёте проекта) - иначе Playwright не запускал файл из-за дублей.
- PERF_TRACE=0 для прогонов с одним повтором.
- README: scroll-coauthor, run-all, возобновление, без CPU 4/6 в матрицах.
- design.md: уточнена формулировка R-2, решения Task 10a.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:59:49 +04:00
parent d58fc3de29
commit 29867535b0
15 changed files with 626 additions and 47 deletions

View File

@ -157,7 +157,7 @@
| 2026-10-25 | Рендер компонента в коммите: `flags & PerformedWork` для тегов 0/1/11/15 + обход как в DevTools; `self_ms = actualDuration - сумма детей` | Единственный способ получить renders/self_ms без React DevTools extension | Приближение: не учитывает memo-bailout'ы вне обхода |
| 2026-10-25 | `modals.json`: Menu/Popover-контекстные меню (RowActionMenu, StagesTable Menu, ValidationErrorsAlert) не включены; Popover выбора колонок (`sheet_columns_tree`) включён как единственный оверлей, доступный на большом листе | Меню не модалки; select_vsp/select_expense/create_* на самом большом листе rf (FORM_2/AHR) не открываются (проверено на стенде) | Неполное покрытие «модалка поверх большого листа» |
| 2026-10-25 | Для открытия под admin без права на лист — роль admin только для модалок страниц /tasks, задач и админки; остальные под rf | Права строк есть у rf | Нет |
| 2026-10-26 | R-2: форм-модалки (выбор ВСП/статьи, создание программы/проекта) мерятся и на малом листе, и на «выращенном» (@big: FORM_2 AHR_RENT/UTILITY/SECURITY ~500 строк, FORM_4 AHR ~300) через perfkit/gen/grow.py, без пересоздания БД; grow запускается после скролл-сценариев | Ревью T7: на листах, где эти модалки существуют, 31–110 строк, а жалоба заказчика — на модалку большой формы; FORM_4 add_line O(n) не позволяет выращивать до 1000+ | БД после grow отличается от базовой на этих листах; цели одинаковы на масштабах 1 и 2 |
| 2026-10-26 | R-2: форм-модалки (выбор ВСП/статьи, создание программы/проекта) мерятся и на малом листе (FORM_2 AHR_RENT, FORM_4 AHR), и на «выращенном» (@big: FORM_2 AHR_UTILITY/AHR_SECURITY ~500 строк, FORM_4 CAP ~300; исходная формулировка рулинга называла выращиваемыми RENT и FORM_4 AHR - уточнено при исполнении, см. следующую строку) через perfkit/gen/grow.py, без пересоздания БД; grow запускается после скролл-сценариев | Ревью T7: на листах, где эти модалки существуют, 31–110 строк, а жалоба заказчика — на модалку большой формы; FORM_4 add_line O(n) не позволяет выращивать до 1000+ | БД после grow отличается от базовой на этих листах; цели одинаковы на масштабах 1 и 2 |
| 2026-10-26 | R-2, детали: grow растит FORM_2 AHR_UTILITY и AHR_SECURITY (rf) до 500 строк и FORM_4 CAP (rf, форма 13) до 300; AHR_RENT и FORM_4 AHR остаются малыми и служат «малым» листом пары (поэтому порядок запуска не влияет на малые замеры; на скролл-сценарии по UTILITY/SECURITY/CAP grow влияет, их запускать до grow). Цели те же на масштабах 1 и 2 | Один и тот же лист нельзя замерить и малым, и большим без жёсткого порядка; на FORM_4 add_line O(n) | После grow БД отличается от базовой; скролл-замеры этих трёх листов после grow не сравнимы с до |
| 2026-10-26 | Строки grow пустые (значения не заполняются), FORM_2: дубли ВСП допустимы (API принимает) | Для открытия модалки важно число строк в DOM/таблице, не данные | Нет |
| 2026-10-26 | Время grow на масштабе 1: FORM_2 +468/+469 строк по ~1 c (параллельно), FORM_4 CAP +190 строк 213 c (последовательно, ~1.1 c на строку, O(n)) | Замер | Для масштаба 2 цели те же - время не растёт |
@ -173,6 +173,18 @@
| 2026-10-26 | `perfkit.results.read_all` и aggregate игнорируют `network.jsonl`; aggregate исключает `metrics.traced == 1` и скрывает метрику `traced` | Сетевой лог лежит рядом с результатами; O-9 (трассировка искажает время) | Трассировочные повторы не видны в таблицах, только счётчик в заголовке группы |
| 2026-10-26 | `globalSetup` задаёт `PERF_RUN_ID` (минутный, если не задан), `globalTeardown` дампит SQL только при наличии `network.jsonl` | Один run_id для воркеров и teardown; тесты lib не должны писать sql_top | Запуск без PERF_RUN_ID через границу минуты раньше писал в разные каталоги |
| 2026-10-26 | R-3: Task 10 исполняется в два шага — T10a (run-all.sh, README, правки из carry, коммит) и T10b (анализ и REPORT.md); многочасовой прогон run-all.sh между ними запускает и контролирует контроллер, а не исполнитель | Прогон идёт часами, исполнитель-сабагент не должен висеть в ожидании и терять контекст | Лишний диспатч; при сбое прогона перезапуск по частям |
| 2026-10-26 | Основная метрика «время main thread на действие» для скролла - `main_thread_ms` = прирост CDP `Performance.TaskDuration` за скролл (как у модалок, `lib/probe.ts` `startProbe/stop`), и `main_thread_ms_per_step` = `main_thread_ms / steps` (шаг колеса 200 px); для смены фильтра свода - `main_thread_ms` на действие. Дополнительно probe даёт `frame_excess_ms` (сумма превышений кадра над 16,7 мс) для сверки. В `summary.md` эти две метрики идут первыми строками группы | Одинаковый источник для скролла и модалок; учитывает задачи короче 50 мс (то, что станет long task на слабой машине); не требует разбора trace и есть в каждом повторе, а не только в трассированном | TaskDuration включает накладные расходы харнесса (page.evaluate на шаг, < 1 мс) и фоновые задачи страницы; при сравнении «до/после» они одинаковы |
| 2026-10-26 | SQL-статистика по шагам: `globalTeardown` пишет `sql_top.json`/`explain/` в `<run_dir>/sql/<PERF_SQLSTATS_TAG>/` (`sqlstats dump --out`); run-all задаёт тег на каждый вызов Playwright (scroll, coauthor, modals, modals_profiling, memory, control4x_*). Параметры EXPLAIN берутся из общего `<run_dir>/network.jsonl` | Каждый вызов Playwright делает `pg_stat_statements_reset()` в globalSetup и dump в teardown: в одном run_id они перезатирали бы друг друга; общий dump на весь масштаб смешал бы сценарии и захватил бы SQL grow (add_line FORM_4 O(n)) | Для сравнения «весь масштаб» T10b складывает несколько sql_top; параметры EXPLAIN шага могут прийти из запроса другого сценария (берётся самый долгий подходящий) |
| 2026-10-26 | `netlog.ts`: тело не-GET для путей с `/auth/` не пишется (`post_data: null`), из остальных JSON/form-urlencoded тел вырезаются поля `password`/`passwd`/`pass`/`*token*`/`*secret*`; неразбираемое тело с таким словом - null | Ревью T9 (I-a): логин и пароль попадали в `network.jsonl`; bench повторяет только GET и идемпотентный PATCH ячеек, тела логина ему не нужны | Поле с «token» в имени у ячейки потерялось бы в логе (сейчас таких нет) |
| 2026-10-26 | Имена целей скролла: лист не-rf владельца с тем же именем, что у rf-листа, получает суффикс владельца (`FORM_2/AHR/null/ssp`); отчёт проекта - `project/<report_type>/<project_id>/<year>` (было без года) | Найдено при проверке `--list`: FORM_2 есть у ССП-01 и РФ-01, отчёты проекта за 2026 и 2027 одного типа - Playwright отказывался запускать `scroll.spec.ts` целиком из-за одинаковых названий тестов, а записи смешались бы в одну группу | Другие имена target, чем в прогонах t5-*; glob `project/LIMIT/1` больше не совпадает (нужен `project/LIMIT/1/*`) |
| 2026-10-26 | `PERF_TRACE=0` в scroll и scroll-coauthor отключает trace на повторе 0; run-all ставит его при 1 повторе (control4x, `PERF_S2_REPEATS=1`) | aggregate исключает `traced=1` (O-9): при единственном повторе с трейсом в статистике не осталось бы ни одной записи | На таких прогонах нет trace-файла для разбора |
| 2026-10-26 | `perf/run-all.sh`: канонический порядок reset -> gen -> verify -> stand -> preflight -> scroll -> coauthor -> grow -> modals -> modals_profiling -> memory -> memtrend -> [control4x] -> bench -> aggregate; run_id `<PERF_RUN_TAG>-s<scale>` (тег по умолчанию - дата); фатальны только reset, gen и stand (остаток масштаба SKIP, следующий масштаб идёт); verify не фатален | Порядок - R-2/T7 (grow после скролла и соавтора); verify может споткнуться о допуски масштаба 1 на масштабе 2 (T3.1), а данные уже есть | Прогон на «плохих» данных при ложно-нефатальном verify - видно по FAIL в status.json |
| 2026-10-26 | Шаг stand проверяет, что `:8080` отдаёт именно prod-сборку (`index.html` совпадает с `perf/.build/prod/index.html`), поднимает api/web при необходимости и гоняет smoke; шаг preflight делает `playwright test --list` всех сценариев масштаба | Профилирующая сборка на :8080 исказила бы основные цифры; ошибки манифеста и дубли названий ловятся за секунды, а не через часы | Ложный FAIL при пересборке prod без перезапуска preview |
| 2026-10-26 | Профилирующий прогон модалок (только масштаб 1) - `PERF_PRIORITY=1` (все цели приоритета 1, включая @big), 1 повтор, `:8081` (`stand.sh web-profiling`); модалки на обоих масштабах - `PERF_PRIORITY=1,2` одним вызовом (p1 x5, p2 x3) | Счётчики рендеров нужны для форм-модалок и страниц задач/проектов (p1); p2 - админка, вторым приоритетом | Нет render counts для модалок админки |
| 2026-10-26 | control4x: `PERF_CONTROL_TARGETS` - список через запятую, `modal:<id>` - модалка, `coauthor:<glob>` - скролл при соавторе, остальное - glob скролла; CPU 4, 1 повтор, без trace, роли скролла `PERF_CONTROL_ROLES` (rf,dfip); пишет в тот же run_id масштаба (группы различаются `cpu_rate`) | Решение человека: контроль 4x только для топ-3-4 находок, 1 повтор; один summary.md показывает 1x и 4x рядом | Контроль после grow: скролл UTILITY/SECURITY/FORM_4 CAP несопоставим с замером до grow |
| 2026-10-26 | `PERF_S2_REPEATS` (по умолчанию 3) и `PERF_S2_TARGETS` регулируют только скролл масштаба 2; модалки масштаба 2 - с обычными повторами | Решение человека: повторы не сокращать, регулятор - только для масштаба 2 при оценке > 6 ч; модалки дешёвые (~12 мин) | Нет |
| 2026-10-26 | В `perf/report-data/<run_id>/` (коммитится) шаг aggregate кладёт summary.json/md, modals-inventory.md, `sql/<шаг>/{sql_top.json,explain/}`, memtrend.txt, manifest/gen-stats/grow масштаба и run-all.status.json; traces, heap snapshots и сырые jsonl остаются в `perf/results/` | Каждая цифра REPORT.md должна ссылаться на файл в report-data (дизайн); sql_top ~110 КБ на шаг | Рост репозитория на ~1-2 МБ |
| 2026-10-26 | `sqlstats dump` берёт пользователей из манифеста `PERF_SCALE` (было всегда s1) | Масштаб 2 - свой манифест | Нет (логины одинаковые) |
## Decisions made with the human

View File

@ -26,7 +26,7 @@ perf/stand.sh down # остановить бэк, фронт и Postgres
```
perf/stand.sh reset # пустая БД (генератор на непустой БД откажется работать)
cd perf && .venv/bin/python -m perfkit.gen.run --scale 1 # ~20-40 мин, пишет results/manifest-s1.json
cd perf && .venv/bin/python -m perfkit.gen.run --scale 1 # ~12 мин на масштабе 1 (факт 11.4), ~45-70 мин на 2; пишет results/manifest-s1.json
cd perf && .venv/bin/python -m perfkit.gen.verify --scale 1 # должен напечатать VERIFY OK
```
@ -48,7 +48,9 @@ npx playwright test tests/harness.spec.ts # самопро
- `PERF_SCALE` — масштаб манифеста `perf/results/manifest-s<scale>.json` (по умолчанию 1).
- `PERF_API`, `PERF_WEB` — адреса стенда (`http://localhost:8000`, `http://localhost:8080`).
- Агрегация: `cd perf && .venv/bin/python -m perfkit.aggregate results/<run_id>`.
- Библиотеки `perf/e2e/lib/`: `auth.loginAs`, `cdp.*` (throttle, trace, gc, heap), `probe` (long tasks > 50 мс, кадры rAF; тяжёлый кадр — интервал > 50 мс), `results.append`, `manifest.loadManifest`.
- Библиотеки `perf/e2e/lib/`: `auth.loginAs`, `cdp.*` (throttle, trace, gc, heap, `mainThreadMs`), `probe` (long tasks > 50 мс, кадры rAF; тяжёлый кадр — интервал > 50 мс; `frame_excess_ms` — сумма превышений кадра над 16,7 мс; `startProbe(page)`/`stop()` добавляет `main_thread_ms`), `results.append`, `manifest.loadManifest`, `netlog.attachNetlog`.
- Троттлинг CPU выключен по решению человека (все матрицы по умолчанию на CPU 1); включается вручную `PERF_CPU_RATES` (например `1,4`), контроль 4x — шаг `control4x` в `run-all.sh`.
- Основная метрика сравнения (решение человека): суммарное время main thread на действие — `main_thread_ms` (прирост CDP `Performance.TaskDuration`: скрипт, стили, layout, задачи короче 50 мс тоже) и для скролла `main_thread_ms_per_step` (на шаг колеса 200 px). В `summary.md` они первые строки каждой группы.
### Сценарии скролла
@ -61,13 +63,29 @@ PERF_RUN_ID=t PERF_TARGETS='FORM_2/AHR/*' PERF_CPU_RATES=1 PERF_REPEATS=1 npx pl
cd .. && .venv/bin/python -m perfkit.aggregate results/s1
```
- Матрица: роль `rf` x листы с `owner=rf` и отчёты проектов x оси y,x x CPU 1 (без троттлинга, решение человека) x 3 повтора; `admin` x все листы x ось y x CPU x 1 повтор (только масштаб 1); `admin` и `dfip` x своды (svod1_MAIN/FORM_1..4, svod2_GO/RF, projects_summaries) x ось y x CPU x 3 повтора + одна смена фильтра на CPU 6 (target `.../filter`).
- target: `FORM_x/<sheet>/<direction|null>`, `project/<report_type>/<project_id>`, `svod/<name>`; к записи добавляется суффикс оси `@y` / `@x` (агрегатор группирует по target).
- Переменные: `PERF_TARGETS` (glob через запятую; сопоставляется с target без суффикса оси и с полным), `PERF_CPU_RATES` (по умолчанию `1`; `1,4,6` включает троттлинг вручную), `PERF_REPEATS` (число повторов вместо матричного), `PERF_ROLES` (`rf,admin,dfip`), `PERF_AXES` (`y,x`), `PERF_LOAD_TIMEOUT_MS` (ожидание данных, по умолчанию 15 мин; ответ свода 2 до 47 МБ), `PERF_MAX_STEPS`, `PERF_TEST_TIMEOUT_MS` (таймаут одного замера, по умолчанию 45 мин), `PERF_TEST_TIMEOUT_SVOD2_MS` (для свода 2, по умолчанию 3 ч: загрузка до 15 мин + скролл на CPU 6 порядка 40 мин).
- Матрица (CPU 1): роль `rf` x листы с `owner=rf` и отчёты проектов x оси y,x x 3 повтора; `admin` x все листы x ось y x 1 повтор (контроль, только масштаб 1); `admin` и `dfip` x своды (svod1_MAIN/FORM_1..4, svod2_GO/RF, projects_summaries) x ось y x 3 повтора + одна смена фильтра (target `.../filter`, на максимальном из `PERF_CPU_RATES`). На масштабе 1 — 249 тестов (rf 156, admin 61, dfip 32).
- target: `FORM_x/<sheet>/<direction|null>` (лист ССП с тем же именем, что у листа РФ, — с суффиксом владельца: `FORM_2/AHR/null/ssp`), `project/<report_type>/<project_id>/<year>`, `svod/<name>`; к записи добавляется суффикс оси `@y` / `@x` (агрегатор группирует по target).
- Переменные: `PERF_TARGETS` (glob через запятую; сопоставляется с target без суффикса оси и с полным), `PERF_CPU_RATES` (по умолчанию `1`; `1,4,6` включает троттлинг вручную), `PERF_REPEATS` (число повторов вместо матричного), `PERF_ROLES` (`rf,admin,dfip`), `PERF_AXES` (`y,x`), `PERF_LOAD_TIMEOUT_MS` (ожидание данных, по умолчанию 15 мин; ответ свода 2 до 47 МБ), `PERF_MAX_STEPS`, `PERF_TEST_TIMEOUT_MS` (таймаут одного замера, по умолчанию 45 мин), `PERF_TEST_TIMEOUT_SVOD2_MS` (для свода 2, по умолчанию 3 ч: загрузка до 15 мин + скролл ~7 мин на CPU 1, с запасом на ручной троттлинг), `PERF_TRACE=0` (не снимать trace на повторе 0).
- Каждый замер - новый browser context; CPU-троттлинг включается после загрузки данных (до скролла). Перед замером ждём >= 20 строк `tbody tr`; если не дождались - запись `incomplete: true` с `skip_reason`.
- Метрики: probe (long tasks, кадры), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), Task 10 может исключать его из статистики. Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Метрики: `main_thread_ms`, `main_thread_ms_per_step`, probe (long tasks, кадры, `frame_excess_ms`), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), aggregate исключает его из статистики (поэтому при 1 повторе нужен `PERF_TRACE=0`). Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.
### Скролл при соавторе
`perf/e2e/scenarios/scroll-coauthor.spec.ts`, бот `perf/perfkit/coauthor.py`. Автор `perf_rf` (роль rf) скроллит лист по оси y, пока бот `perf_rf_coauthor` правит числовые ячейки того же листа по WebSocket с частотой `rate` правок/с. Цели — самый большой лист каждой формы среди `owner=rf` (FORM_2/AHR 1154 строки, FORM_4/STRUCTURE 775; FORM_1 у rf нет). Бот стартует после загрузки листа и ждёт эха первой правки (READY), работает до SIGTERM после скролла. Только масштаб 1 (так в плане).
```
cd perf/e2e
PERF_RUN_ID=co npx playwright test scenarios/scroll-coauthor.spec.ts # 2 цели x rate 1,5 x 3 повтора, ~12 мин
PERF_RUN_ID=co PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=5 PERF_REPEATS=1 PERF_TRACE=0 npx playwright test scenarios/scroll-coauthor.spec.ts
cd .. && .venv/bin/python -m pytest -m stand tests/test_coauthor.py # самопроверка бота (нужен стенд)
```
- target `<form_type>/<sheet>/<direction|null>/coauthor<rate>@y`, scenario `scroll_coauthor`; метрики как у скролла + `coauthor_rate`, `coauthor_sent`, `coauthor_echoed`, `coauthor_errors`, `page_ws_updates` (сколько чужих `cell_updated` получила страница автора).
- Переменные: `PERF_COAUTHOR_RATES` (`1,5`), `PERF_TARGETS` (glob по имени листа), `PERF_REPEATS` (3), `PERF_CPU_RATES` (`1`), `PERF_TRACE`, `PERF_LOAD_TIMEOUT_MS`, `PERF_MAX_STEPS`, `PERF_TEST_TIMEOUT_MS`, `PERF_PYTHON`.
- Запись не пишется (тест падает), если бот не получил ни одного эха или страница не увидела ни одного `cell_updated` соавтора: иначе замер был бы «без соавтора».
- На rate 5 сервер подтверждает ~1,2 правки/с (одно WS-соединение обрабатывается последовательно): фактическую нагрузку показывают `coauthor_echoed`/`page_ws_updates`, а не номинальный rate.
### Модалки
Перечень - `perf/e2e/modals.json` (id, page, source `file:line`, `open{selector, action, pre}`, priority, роль; `skip_reason` для недостижимых из UI). `lib/modal.ts` (`measureOpen`: `click_to_visible_ms`, `long_task_n`, `long_task_ms`, `main_thread_ms` = прирост CDP `Performance.TaskDuration` от клика до отрисовки), `lib/reactProfile.ts` (минимальный `__REACT_DEVTOOLS_GLOBAL_HOOK__`: renders и self_ms по компонентам, нужна профилирующая сборка). Самопроверка: `npx playwright test tests/modal-lib.spec.ts`.
@ -81,9 +99,9 @@ PERF_WEB=http://localhost:8081 PERF_PROFILING=1 PERF_RUN_ID=m PERF_MODALS='sheet
cd .. && .venv/bin/python -m perfkit.modals_inventory results/m # report-data/m/modals-inventory.md
```
- Переменные: `PERF_MODALS` (glob по id; `<id>@big` - вариант на самом большом листе), `PERF_PRIORITY` (`1`, `2`, `1,2`), `PERF_ROLES`, `PERF_REPEATS`, `PERF_CPU_RATES` (по умолчанию `1`).
- Переменные: `PERF_MODALS` (glob по id; `<id>@big` - вариант на самом большом листе), `PERF_PRIORITY` (`1`, `2`, `1,2`), `PERF_ROLES`, `PERF_REPEATS`, `PERF_CPU_RATES` (по умолчанию `1`, троттлинг выключен).
- Каждый замер - новый browser context. Записи: `scenario: modal` (метрики `measureOpen`) и `modal_renders` (`metrics.renders_top` - топ-20 по self_ms, `commits`, `total_renders`).
- Оценка полной матрицы на CPU 1: priority 1 (17 целей x 5) около 6 мин, priority 2 (15 x 3) около 2 мин, profiling около 2 мин; всего 10-12 мин. С `PERF_CPU_RATES=1,4,6` priority 1 вырастет втрое.
- Оценка полной матрицы на CPU 1: priority 1 (17 целей x 5) около 6 мин, priority 2 (15 x 3) около 2 мин, profiling около 2 мин; всего 10-12 мин (150 тестов при `PERF_PRIORITY=1,2`, 21 в профилирующем прогоне приоритета 1).
Малый и большой лист. Форм-модалки (выбор ВСП/статьи, создание программы/проекта) существуют только на листах FORM_2 AHR_RENT/UTILITY/SECURITY (31 строка) и FORM_4 AHR/CAP/OPER (110 строк). Для зависимости от числа строк `perfkit.gen.grow` (`cd perf && .venv/bin/python -m perfkit.gen.grow --scale 1`, идемпотентно, ~4 мин, FORM_4 add_line O(n)) растит FORM_2 AHR_UTILITY/AHR_SECURITY до 500 и FORM_4 CAP до 300 строк и обновляет манифест. В `modals.json` у этих модалок `big_sheet`: пара записей `<id>` (малый лист: AHR_RENT, FORM_4/AHR) и `<id>@big` (UTILITY, FORM_4/CAP). Порядок для run-all: reset -> gen -> скролл -> соавтор -> grow -> модалки; малые замеры от grow не зависят, но скролл по UTILITY/SECURITY/CAP после grow несопоставим с до него.
@ -111,16 +129,72 @@ cd .. && .venv/bin/python -m perfkit.memtrend results/mem [--from-cycle 1]
### Бэк
Сценарии Task 5-8 пишут сетевой лог (`perf/e2e/lib/netlog.ts`, подключён в каждом сценарии после `newContext`) в `perf/results/<run_id>/network.jsonl`: метод, полный URL с query, путь, статус, `duration_ms`/`ttfb_ms`, `body_size` (сжатый, по CDP), тело не-GET, `scenario`/`target`/`role`. Фронт ходит в API через proxy `:8080/api`, оба origin учитываются. `globalSetup` Playwright фиксирует `PERF_RUN_ID` и делает `pg_stat_statements_reset()`, `globalTeardown` (если был сетевой лог) пишет `sql_top.json` и `explain/*.json`; `PERF_SQLSTATS=0` отключает, при недоступном Postgres печатается предупреждение, прогон не падает.
Сценарии Task 5-8 пишут сетевой лог (`perf/e2e/lib/netlog.ts`, подключён в каждом сценарии после `newContext`) в `perf/results/<run_id>/network.jsonl`: метод, полный URL с query, путь, статус, `duration_ms`/`ttfb_ms`, `body_size` (сжатый, по CDP), тело не-GET (для путей с `/auth/` не пишется, из остальных вырезаются поля `password`/`token`/`secret`), `scenario`/`target`/`role`. Фронт ходит в API через proxy `:8080/api`, оба origin учитываются. `globalSetup` Playwright фиксирует `PERF_RUN_ID` и делает `pg_stat_statements_reset()`, `globalTeardown` (если был сетевой лог) пишет `sql_top.json` и `explain/*.json` в каталог прогона, а при `PERF_SQLSTATS_TAG=<tag>` — в `<run_dir>/sql/<tag>/` (так делает `run-all.sh`: каждый вызов Playwright сбрасывает статистику, без тега следующий вызов перезатёр бы предыдущий); `PERF_SQLSTATS=0` отключает, при недоступном Postgres печатается предупреждение, прогон не падает.
```
cd perf
.venv/bin/python -m perfkit.bench results/<run_id> --repeats 20 # api.jsonl: scenario api, metrics ms/bytes
.venv/bin/python -m perfkit.aggregate results/<run_id> # таблицы, в т.ч. scenario api
.venv/bin/python -m perfkit.sqlstats dump results/<run_id> # вручную, если нужно перегенерировать
.venv/bin/python -m perfkit.sqlstats dump results/<run_id> [--out results/<run_id>/sql/<tag>] # вручную
```
- `bench`: повторяет уникальные GET (роль и токен из манифеста), `target` = `METHOD шаблон` (числовые сегменты -> `{id}`; разные form_id сливаются в одну группу, полный URL - в поле `url` записи). `ms` - полное время ответа с чтением тела, `bytes` - размер после распаковки (в логе `body_size` сжатый). PATCH ячеек повторяются только при `"idempotent": true` в строке лога; сценарии такой пометки не ставят (правки идут по WebSocket), поэтому PATCH не повторяются. POST/DELETE - никогда. Запросы, длившиеся в логе дольше `--max-logged-s` (60), пропускаются (свод 2). Повторный запуск заменяет `api.jsonl`.
- `sql_top.json`: топ по `total_exec_time` и `mean_exec_time`; поле `toplevel=false` - вложенные запросы внутри plpgsql-функций (их `p_*` в тексте - переменные функции, отдельно не выполнить). Собственные запросы статистики (EXPLAIN, `perf-sqlstats`) исключены.
- `explain/<n>.json`: топ-5 по `total_exec_time` среди top-level SELECT (не `v3.add_/upd_/del_`, не DML), в транзакции с ROLLBACK. Параметры: для `v3.v_form_view` и `v3.v_summary_view` берутся из сетевого лога (тот же разбор query, что у роутера), режим `analyze` (`EXPLAIN (ANALYZE, BUFFERS)`, `SET LOCAL app.user_id` роли из лога). Для остальных - `generic_plan` (PREPARE + `force_generic_plan` + EXPLAIN без выполнения), поле `mode`. План вызова функции - один Function Scan: внутренности функции видны только в `sql_top.json` (nested-строки).
- `aggregate` исключает записи с `metrics.traced == 1` (повтор под трассировкой, O-9) и не выводит метрику `traced`; число исключённых печатается в заголовке группы.
## Полный прогон: `perf/run-all.sh`
Все замеры на масштабах 1 и 2 одной командой (долго: оценка ~6,5 ч на масштаб 1 и ~13 ч на масштаб 2 при 3 повторах). Стенд должен быть собран (`perf/stand.sh all` хотя бы раз); `reset` сносит только volume `perf-pg` этого стенда.
```
PERF_DRY_RUN=1 perf/run-all.sh # план: шаги, команды, оценка по шагам и итог; ничего не запускает
nohup perf/run-all.sh > /tmp/run-all.out 2>&1 & # боевой прогон (масштаб 1, затем 2)
```
Порядок шагов на каждом масштабе (`grow` меняет размеры листов FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP, поэтому скролл и соавтор — до него, модалки — после):
| шаг | что делает | масштаб 1 | масштаб 2 |
|---|---|---|---|
| reset | `stand.sh reset`: новый volume Postgres, миграции, bootstrap, api | ~2 мин | ~2 мин |
| gen | `perfkit.gen.run --scale S` | ~12 мин | ~45-70 мин |
| verify | `perfkit.gen.verify --scale S` (не фатален) | ~1 мин | ~2 мин |
| stand | api на :8000, prod-сборка на :8080 (сверка `index.html` с `.build/prod`), smoke | ~1 мин | ~1 мин |
| preflight | `playwright test --list` всех сценариев масштаба (манифест, дубли названий) | секунды | секунды |
| scroll | `scroll.spec.ts`, полная матрица (admin-контроль только на масштабе 1) | ~4,2 ч | ~10,8 ч (3 повтора) |
| coauthor | `scroll-coauthor.spec.ts`, rate 1 и 5 | ~13 мин | — |
| grow | `perfkit.gen.grow --scale S` | ~4 мин | ~4 мин |
| modals | `modals.spec.ts`, `PERF_PRIORITY=1,2` (p1 x5, p2 x3, включая `@big`) | ~10 мин | ~12 мин |
| modals_profiling | `stand.sh web-profiling` + профилирующий прогон p1 на :8081 | ~3 мин | — |
| memory | `memory.spec.ts`, 20 циклов | ~60 мин | — |
| memtrend | `perfkit.memtrend` -> `memtrend.txt` | секунды | — |
| control4x | только по запросу (см. ниже) | ~5 мин на цель | |
| bench | `perfkit.bench <run_dir> --repeats 20 --scale S` | ~30 мин | ~45 мин |
| aggregate | `perfkit.aggregate`, `modals_inventory`, копирование в `report-data/<run_id>/` | ~1 мин | ~1 мин |
- run_id масштаба — `<PERF_RUN_TAG>-s<S>` (тег по умолчанию — сегодняшняя дата `YYYYMMDD`) или явный `PERF_RUN_ID_S1`/`PERF_RUN_ID_S2`; все сценарии масштаба пишут в один каталог `perf/results/<run_id>/`, SQL-статистика — по шагам в `sql/<шаг>/`.
- Лог `perf/results/<run_id>/run-all.log`: `START`/`END` шага со временем, кодом возврата и длительностью, вывод команд. Статус `run-all.status.json`: по шагу `OK`/`FAIL`/`SKIP`, `rc`, время, команда. Падение шага не останавливает прогон; падение `reset`, `gen` или `stand` пропускает остаток масштаба (следующий масштаб идёт). Код выхода 1, если был FAIL.
- В `perf/report-data/<run_id>/` (коммитится): `summary.json`, `summary.md`, `modals-inventory.md`, `sql/<шаг>/{sql_top.json,explain/}`, `memtrend.txt`, `manifest-sS.json`, `gen-stats-sS.json`, `grow-sS.json`, `run-all.status.json`. Traces, heap snapshots и сырые jsonl остаются в `perf/results/` (в git не попадают).
- Матричные переменные сценариев (`PERF_TARGETS`, `PERF_REPEATS`, `PERF_CPU_RATES`, `PERF_ROLES`, `PERF_MODALS`, `PERF_PRIORITY`, ...) из окружения не наследуются — их задаёт шаг; `PERF_LOAD_TIMEOUT_MS`, `PERF_TEST_TIMEOUT_MS`, `PERF_TEST_TIMEOUT_SVOD2_MS`, `PERF_MAX_STEPS` передаются как есть.
Возобновление (данные не пересоздаются, шаги выполняются в каноническом порядке):
```
PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=memory,memtrend,bench,aggregate perf/run-all.sh
PERF_SCALES=2 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=scroll,grow,modals,bench,aggregate perf/run-all.sh
```
- `PERF_SCALES` (`1 2`), `PERF_STEPS` (через запятую), `PERF_SKIP_RESET=1` (пропустить `reset` и `gen`), `PERF_RUN_TAG`, `PERF_BENCH_REPEATS` (20).
- Перезапуск `scroll`/`coauthor` после `grow` даёт несопоставимые цифры для FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP; повторно запущенный шаг дописывает записи в те же jsonl (агрегатор сложит их с прежними) — для чистого повтора удалить `<scenario>.jsonl` шага.
- Масштаб 2: `PERF_S2_REPEATS` (по умолчанию 3, как на масштабе 1; при 1 trace не снимается) и `PERF_S2_TARGETS` (glob целей скролла) — только для скролла масштаба 2. Оценка: 3 повтора ~13 ч, 2 — ~9,6 ч, 1 — ~6,3 ч; основная часть — свод 2 (строк ~в 4 раза больше, ответ ~150-190 МБ, при `PERF_LOAD_TIMEOUT_MS` 15 мин возможны `incomplete`).
Контроль на CPU 4x (решение человека: только для топ-3–4 находок, 1 повтор, цифры в отчёте помечаются как контрольные):
```
PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=control4x,aggregate \
PERF_CONTROL_TARGETS='FORM_2/AHR/null@y,svod/svod1_MAIN,modal:sheet_select_vsp@big,coauthor:FORM_2/AHR/null' perf/run-all.sh
```
- `PERF_CONTROL_TARGETS`: `modal:<id>` — модалка, `coauthor:<glob>` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор, `PERF_TRACE=0`; записи идут в тот же run_id (группы с `cpu_rate=4`), SQL — в `sql/control4x_*`.
- Контроль выполняется после `grow`: для FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP цифры 4x несопоставимы со скроллом 1x.

View File

@ -1,11 +1,21 @@
/** Playwright globalTeardown: sql_top.json + explain/*.json в каталог прогона. Только если был сетевой лог (т.е. гонялись сценарии). */
/**
* Playwright globalTeardown: sql_top.json + explain/*.json. Только если был сетевой лог (т.е. гонялись сценарии).
* PERF_SQLSTATS_TAG=<tag> - писать в <run_dir>/sql/<tag>/ (run-all.sh: несколько вызовов Playwright в одном run_id,
* у каждого свой reset/dump pg_stat_statements; без тега каждый вызов перезатирал бы <run_dir>/sql_top.json).
*/
import { existsSync } from 'node:fs';
import path from 'node:path';
import { sqlstats } from './global-setup';
import { runDir } from './results';
import { netlogFile } from './netlog';
export function dumpArgs(): string[] {
const tag = process.env.PERF_SQLSTATS_TAG?.replace(/[^\w.-]+/g, '_');
return ['dump', runDir(), ...(tag ? ['--out', path.join(runDir(), 'sql', tag)] : [])];
}
export default async function globalTeardown(): Promise<void> {
if (process.env.PERF_SQLSTATS === '0') return;
if (!existsSync(netlogFile())) return;
sqlstats(['dump', runDir()]);
sqlstats(dumpArgs());
}

View File

@ -3,7 +3,8 @@
* Формат строки: perfkit/bench.py (docstring). Подключается во всех сценариях одной строкой после newContext:
* const nl = attachNetlog(ctx, { scenario, target, role }); ... finally { await nl.flush(); await ctx.close(); }
* Размер тела - request.sizes().responseBodySize (по CDP, работает и для ответов 30-47 МБ, где response.body() нет).
* Тело запроса пишется для не-GET (для повтора в bench); ограничено 64 КБ.
* Тело запроса пишется для не-GET (для повтора в bench); ограничено 64 КБ. Секреты не пишутся (sanitizePost):
* для путей с /auth/ тело = null (логин, refresh), из остальных тел вырезаются поля password/token/secret.
*/
import type { BrowserContext, Request } from '@playwright/test';
import { appendFileSync, mkdirSync } from 'node:fs';
@ -14,6 +15,36 @@ import { runDir, runId } from './results';
export interface NetMeta { scenario: string; target: string; role: string }
const MAX_POST = 64 * 1024;
const SECRET_KEY = /password|passwd|^pass$|token|secret/i;
function stripSecrets(v: unknown): unknown {
if (Array.isArray(v)) return v.map(stripSecrets);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).filter(([k]) => !SECRET_KEY.test(k)).map(([k, x]) => [k, stripSecrets(x)]));
}
return v;
}
/**
* Тело не-GET запроса для лога: null для /auth/ и пустых тел; JSON и form-urlencoded - без полей password/token/secret;
* неразбираемое тело, в котором встречается такое слово, - null (лучше потерять тело, чем записать пароль).
*/
export function sanitizePost(pathname: string, post: string | null): string | null {
if (!post || pathname.includes('/auth/')) return null;
try {
const parsed = JSON.parse(post);
const clean = stripSecrets(parsed);
return JSON.stringify(clean) === JSON.stringify(parsed) ? post : JSON.stringify(clean);
} catch { /* не JSON */ }
if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(post)) {
const q = new URLSearchParams(post);
let changed = false;
for (const k of [...q.keys()]) if (SECRET_KEY.test(k)) { q.delete(k); changed = true; }
return changed ? q.toString() : post;
}
return SECRET_KEY.test(post) ? null : post;
}
export function netlogFile(): string { return path.join(runDir(), 'network.jsonl'); }
export function attachNetlog(ctx: BrowserContext, meta: NetMeta): { flush: () => Promise<void> } {
@ -26,8 +57,8 @@ export function attachNetlog(ctx: BrowserContext, meta: NetMeta): { flush: () =>
try { status = (await req.response())?.status() ?? 0; } catch { /* контекст закрыт */ }
try { body_size = (await req.sizes()).responseBodySize; } catch { /* контекст закрыт */ }
const t = req.timing();
const post = req.method() === 'GET' ? null : req.postData();
const u = new URL(url);
const post = req.method() === 'GET' ? null : sanitizePost(u.pathname, req.postData());
const rec = {
run_id: runId(), ts: new Date().toISOString(), ...meta,
method: req.method(), url, path: u.pathname, query: u.search.replace(/^\?/, ''), status,

View File

@ -1,10 +1,20 @@
/**
* Init-script для page.addInitScript(PROBE_SCRIPT).
* window.__perf.start() / stop() -> {long_task_n, long_task_ms, frame_n, heavy_frame_n,
* frame_p50_ms, frame_p95_ms, duration_ms}.
* frame_p50_ms, frame_p95_ms, frame_excess_ms, duration_ms}.
* Long task: > 50 мс (PerformanceObserver 'longtask'). Тяжёлый кадр: интервал между rAF > 50 мс.
* frame_excess_ms: сумма превышений интервала кадра над бюджетом 16,7 мс (60 Гц).
*
* Основная метрика (решение человека 2026-10-25, design.md): суммарное время main thread на действие.
* Её даёт Node-сторона: startProbe(page) / stop() добавляет main_thread_ms = прирост CDP Performance.TaskDuration
* (все задачи main thread: скрипт, стили, layout, paint-подготовка; задачи < 50 мс тоже) между start и концом действия
* (до 100 мс ожидания longtask в __perf.stop). perStep() даёт main_thread_ms_per_step для скролла.
*/
import type { Page } from '@playwright/test';
import { mainThreadMs } from './cdp';
export const HEAVY_FRAME_MS = 50;
export const FRAME_BUDGET_MS = 1000 / 60;
export interface ProbeMetrics {
long_task_n: number;
@ -13,12 +23,15 @@ export interface ProbeMetrics {
heavy_frame_n: number;
frame_p50_ms: number;
frame_p95_ms: number;
frame_excess_ms: number;
duration_ms: number;
}
export interface ProbeResult extends ProbeMetrics { main_thread_ms: number }
export const PROBE_SCRIPT = `(() => {
if (window.__perf) return;
const HEAVY = ${HEAVY_FRAME_MS};
const HEAVY = ${HEAVY_FRAME_MS}, BUDGET = ${FRAME_BUDGET_MS};
let active = false, t0 = 0, tasks = [], frames = [], last = 0, raf = 0;
try {
new PerformanceObserver((list) => {
@ -50,8 +63,30 @@ export const PROBE_SCRIPT = `(() => {
heavy_frame_n: frames.filter((f) => f > HEAVY).length,
frame_p50_ms: pct(s, 0.5),
frame_p95_ms: pct(s, 0.95),
frame_excess_ms: frames.reduce((a, f) => a + Math.max(0, f - BUDGET), 0),
duration_ms: performance.now() - t0,
};
},
};
})();`;
/**
* Замер действия: const p = await startProbe(page); ...действие...; const m = await p.stop();
* Требует PROBE_SCRIPT в init-скриптах страницы. main_thread_ms снимается сразу по окончании действия.
*/
export async function startProbe(page: Page): Promise<{ stop: () => Promise<ProbeResult> }> {
const t0 = await mainThreadMs(page);
await page.evaluate(() => (window as any).__perf.start());
return {
stop: async () => {
const t1 = await mainThreadMs(page);
const m: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop());
return { ...m, main_thread_ms: t1 - t0 };
},
};
}
/** Метрики на шаг скролла; при steps = 0 (фильтр, no_overflow) - пусто. */
export function perStep(m: ProbeResult, steps: number): { main_thread_ms_per_step?: number } {
return steps > 0 ? { main_thread_ms_per_step: m.main_thread_ms / steps } : {};
}

View File

@ -4,7 +4,7 @@
*
* Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `<form_type>/<sheet>/<direction|null>`), PERF_RUN_ID,
* PERF_REPEATS (3), PERF_CPU_RATES ('1'; троттлинг отключён решением человека, PERF_CPU_RATES=6 включает вручную), PERF_COAUTHOR_RATES ('1,5'), PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS,
* PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python).
* PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python), PERF_TRACE=0 (без trace на повторе 0).
* Цели: самый большой лист каждой формы из доступных роли rf (owner=rf).
* Запись не пишется (тест падает), если бот не получил эха своих правок или страница не увидела ни одного cell_updated.
*/
@ -13,7 +13,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { loadManifest, scale, PERF_DIR, type Sheet } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe';
import { PROBE_SCRIPT, startProbe, perStep, type ProbeResult } from '../lib/probe';
import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
@ -113,12 +113,12 @@ async function measure(browser: import('@playwright/test').Browser, s: Sheet, cp
await throttle(page, cpu);
bot = await startBot(s, rate);
const trace = repeat === 0;
const trace = repeat === 0 && process.env.PERF_TRACE !== '0'; // PERF_TRACE=0 - без трейса (1 повтор)
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
await page.evaluate(() => (window as any).__perf.start());
const pr = await startProbe(page);
const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS);
const probe: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop());
const probe: ProbeResult = await pr.stop();
if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); }
const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
@ -130,7 +130,7 @@ async function measure(browser: import('@playwright/test').Browser, s: Sheet, cp
append({
scenario: 'scroll_coauthor', target: targetName, role: 'rf', scale: scale(), cpu_rate: cpu, repeat,
metrics: {
...probe, axis: 0, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, steps: r.steps,
...probe, ...perStep(probe, r.steps), axis: 0, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, steps: r.steps,
coauthor_rate: rate, coauthor_sent: co.sent, coauthor_echoed: co.echoed, coauthor_errors: co.errors, page_ws_updates: pageUpdates,
},
artifacts,

View File

@ -13,12 +13,16 @@
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000)
* PERF_TEST_TIMEOUT_MS / PERF_TEST_TIMEOUT_SVOD2_MS таймаут теста (45 мин / 3 ч для свода 2)
* PERF_TRACE=0 не снимать trace на повторе 0 (по умолчанию снимается; запись traced=1 aggregate исключает)
*
* Основная метрика: main_thread_ms (CDP TaskDuration за скролл) и main_thread_ms_per_step (на шаг колеса 200 px);
* для смены фильтра - main_thread_ms на действие (шагов нет).
*/
import { test, type Page } from '@playwright/test';
import path from 'node:path';
import { loadManifest, scale, type User } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe';
import { PROBE_SCRIPT, startProbe, perStep, type ProbeResult } from '../lib/probe';
import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
@ -56,16 +60,22 @@ function buildTargets(): Target[] {
const m = loadManifest();
const out: Target[] = [];
const hasRole = (r: string) => ROLES.includes(r) && !!m.users[r];
// FORM_2 есть у ССП-01 и у РФ-01 с одинаковыми именами листов: лист не-rf владельца при совпадении имени получает
// суффикс владельца (`FORM_2/AHR/null/ssp`), иначе admin-контроль дал бы одинаковые названия тестов и смешал группы
const nameCount = new Map<string, number>();
for (const s of m.sheets) nameCount.set(sheetName(s), (nameCount.get(sheetName(s)) ?? 0) + 1);
for (const s of m.sheets) {
const name = nameCount.get(sheetName(s))! > 1 && s.owner !== 'rf' ? `${sheetName(s)}/${s.owner}` : sheetName(s);
const ra: Target['rolesAxes'] = [];
if (s.owner === 'rf' && hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) });
if (scale() === 1 && hasRole('admin')) ra.push({ role: 'admin', axes: ['y'], repeats: repeats(1) });
out.push({ name: sheetName(s), kind: 'sheet', url: s.url, respRe: new RegExp(`/api/v1/form/${s.form_id}/sheet/${s.sheet}(\\?|$)`), rolesAxes: ra });
out.push({ name, kind: 'sheet', url: s.url, respRe: new RegExp(`/api/v1/form/${s.form_id}/sheet/${s.sheet}(\\?|$)`), rolesAxes: ra });
}
for (const p of m.project_reports) {
const ra: Target['rolesAxes'] = [];
if (hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) });
out.push({ name: `project/${p.report_type}/${p.project_id}`, kind: 'project', url: p.url, respRe: new RegExp(`/api/v1/projects/${p.project_id}/report/${p.year}/${p.report_type}(\\?|$)`), rolesAxes: ra });
// год в имени: у проекта отчёты одного типа за 2026 и 2027 (иначе одинаковые названия тестов и смешанные группы)
out.push({ name: `project/${p.report_type}/${p.project_id}/${p.year}`, kind: 'project', url: p.url, respRe: new RegExp(`/api/v1/projects/${p.project_id}/report/${p.year}/${p.report_type}(\\?|$)`), rolesAxes: ra });
}
const svodRoles = ['admin', 'dfip'].filter(hasRole).map((role) => ({ role, axes: ['y'] as Axis[], repeats: repeats(3) }));
const svod1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
@ -201,13 +211,14 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
await net.send('Network.disable').catch(() => {});
await throttle(page, cpu);
const trace = repeat === 0 && !filter;
// PERF_TRACE=0 - без трейса (1 повтор: control4x, PERF_S2_REPEATS=1), иначе единственная запись была бы traced=1
const trace = repeat === 0 && !filter && process.env.PERF_TRACE !== '0';
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-${role}-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
let complete = true, steps = 0, probe: ProbeMetrics, stable = true, reason: string | undefined, effect = true;
let complete = true, steps = 0, probe: ProbeResult, stable = true, reason: string | undefined, effect = true;
const sigBefore = filter ? await signature(page) : '';
if (filter) await armStable(page);
await page.evaluate(() => (window as any).__perf.start());
const pr = await startProbe(page);
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
await input.fill('1');
@ -221,12 +232,12 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
if (r.reason) reason = r.reason;
else if (!complete) reason = 'scroll_incomplete';
}
probe = await page.evaluate(() => (window as any).__perf.stop());
probe = await pr.stop();
if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); }
const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
append({
...rec,
metrics: { ...probe, axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
artifacts,
incomplete: !complete || !stable || !effect,
...(reason ? { skip_reason: reason } : {}),

View File

@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PROBE_SCRIPT } from '../lib/probe';
import { PROBE_SCRIPT, startProbe } from '../lib/probe';
import { throttle } from '../lib/cdp';
import { append } from '../lib/results';
@ -22,6 +22,28 @@ test('probe detects long task', async ({ page }) => {
expect(m.duration_ms).toBeGreaterThan(0);
});
test('probe: main_thread_ms (CDP TaskDuration) и frame_excess_ms учитывают задачу 120 мс', async ({ page }) => {
await page.addInitScript(PROBE_SCRIPT);
await page.goto(BUSY);
const p = await startProbe(page);
await page.click('#busy');
const m = await p.stop();
expect(m.main_thread_ms).toBeGreaterThanOrEqual(100);
expect(m.main_thread_ms).toBeLessThan(m.duration_ms);
expect(m.frame_excess_ms).toBeGreaterThanOrEqual(80); // кадр ~120 мс сверх бюджета 16,7 мс
expect(m.long_task_ms).toBeGreaterThanOrEqual(100);
});
test('probe: main_thread_ms на простое - малый', async ({ page }) => {
await page.addInitScript(PROBE_SCRIPT);
await page.goto(BUSY);
const p = await startProbe(page);
await page.waitForTimeout(300);
const m = await p.stop();
expect(m.main_thread_ms).toBeGreaterThanOrEqual(0);
expect(m.main_thread_ms).toBeLessThan(50);
});
test('throttle slows script', async ({ page }) => {
await page.goto(BUSY);
const loop = () => page.evaluate(() => {
@ -66,3 +88,17 @@ test('results format', async () => {
rmSync(tmp, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
test('globalTeardown: PERF_SQLSTATS_TAG -> dump --out <run_dir>/sql/<tag>', async () => {
const { dumpArgs } = await import('../lib/global-teardown');
process.env.PERF_RESULTS_DIR = '/tmp/perf-rd';
try {
delete process.env.PERF_SQLSTATS_TAG;
expect(dumpArgs()).toEqual(['dump', '/tmp/perf-rd']);
process.env.PERF_SQLSTATS_TAG = 'modals profiling';
expect(dumpArgs()).toEqual(['dump', '/tmp/perf-rd', '--out', '/tmp/perf-rd/sql/modals_profiling']);
} finally {
delete process.env.PERF_SQLSTATS_TAG;
delete process.env.PERF_RESULTS_DIR;
}
});

View File

@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { attachNetlog, sanitizePost } from '../lib/netlog';
import { WEB } from '../lib/auth';
test('sanitizePost: /auth/ - тело не пишется', () => {
expect(sanitizePost('/api/v1/auth/login', JSON.stringify({ username: 'perf_rf', password: 'perf' }))).toBeNull();
expect(sanitizePost('/api/v1/auth/refresh', JSON.stringify({ refresh_token: 'abc' }))).toBeNull();
});
test('sanitizePost: поля password/token вырезаются из JSON на любой глубине', () => {
const out = sanitizePost('/api/v1/users/', JSON.stringify({
username: 'u', password: 'p', nested: { access_token: 't', Token: 'x', keep: 1 }, list: [{ secret: 's', v: 2 }],
}));
expect(out).not.toBeNull();
const v = JSON.parse(out!);
expect(v).toEqual({ username: 'u', nested: { keep: 1 }, list: [{ v: 2 }] });
expect(out).not.toMatch(/"p"|"t"|"x"|"s"/);
});
test('sanitizePost: form-urlencoded - поля password/token вырезаются', () => {
expect(sanitizePost('/api/v1/x', 'username=u&password=p&token=t&a=1')).toBe('username=u&a=1');
});
test('sanitizePost: тело без секретов не меняется, пустое - null', () => {
const body = JSON.stringify({ value: 1000, column: 'plan' });
expect(sanitizePost('/api/v1/form/7/sheet/AHR/cell', body)).toBe(body);
expect(sanitizePost('/api/v1/x', null)).toBeNull();
});
test('attachNetlog: логин не попадает в network.jsonl, остальные POST - без секретов', async ({ browser }) => {
const tmp = mkdtempSync(path.join(tmpdir(), 'perf-netlog-'));
process.env.PERF_RESULTS_DIR = tmp;
const ctx = await browser.newContext();
try {
// стенд не нужен: все запросы к WEB отвечает route
await ctx.route(WEB + '/**', (r) => r.fulfill({ status: 200, contentType: r.request().url().includes('/api/') ? 'application/json' : 'text/html', body: r.request().url().includes('/api/') ? '{}' : '<html></html>' }));
const nl = attachNetlog(ctx, { scenario: 'netlog_test', target: 't', role: 'rf' });
const page = await ctx.newPage();
await page.goto(WEB + '/');
await page.evaluate(async () => {
const post = (u: string, b: unknown) => fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
await post('/api/v1/auth/login', { username: 'perf_rf', password: 'SECRET-PASS' });
await post('/api/v1/form/7/rows', { value: 1, token: 'SECRET-TOKEN' });
});
await page.waitForTimeout(300); // событие requestfinished приходит после завершения fetch на странице
await nl.flush();
const lines = readFileSync(path.join(tmp, 'network.jsonl'), 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
const login = lines.find((l) => l.path === '/api/v1/auth/login');
const rows = lines.find((l) => l.path === '/api/v1/form/7/rows');
expect(login).toBeTruthy();
expect(login.post_data).toBeNull();
expect(JSON.parse(rows.post_data)).toEqual({ value: 1 });
expect(readFileSync(path.join(tmp, 'network.jsonl'), 'utf-8')).not.toMatch(/SECRET/);
} finally {
await ctx.close();
delete process.env.PERF_RESULTS_DIR;
rmSync(tmp, { recursive: true, force: true });
}
});

View File

@ -1,6 +1,7 @@
import { test, expect } from '@playwright/test';
import path from 'node:path';
import { scrollThrough } from '../lib/scroll';
import { PROBE_SCRIPT, startProbe, perStep } from '../lib/probe';
const fx = (n: string) => 'file://' + path.resolve(__dirname, '../fixtures', n);
@ -42,3 +43,16 @@ test('scrollThrough: ось x на длинном по высоте, но не
expect(r.complete).toBe(false);
expect(r.reason).toBe('no_overflow');
});
test('scroll + probe: main_thread_ms_per_step = main_thread_ms / steps', async ({ page }) => {
await page.addInitScript(PROBE_SCRIPT);
await page.goto(fx('long-list.html'));
const p = await startProbe(page);
const r = await scrollThrough(page, '#box', 'y');
const m = await p.stop();
expect(r.steps).toBeGreaterThan(0);
expect(m.main_thread_ms).toBeGreaterThan(0);
const ps = perStep(m, r.steps);
expect(ps.main_thread_ms_per_step).toBeCloseTo(m.main_thread_ms / r.steps, 6);
expect(perStep(m, 0)).toEqual({}); // нет шагов (фильтр, no_overflow) - метрики на шаг нет
});

View File

@ -10,6 +10,12 @@ from . import REPORT_DATA_DIR
from .results import read_all
GROUP_KEYS = ("scenario", "target", "role", "scale", "cpu_rate")
# основная метрика сравнения (решение человека 2026-10-25): суммарное время main thread на действие - первыми строками
PRIMARY_METRICS = ("main_thread_ms_per_step", "main_thread_ms")
def _metric_order(m: str):
return (PRIMARY_METRICS.index(m), "") if m in PRIMARY_METRICS else (len(PRIMARY_METRICS), m)
def _p95(sorted_vals):
@ -48,7 +54,7 @@ def summarize(run_dir) -> dict:
for key in sorted(groups, key=lambda k: tuple(str(x) for x in k)):
g = groups[key]
metrics = {}
for m, vals in sorted(g["vals"].items()):
for m, vals in sorted(g["vals"].items(), key=lambda kv: _metric_order(kv[0])):
s = sorted(vals)
metrics[m] = {"median": _median(s), "p95": _p95(s), "min": s[0], "max": s[-1], "n": len(s)}
out.append({**dict(zip(GROUP_KEYS, key)), "n": g["n"], "incomplete_n": g["incomplete_n"], "traced_n": g["traced_n"], "metrics": metrics})

View File

@ -2,7 +2,10 @@
CLI (его зовут globalSetup/globalTeardown Playwright):
python -m perfkit.sqlstats reset
python -m perfkit.sqlstats dump <run_dir> [--top 15]
python -m perfkit.sqlstats dump <run_dir> [--top 15] [--out <dir>]
--out: каталог для sql_top.json и explain/ (по умолчанию run_dir). Сетевой лог для параметров EXPLAIN читается
всегда из <run_dir>/network.jsonl. Его задаёт globalTeardown по PERF_SQLSTATS_TAG (<run_dir>/sql/<tag>):
run-all.sh гоняет несколько вызовов Playwright в одном run_id, и без --out каждый перезатирал бы sql_top.json.
Окружение: PERF_DB_DSN (по умолчанию postgresql://perf:perf@localhost:55432/perf). Если Postgres недоступен,
команды печатают предупреждение в stderr и завершаются с кодом 0 (прогон сценариев не должен падать из-за статистики).
@ -179,7 +182,7 @@ async def _explain_one(conn, query: str, params, user_id: int | None) -> tuple[s
await conn.execute("DEALLOCATE perf_gp") # PREPARE живёт в сессии, а не в транзакции
async def _dump(run_dir: Path, top: int) -> dict:
async def _dump(run_dir: Path, top: int, out_dir: Path) -> dict:
conn = await _connect()
try:
rows = [dict(r) for r in await conn.fetch(STAT_SQL)]
@ -188,7 +191,7 @@ async def _dump(run_dir: Path, top: int) -> dict:
r[k] = round(float(r[k]), 3)
by_total = sorted(rows, key=lambda r: r["total_exec_time"], reverse=True)[:top]
by_mean = sorted(rows, key=lambda r: r["mean_exec_time"], reverse=True)[:top]
(run_dir / "sql_top.json").write_text(
(out_dir / "sql_top.json").write_text(
json.dumps({"by_total": by_total, "by_mean": by_mean}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
netlog = []
@ -199,16 +202,16 @@ async def _dump(run_dir: Path, top: int) -> dict:
manifest_users = {}
try:
from .manifest import load, manifest_path
manifest_users = {k: u.username for k, u in load(manifest_path(1)).users.items()}
manifest_users = {k: u.username for k, u in load(manifest_path(int(os.environ.get("PERF_SCALE", "1")))).users.items()}
except Exception: # noqa: BLE001
pass
cands = [r for r in rows if r["toplevel"]]
cands = sorted([r for r in cands if is_explainable(r["query"])], key=lambda r: r["total_exec_time"], reverse=True)
samples = build_samples(cands, netlog)
out_dir = run_dir / "explain"
out_dir.mkdir(exist_ok=True)
for old in out_dir.glob("*.json"): # результаты прошлого dump в том же каталоге
ex_dir = out_dir / "explain"
ex_dir.mkdir(exist_ok=True)
for old in ex_dir.glob("*.json"): # результаты прошлого dump в том же каталоге
old.unlink()
done, skipped = 0, []
for r in cands:
@ -223,24 +226,26 @@ async def _dump(run_dir: Path, top: int) -> dict:
skipped.append({"query": r["query"], "error": f"{type(ex).__name__}: {str(ex)[:300]}"})
continue
done += 1
(out_dir / f"{done}.json").write_text(json.dumps({
(ex_dir / f"{done}.json").write_text(json.dumps({
"query": r["query"], "calls": r["calls"], "total_exec_time": r["total_exec_time"],
"mean_exec_time": r["mean_exec_time"], "mode": mode, "analyze": mode == "analyze",
"params": params, "param_source": ({k: entry.get(k) for k in ("method", "url", "role")} if entry else None),
"plan": plan}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
if skipped:
(out_dir / "skipped.json").write_text(json.dumps(skipped, ensure_ascii=False, indent=2), encoding="utf-8")
(ex_dir / "skipped.json").write_text(json.dumps(skipped, ensure_ascii=False, indent=2), encoding="utf-8")
return {"explained": done, "skipped": len(skipped)}
finally:
await conn.close()
def dump(run_dir, top: int = 15) -> bool:
def dump(run_dir, top: int = 15, out_dir=None) -> bool:
run_dir = Path(run_dir)
out_dir = Path(out_dir) if out_dir is not None else run_dir
run_dir.mkdir(parents=True, exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True)
try:
info = asyncio.run(_dump(run_dir, top))
print(f"sqlstats: sql_top.json, explain: {info['explained']} (пропущено {info['skipped']}) -> {run_dir}")
info = asyncio.run(_dump(run_dir, top, out_dir))
print(f"sqlstats: sql_top.json, explain: {info['explained']} (пропущено {info['skipped']}) -> {out_dir}")
return True
except Exception as ex: # noqa: BLE001
_warn(f"dump не выполнен, sql_top.json/explain не созданы: {type(ex).__name__}: {ex}")
@ -254,9 +259,10 @@ def main(argv=None) -> int:
return 0
if len(argv) >= 2 and argv[0] == "dump":
top = int(argv[argv.index("--top") + 1]) if "--top" in argv else 15
dump(argv[1], top)
out = argv[argv.index("--out") + 1] if "--out" in argv else None
dump(argv[1], top, out)
return 0
print("usage: python -m perfkit.sqlstats reset | dump <run_dir> [--top N]", file=sys.stderr)
print("usage: python -m perfkit.sqlstats reset | dump <run_dir> [--top N] [--out DIR]", file=sys.stderr)
return 2

242
perf/run-all.sh Executable file
View File

@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Полный прогон замеров на масштабах 1 и 2 (Task 10). Долго: часы. Описание и порядок шагов - perf/README.md («Полный прогон»).
#
# perf/run-all.sh # всё: масштаб 1, затем 2
# PERF_DRY_RUN=1 perf/run-all.sh # план: шаги, команды, оценка длительности; ничего не запускает
# PERF_SCALES=2 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=modals,bench,aggregate perf/run-all.sh # возобновить
# PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_STEPS=control4x,aggregate \
# PERF_CONTROL_TARGETS='FORM_2/AHR/null@y,modal:sheet_select_vsp' perf/run-all.sh # контроль CPU 4x
#
# Переменные:
# PERF_SCALES масштабы по порядку (по умолчанию "1 2")
# PERF_STEPS шаги через запятую/пробел (по умолчанию все, кроме control4x); выполняются в каноническом порядке
# PERF_SKIP_RESET=1 не пересоздавать данные: шаги reset и gen пропускаются (SKIP), даже если перечислены
# PERF_RUN_TAG префикс run_id (по умолчанию дата YYYYMMDD); run_id = <tag>-s<scale>, каталог perf/results/<run_id>/
# PERF_RUN_ID_S1 / PERF_RUN_ID_S2 явный run_id масштаба (вместо <tag>-s<scale>)
# PERF_S2_REPEATS повторы скролла на масштабе 2 (по умолчанию 3, как на масштабе 1; при 1 - без trace)
# PERF_S2_TARGETS glob(ы) PERF_TARGETS для скролла на масштабе 2 (по умолчанию все)
# PERF_BENCH_REPEATS повторы bench (20)
# PERF_CONTROL_TARGETS шаг control4x: список через запятую; `modal:<id>` - модалка, `coauthor:<glob>` - скролл при
# соавторе, остальное - glob PERF_TARGETS скролла. CPU 4x, 1 повтор, без trace.
# PERF_CONTROL_ROLES роли скролла в control4x (по умолчанию rf,dfip: rf - листы и отчёты, dfip - своды)
# PERF_DRY_RUN=1 только план
# Сквозные переменные сценариев (PERF_LOAD_TIMEOUT_MS, PERF_TEST_TIMEOUT_MS, PERF_TEST_TIMEOUT_SVOD2_MS, PERF_MAX_STEPS)
# передаются как есть; матричные (PERF_TARGETS, PERF_REPEATS, PERF_CPU_RATES, PERF_ROLES, PERF_MODALS, PERF_PRIORITY...)
# из окружения вызывающего игнорируются - их задаёт шаг.
#
# Лог: perf/results/<run_id>/run-all.log (START/END шага со временем, кодом возврата и длительностью, вывод команд).
# Статус: perf/results/<run_id>/run-all.status.json (по шагу: OK | FAIL | SKIP, rc, время). Падение шага не
# останавливает прогон; падение reset, gen или stand прекращает текущий масштаб (без данных/стенда дальше нет смысла).
# Код выхода: 0 - все шаги OK/SKIP, 1 - был FAIL.
set -uo pipefail
PERF="$(cd "$(dirname "$0")" && pwd)"
E2E="$PERF/e2e"
PY="$PERF/.venv/bin/python"
WEB_URL="http://localhost:8080"
PROF_URL="http://localhost:8081"
API_URL="${PERF_API:-http://localhost:8000}"
SCALES="${PERF_SCALES:-1 2}"
RUN_TAG="${PERF_RUN_TAG:-$(date +%Y%m%d)}"
S2_REPEATS="${PERF_S2_REPEATS:-3}"
S2_TARGETS="${PERF_S2_TARGETS:-}"
BENCH_REPEATS="${PERF_BENCH_REPEATS:-20}"
CONTROL_TARGETS="${PERF_CONTROL_TARGETS:-}"
CONTROL_ROLES="${PERF_CONTROL_ROLES:-rf,dfip}"
DRY="${PERF_DRY_RUN:-0}"
SKIP_RESET="${PERF_SKIP_RESET:-0}"
# канонический порядок: grow меняет размеры листов FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP,
# поэтому скролл и соавтор - до grow, модалки (малые и @big) - после (design.md, R-2)
ALL_STEPS="reset gen verify stand preflight scroll coauthor grow modals modals_profiling memory memtrend control4x bench aggregate"
DEFAULT_STEPS="reset gen verify stand preflight scroll coauthor grow modals modals_profiling memory memtrend bench aggregate"
FATAL_STEPS="reset gen stand"
STEPS="$(echo "${PERF_STEPS:-$DEFAULT_STEPS}" | tr ',' ' ')"
for s in $STEPS; do
case " $ALL_STEPS " in *" $s "*) ;; *) echo "неизвестный шаг: $s (есть: $ALL_STEPS)" >&2; exit 2 ;; esac
done
# переменные матрицы сценариев, которые шаг задаёт сам (из окружения вызывающего не наследуются)
CLEAN_ENV="env -u PERF_TARGETS -u PERF_REPEATS -u PERF_CPU_RATES -u PERF_ROLES -u PERF_AXES -u PERF_MODALS -u PERF_PRIORITY -u PERF_PROFILING -u PERF_SQLSTATS_TAG -u PERF_SQLSTATS -u PERF_COAUTHOR_RATES -u PERF_TRACE -u PERF_RESULTS_DIR -u PERF_MEM_CYCLES -u PERF_MEM_SNAPSHOTS"
# Оценка длительности, мин (по замерам Task 3, 5-9 на масштабе 1, без троттлинга; масштаб 2 - экстраполяция:
# листы x2 строк, отчёты проектов x2 штук, свод 2 ~x4 строк). Подробно - task-10a-report.md / README.
estimate() { # step scale -> минуты (целое)
local st="$1" sc="$2"
if [ "$sc" = 1 ]; then
case "$st" in
reset) echo 2 ;; gen) echo 12 ;; verify) echo 1 ;; stand) echo 1 ;; preflight) echo 1 ;;
scroll) echo 250 ;; coauthor) echo 13 ;; grow) echo 4 ;; modals) echo 10 ;; modals_profiling) echo 3 ;;
memory) echo 60 ;; memtrend) echo 0 ;; bench) echo 30 ;; aggregate) echo 1 ;;
control4x) control_estimate ;;
esac
else
case "$st" in
reset) echo 2 ;; gen) echo 60 ;; verify) echo 2 ;; stand) echo 1 ;; preflight) echo 1 ;;
scroll) echo $((200 * S2_REPEATS + 50)) ;; grow) echo 4 ;; modals) echo 12 ;;
bench) echo 45 ;; aggregate) echo 1 ;; control4x) control_estimate ;;
*) echo 0 ;;
esac
fi
}
control_estimate() { # ~5 мин на цель скролла/модалки на CPU 4 (свод 2 - до 40 мин)
local n=0; IFS=',' read -ra arr <<<"$CONTROL_TARGETS"; for t in "${arr[@]}"; do [ -n "$t" ] && n=$((n + 1)); done
echo $((n * 5))
}
only_scale1() { case "$1" in coauthor|modals_profiling|memory|memtrend) return 0 ;; esac; return 1; }
hours() { echo "$(( $1 / 60 )).$(( ($1 % 60) * 10 / 60 ))"; }
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
pw() { # "<env assignments>" <spec> -> строка команды Playwright
echo "cd '$E2E' && $CLEAN_ENV PERF_SCALE=$SCALE PERF_RUN_ID=$RID PERF_WEB=${3:-$WEB_URL} $1 npx playwright test $2"
}
# Команда шага (строка для eval). Пустая строка - шаг не применим (SKIP).
step_cmd() {
local st="$1"
case "$st" in
reset) echo "'$PERF/stand.sh' reset" ;;
gen) echo "cd '$PERF' && '$PY' -m perfkit.gen.run --scale $SCALE" ;;
verify) echo "cd '$PERF' && '$PY' -m perfkit.gen.verify --scale $SCALE" ;;
stand)
# api и prod-сборка фронта на :8080 (index.html совпадает с perf/.build/prod), smoke (логин, proxy /api, WS)
echo "(curl -fsS -o /dev/null '$API_URL/healthz' || '$PERF/stand.sh' api)" \
"&& ([ -f '$PERF/.build/prod/index.html' ] || '$PERF/stand.sh' web-build)" \
"&& (curl -fsS -o /dev/null '$WEB_URL/' || '$PERF/stand.sh' web)" \
"&& curl -fsS '$WEB_URL/' | cmp -s - '$PERF/.build/prod/index.html'" \
"&& echo 'web :8080 = prod-сборка' && '$PERF/stand.sh' smoke" ;;
preflight)
# список тестов каждого сценария на манифесте масштаба: ловит ошибки манифеста и дубли названий до часов прогона
local specs="scenarios/scroll.spec.ts scenarios/modals.spec.ts"
[ "$SCALE" = 1 ] && specs="$specs scenarios/scroll-coauthor.spec.ts scenarios/memory.spec.ts"
echo "cd '$E2E' && for f in $specs; do $CLEAN_ENV PERF_SCALE=$SCALE PERF_PRIORITY=1,2 npx playwright test --list \$f | tail -1 || exit 1; done" ;;
scroll)
if [ "$SCALE" = 1 ]; then
pw "PERF_SQLSTATS_TAG=scroll" scenarios/scroll.spec.ts
else
local extra="PERF_REPEATS=$S2_REPEATS"
[ "$S2_REPEATS" = 1 ] && extra="$extra PERF_TRACE=0"
[ -n "$S2_TARGETS" ] && extra="$extra PERF_TARGETS='$S2_TARGETS'"
pw "PERF_SQLSTATS_TAG=scroll $extra" scenarios/scroll.spec.ts
fi ;;
coauthor) pw "PERF_SQLSTATS_TAG=coauthor PERF_COAUTHOR_RATES=1,5" scenarios/scroll-coauthor.spec.ts ;;
grow) echo "cd '$PERF' && '$PY' -m perfkit.gen.grow --scale $SCALE" ;;
modals) pw "PERF_SQLSTATS_TAG=modals PERF_PRIORITY=1,2" scenarios/modals.spec.ts ;;
modals_profiling)
echo "'$PERF/stand.sh' web-profiling && $(pw "PERF_SQLSTATS_TAG=modals_profiling PERF_PROFILING=1 PERF_PRIORITY=1" scenarios/modals.spec.ts "$PROF_URL")" ;;
memory) pw "PERF_SQLSTATS_TAG=memory" scenarios/memory.spec.ts ;;
memtrend) echo "cd '$PERF' && '$PY' -m perfkit.memtrend 'results/$RID' | tee 'results/$RID/memtrend.txt'" ;;
control4x) control_cmd ;;
bench) echo "cd '$PERF' && '$PY' -m perfkit.bench 'results/$RID' --repeats $BENCH_REPEATS --scale $SCALE" ;;
aggregate)
# в report-data/<run_id>/ (коммитится): summary.json/md, modals-inventory.md, sql/<шаг>/{sql_top.json,explain/},
# memtrend.txt, manifest/gen-stats/grow масштаба, run-all.status.json. Сырое (traces, snapshots, jsonl) - в results/.
local out="$PERF/report-data/$RID"
echo "cd '$PERF' && '$PY' -m perfkit.aggregate 'results/$RID'" \
"&& { [ ! -f 'results/$RID/modal.jsonl' ] || '$PY' -m perfkit.modals_inventory 'results/$RID'; }" \
"&& mkdir -p '$out' && for d in results/$RID/sql/*/; do [ -d \"\$d\" ] || continue; t=\$(basename \"\$d\");" \
"mkdir -p '$out/sql/'\$t && cp \"\$d\"sql_top.json '$out/sql/'\$t/ 2>/dev/null;" \
"[ -d \"\$d\"explain ] && cp -R \"\$d\"explain '$out/sql/'\$t/; done;" \
"for f in results/$RID/memtrend.txt results/manifest-s$SCALE.json results/gen-stats-s$SCALE.json results/grow-s$SCALE.json results/$RID/run-all.status.json;" \
"do [ -f \"\$f\" ] && cp \"\$f\" '$out/'; done; ls -R '$out'" ;;
esac
}
# control4x: цели из PERF_CONTROL_TARGETS по видам, по одному вызову Playwright на вид; CPU 4, 1 повтор, без trace
control_cmd() {
[ -n "$CONTROL_TARGETS" ] || { echo "echo 'PERF_CONTROL_TARGETS пуст: нечего гонять' >&2; exit 1"; return; }
local scroll="" modal="" coauthor="" t parts=()
IFS=',' read -ra arr <<<"$CONTROL_TARGETS"
for t in "${arr[@]}"; do
t="$(echo "$t" | xargs)"; [ -n "$t" ] || continue
case "$t" in
modal:*) modal="${modal:+$modal,}${t#modal:}" ;;
coauthor:*) coauthor="${coauthor:+$coauthor,}${t#coauthor:}" ;;
*) scroll="${scroll:+$scroll,}$t" ;;
esac
done
local common="PERF_CPU_RATES=4 PERF_REPEATS=1 PERF_TRACE=0"
[ -n "$scroll" ] && parts+=("$(pw "PERF_SQLSTATS_TAG=control4x_scroll $common PERF_ROLES=$CONTROL_ROLES PERF_TARGETS='$scroll'" scenarios/scroll.spec.ts)")
[ -n "$coauthor" ] && parts+=("$(pw "PERF_SQLSTATS_TAG=control4x_coauthor $common PERF_TARGETS='$coauthor'" scenarios/scroll-coauthor.spec.ts)")
[ -n "$modal" ] && parts+=("$(pw "PERF_SQLSTATS_TAG=control4x_modals $common PERF_PRIORITY=1,2 PERF_MODALS='$modal'" scenarios/modals.spec.ts)")
# все части выполняются, код возврата - последней упавшей
local joined="rc=0" p
for p in "${parts[@]}"; do joined="$joined; ( $p ) || rc=\$?"; done
echo "$joined; exit \$rc"
}
status_update() { # step status rc start end cmd
"$PY" - "$STATUS" "$RID" "$SCALE" "$@" <<'EOF'
import json, sys, pathlib
f, rid, scale, step, status, rc, start, end, dur, cmd = sys.argv[1:]
p = pathlib.Path(f)
d = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {"run_id": rid, "scale": int(scale), "steps": {}}
d["steps"][step] = {"status": status, "rc": int(rc), "start": start, "end": end, "duration_s": int(dur), "cmd": cmd}
p.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
EOF
}
log() { echo "[$(ts)] $*" | tee -a "$LOG"; }
total_all=0
anyfail=0
s2total=0
for SCALE in $SCALES; do
rid_var="PERF_RUN_ID_S$SCALE"; RID="${!rid_var:-$RUN_TAG-s$SCALE}"
RDIR="$PERF/results/$RID"
LOG="$RDIR/run-all.log"
STATUS="$RDIR/run-all.status.json"
if [ "$DRY" = 1 ]; then
echo "=== масштаб $SCALE, run_id $RID (perf/results/$RID) ==="
total=0
for st in $ALL_STEPS; do
case " $STEPS " in *" $st "*) ;; *) continue ;; esac
if only_scale1 "$st" && [ "$SCALE" != 1 ]; then echo " SKIP $st (только масштаб 1)"; continue; fi
if [ "$SKIP_RESET" = 1 ] && { [ "$st" = reset ] || [ "$st" = gen ]; }; then echo " SKIP $st (PERF_SKIP_RESET=1)"; continue; fi
m="$(estimate "$st" "$SCALE")"; total=$((total + m))
printf ' %-17s ~%4s мин %s\n' "$st" "$m" "$(step_cmd "$st")"
done
printf ' ИТОГО масштаб %s: ~%s мин (~%s ч)\n\n' "$SCALE" "$total" "$(hours "$total")"
total_all=$((total_all + total))
[ "$SCALE" = 2 ] && s2total=$total
continue
fi
mkdir -p "$RDIR"
log "=== run-all: масштаб $SCALE, run_id $RID, шаги: $STEPS"
abort=""
for st in $ALL_STEPS; do
case " $STEPS " in *" $st "*) ;; *) continue ;; esac
now="$(ts)"
if [ -n "$abort" ]; then anyfail=1; log "SKIP $st (прервано: упал $abort)"; status_update "$st" SKIP -1 "$now" "$now" 0 "aborted after $abort"; continue; fi
if only_scale1 "$st" && [ "$SCALE" != 1 ]; then log "SKIP $st (только масштаб 1)"; status_update "$st" SKIP 0 "$now" "$now" 0 "scale 1 only"; continue; fi
if [ "$SKIP_RESET" = 1 ] && { [ "$st" = reset ] || [ "$st" = gen ]; }; then log "SKIP $st (PERF_SKIP_RESET=1)"; status_update "$st" SKIP 0 "$now" "$now" 0 "PERF_SKIP_RESET=1"; continue; fi
cmd="$(step_cmd "$st")"
log "START $st (оценка ~$(estimate "$st" "$SCALE") мин): $cmd"
t0=$(date +%s)
(eval "$cmd") >>"$LOG" 2>&1 </dev/null
rc=$?
dur=$(( $(date +%s) - t0 ))
if [ $rc -eq 0 ]; then res=OK; else res=FAIL; anyfail=1; fi
log "END $st $res rc=$rc ${dur}s"
status_update "$st" "$res" "$rc" "$now" "$(ts)" "$dur" "$cmd"
if [ $rc -ne 0 ]; then
case " $FATAL_STEPS " in *" $st "*) abort="$st"; log "FATAL: $st упал, остальные шаги масштаба $SCALE пропускаются" ;; esac
fi
done
# итоговый статус - и в report-data (если aggregate уже создал каталог)
[ -d "$PERF/report-data/$RID" ] && cp "$STATUS" "$PERF/report-data/$RID/"
log "=== масштаб $SCALE готов: $(jq -r '[.steps|to_entries[]|"\(.key)=\(.value.status)"]|join(" ")' "$STATUS" 2>/dev/null)"
done
if [ "$DRY" = 1 ]; then
printf 'ИТОГО: ~%s мин (~%s ч)\n' "$total_all" "$(hours "$total_all")"
[ "$S2_REPEATS" = 3 ] && case " $SCALES " in *" 2 "*)
s2rest=$(( s2total - (200 * S2_REPEATS + 50) ))
echo "Рекомендация: масштаб 2 при 3 повторах ~$(hours "$s2total") ч (> 6 ч). PERF_S2_REPEATS=2 -> ~$(hours $((s2rest + 450))) ч (n=1 без trace на группу)," \
"PERF_S2_REPEATS=1 -> ~$(hours $((s2rest + 250))) ч (n=1, trace не снимается). Крупнейшая часть - свод 2 (~4x строк). Рулинг - за контроллером." ;;
esac
fi
exit $anyfail

View File

@ -56,3 +56,15 @@ def test_read_all_ignores_network_log(tmp_path):
results.append(tmp_path, rec(metrics={"a": 1}))
(tmp_path / "network.jsonl").write_text(json.dumps({"method": "GET", "url": "http://x/api/v1/y"}) + "\n", encoding="utf-8")
assert len(summarize(tmp_path)["groups"]) == 1
def test_main_thread_metrics_in_md_first(tmp_path):
"""Основная метрика (решение человека): main_thread_ms_per_step / main_thread_ms - первыми строками таблицы группы."""
for v in (100.0, 200.0, 300.0):
results.append(tmp_path, rec(scenario="scroll", metrics={
"frame_n": 10, "long_task_ms": v, "main_thread_ms": v * 2, "main_thread_ms_per_step": v / 50, "steps": 50}))
md = render_md(summarize(tmp_path))
rows = [l.split("|")[1].strip() for l in md.splitlines() if l.startswith("| ") and not l.startswith("| metric")]
assert rows[:2] == ["main_thread_ms_per_step", "main_thread_ms"]
assert "| main_thread_ms_per_step | 4 | 6 | 2 | 6 | 3 |" in md
assert set(rows[2:]) == {"frame_n", "long_task_ms", "steps"}

View File

@ -1,3 +1,4 @@
from perfkit import sqlstats
from perfkit.sqlstats import explain_candidates, v3_functions, is_explainable, pick_params
@ -39,3 +40,30 @@ def test_explain_candidates_top5_by_total_in_order():
def test_pick_params_uses_sample_when_given():
assert pick_params("SELECT $1, $2", {"SELECT $1, $2": [1, "a"]}) == [1, "a"]
assert pick_params("SELECT $1", {}) is None
def test_dump_out_dir_passed_to_dump(monkeypatch, tmp_path):
"""--out: sql_top.json/explain пишутся в отдельный каталог шага (run-all), сетевой лог читается из run_dir."""
seen = {}
async def fake(run_dir, top, out_dir):
seen.update(run_dir=run_dir, top=top, out_dir=out_dir)
return {"explained": 0, "skipped": 0}
monkeypatch.setattr(sqlstats, "_dump", fake)
out = tmp_path / "sql" / "scroll"
assert sqlstats.main(["dump", str(tmp_path), "--top", "7", "--out", str(out)]) == 0
assert seen == {"run_dir": tmp_path, "top": 7, "out_dir": out}
assert out.is_dir()
def test_dump_without_out_writes_to_run_dir(monkeypatch, tmp_path):
seen = {}
async def fake(run_dir, top, out_dir):
seen.update(out_dir=out_dir)
return {"explained": 0, "skipped": 0}
monkeypatch.setattr(sqlstats, "_dump", fake)
assert sqlstats.main(["dump", str(tmp_path)]) == 0
assert seen["out_dir"] == tmp_path