From 29867535b006132ea1519ea7d2b39925068decb1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:59:49 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20run-all.sh=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- openspec/changes/perf-profiling/design.md | 14 +- perf/README.md | 94 +++++++- perf/e2e/lib/global-teardown.ts | 14 +- perf/e2e/lib/netlog.ts | 35 ++- perf/e2e/lib/probe.ts | 39 +++- perf/e2e/scenarios/scroll-coauthor.spec.ts | 12 +- perf/e2e/scenarios/scroll.spec.ts | 27 ++- perf/e2e/tests/harness.spec.ts | 38 +++- perf/e2e/tests/netlog.spec.ts | 62 ++++++ perf/e2e/tests/scroll-lib.spec.ts | 14 ++ perf/perfkit/aggregate.py | 8 +- perf/perfkit/sqlstats.py | 34 +-- perf/run-all.sh | 242 +++++++++++++++++++++ perf/tests/test_aggregate.py | 12 + perf/tests/test_sqlstats.py | 28 +++ 15 files changed, 626 insertions(+), 47 deletions(-) create mode 100644 perf/e2e/tests/netlog.spec.ts create mode 100755 perf/run-all.sh diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 0f82bff..7562833 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -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/` в `/sql//` (`sqlstats dump --out`); run-all задаёт тег на каждый вызов Playwright (scroll, coauthor, modals, modals_profiling, memory, control4x_*). Параметры EXPLAIN берутся из общего `/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///` (было без года) | Найдено при проверке `--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 `-s` (тег по умолчанию - дата); фатальны только 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:` - модалка, `coauthor:` - скролл при соавторе, остальное - 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//` (коммитится) шаг 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 diff --git a/perf/README.md b/perf/README.md index 014e8bb..334991f 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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.json` (по умолчанию 1). - `PERF_API`, `PERF_WEB` — адреса стенда (`http://localhost:8000`, `http://localhost:8080`). - Агрегация: `cd perf && .venv/bin/python -m perfkit.aggregate results/`. -- Библиотеки `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//`, `project//`, `svod/`; к записи добавляется суффикс оси `@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//` (лист ССП с тем же именем, что у листа РФ, — с суффиксом владельца: `FORM_2/AHR/null/ssp`), `project///`, `svod/`; к записи добавляется суффикс оси `@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//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//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 `///coauthor@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; `@big` - вариант на самом большом листе), `PERF_PRIORITY` (`1`, `2`, `1,2`), `PERF_ROLES`, `PERF_REPEATS`, `PERF_CPU_RATES` (по умолчанию `1`). +- Переменные: `PERF_MODALS` (glob по 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`: пара записей `` (малый лист: AHR_RENT, FORM_4/AHR) и `@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//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//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=` — в `/sql//` (так делает `run-all.sh`: каждый вызов Playwright сбрасывает статистику, без тега следующий вызов перезатёр бы предыдущий); `PERF_SQLSTATS=0` отключает, при недоступном Postgres печатается предупреждение, прогон не падает. ``` cd perf .venv/bin/python -m perfkit.bench results/ --repeats 20 # api.jsonl: scenario api, metrics ms/bytes .venv/bin/python -m perfkit.aggregate results/ # таблицы, в т.ч. scenario api -.venv/bin/python -m perfkit.sqlstats dump results/ # вручную, если нужно перегенерировать +.venv/bin/python -m perfkit.sqlstats dump results/ [--out results//sql/] # вручную ``` - `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/.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 --repeats 20 --scale S` | ~30 мин | ~45 мин | +| aggregate | `perfkit.aggregate`, `modals_inventory`, копирование в `report-data//` | ~1 мин | ~1 мин | + +- run_id масштаба — `-s` (тег по умолчанию — сегодняшняя дата `YYYYMMDD`) или явный `PERF_RUN_ID_S1`/`PERF_RUN_ID_S2`; все сценарии масштаба пишут в один каталог `perf/results//`, SQL-статистика — по шагам в `sql/<шаг>/`. +- Лог `perf/results//run-all.log`: `START`/`END` шага со временем, кодом возврата и длительностью, вывод команд. Статус `run-all.status.json`: по шагу `OK`/`FAIL`/`SKIP`, `rc`, время, команда. Падение шага не останавливает прогон; падение `reset`, `gen` или `stand` пропускает остаток масштаба (следующий масштаб идёт). Код выхода 1, если был FAIL. +- В `perf/report-data//` (коммитится): `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 (агрегатор сложит их с прежними) — для чистого повтора удалить `.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:` — модалка, `coauthor:` — скролл при соавторе, остальное — 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. + diff --git a/perf/e2e/lib/global-teardown.ts b/perf/e2e/lib/global-teardown.ts index cb752d6..cf7e7ab 100644 --- a/perf/e2e/lib/global-teardown.ts +++ b/perf/e2e/lib/global-teardown.ts @@ -1,11 +1,21 @@ -/** Playwright globalTeardown: sql_top.json + explain/*.json в каталог прогона. Только если был сетевой лог (т.е. гонялись сценарии). */ +/** + * Playwright globalTeardown: sql_top.json + explain/*.json. Только если был сетевой лог (т.е. гонялись сценарии). + * PERF_SQLSTATS_TAG= - писать в /sql// (run-all.sh: несколько вызовов Playwright в одном run_id, + * у каждого свой reset/dump pg_stat_statements; без тега каждый вызов перезатирал бы /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 { if (process.env.PERF_SQLSTATS === '0') return; if (!existsSync(netlogFile())) return; - sqlstats(['dump', runDir()]); + sqlstats(dumpArgs()); } diff --git a/perf/e2e/lib/netlog.ts b/perf/e2e/lib/netlog.ts index c09a24e..2600158 100644 --- a/perf/e2e/lib/netlog.ts +++ b/perf/e2e/lib/netlog.ts @@ -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).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 } { @@ -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, diff --git a/perf/e2e/lib/probe.ts b/perf/e2e/lib/probe.ts index 09db64e..a83a50a 100644 --- a/perf/e2e/lib/probe.ts +++ b/perf/e2e/lib/probe.ts @@ -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 }> { + 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 } : {}; +} diff --git a/perf/e2e/scenarios/scroll-coauthor.spec.ts b/perf/e2e/scenarios/scroll-coauthor.spec.ts index 663d7d1..e182553 100644 --- a/perf/e2e/scenarios/scroll-coauthor.spec.ts +++ b/perf/e2e/scenarios/scroll-coauthor.spec.ts @@ -4,7 +4,7 @@ * * Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `//`), 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, diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts index 0e6931e..ecd0f09 100644 --- a/perf/e2e/scenarios/scroll.spec.ts +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -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(); + 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 } : {}), diff --git a/perf/e2e/tests/harness.spec.ts b/perf/e2e/tests/harness.spec.ts index b38d847..2f92960 100644 --- a/perf/e2e/tests/harness.spec.ts +++ b/perf/e2e/tests/harness.spec.ts @@ -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 /sql/', 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; + } +}); diff --git a/perf/e2e/tests/netlog.spec.ts b/perf/e2e/tests/netlog.spec.ts new file mode 100644 index 0000000..707c0d8 --- /dev/null +++ b/perf/e2e/tests/netlog.spec.ts @@ -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/') ? '{}' : '' })); + 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 }); + } +}); diff --git a/perf/e2e/tests/scroll-lib.spec.ts b/perf/e2e/tests/scroll-lib.spec.ts index 4bd5735..3fc29a6 100644 --- a/perf/e2e/tests/scroll-lib.spec.ts +++ b/perf/e2e/tests/scroll-lib.spec.ts @@ -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) - метрики на шаг нет +}); diff --git a/perf/perfkit/aggregate.py b/perf/perfkit/aggregate.py index f97edae..7648867 100644 --- a/perf/perfkit/aggregate.py +++ b/perf/perfkit/aggregate.py @@ -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}) diff --git a/perf/perfkit/sqlstats.py b/perf/perfkit/sqlstats.py index f672d54..6741e63 100644 --- a/perf/perfkit/sqlstats.py +++ b/perf/perfkit/sqlstats.py @@ -2,7 +2,10 @@ CLI (его зовут globalSetup/globalTeardown Playwright): python -m perfkit.sqlstats reset - python -m perfkit.sqlstats dump [--top 15] + python -m perfkit.sqlstats dump [--top 15] [--out ] + --out: каталог для sql_top.json и explain/ (по умолчанию run_dir). Сетевой лог для параметров EXPLAIN читается + всегда из /network.jsonl. Его задаёт globalTeardown по PERF_SQLSTATS_TAG (/sql/): + 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 [--top N]", file=sys.stderr) + print("usage: python -m perfkit.sqlstats reset | dump [--top N] [--out DIR]", file=sys.stderr) return 2 diff --git a/perf/run-all.sh b/perf/run-all.sh new file mode 100755 index 0000000..45e79c1 --- /dev/null +++ b/perf/run-all.sh @@ -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 = -s, каталог perf/results// +# PERF_RUN_ID_S1 / PERF_RUN_ID_S2 явный run_id масштаба (вместо -s) +# 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:` - модалка, `coauthor:` - скролл при +# соавторе, остальное - 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-all.log (START/END шага со временем, кодом возврата и длительностью, вывод команд). +# Статус: perf/results//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() { # "" -> строка команды 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// (коммитится): 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)" +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 diff --git a/perf/tests/test_aggregate.py b/perf/tests/test_aggregate.py index d30bf55..752f586 100644 --- a/perf/tests/test_aggregate.py +++ b/perf/tests/test_aggregate.py @@ -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"} diff --git a/perf/tests/test_sqlstats.py b/perf/tests/test_sqlstats.py index 073d68c..63c8477 100644 --- a/perf/tests/test_sqlstats.py +++ b/perf/tests/test_sqlstats.py @@ -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