perf: исправления run-all по ревью (раунд 1)

- C1: trace только на повторе 0 при числе повторов > 1 (lib/trace.ts
  shouldTrace) в scroll и scroll-coauthor: admin-контроль, control4x и
  PERF_S2_REPEATS=1 больше не теряют единственную запись (traced=1).
- I1: масштаб 2 - PERF_LOAD_TIMEOUT_MS=40 мин и PERF_TEST_TIMEOUT_MS=90 мин,
  если не заданы.
- I2: шаг aggregate падает при сбое perfkit.aggregate/копирования и если
  summary.json не создан; тесты run-all (perf/tests/test_run_all.py).
- M2: после modals_profiling останавливается preview :8081.
- README, design.md: решения раунда.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 03:12:40 +04:00
parent ab3d9e89a3
commit 05d1586006
8 changed files with 152 additions and 27 deletions

View File

@ -177,7 +177,7 @@
| 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 | Trace в scroll и scroll-coauthor снимается только на повторе 0 и только если повторов > 1 (`lib/trace.ts` `shouldTrace`); `PERF_TRACE=0` отключает trace совсем (run-all дополнительно ставит его при 1 повторе). Modals и memory trace не снимают | aggregate исключает `traced=1` (O-9): при единственном повторе (admin-контроль - 61 тест на масштабе 1, control4x, `PERF_S2_REPEATS=1`) группа осталась бы без метрик (ревью T10a, C1) | Нет trace-файлов admin-контроля и одноповторных прогонов; для разбора трасс есть rf/своды с 3 повторами |
| 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 для модалок админки |
@ -186,6 +186,9 @@
| 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 - свой манифест | Нет (логины одинаковые) |
| 2026-10-26 | R-4: на масштабе 2 повторы сокращены до 1 (`PERF_S2_REPEATS=1`), масштаб 1 — 3 повтора | Оценка прогона масштаба 2 с 3 повторами ≈ 13 ч, с 1 повтором ≈ 6,3 ч; правило человека: при > ~6 ч сокращать повторы только на масштабе 2 | Масштаб 2 без разброса и p95: годится для оценки роста с объёмом, не для абсолютных цифр |
| 2026-10-26 | Масштаб 2: run-all передаёт сценариям `PERF_LOAD_TIMEOUT_MS=2400000` (40 мин) и `PERF_TEST_TIMEOUT_MS=5400000` (90 мин), если пользователь не задал свои; свод 2 по-прежнему `PERF_TEST_TIMEOUT_SVOD2_MS` 3 ч | Ответ свода 2 на масштабе 2 ~150-190 МБ (x4 от 31-47 МБ): 15 мин ожидания дали бы `incomplete`; 45 мин на тест меньше 40 мин загрузки + скролл (ревью T10a, I1) | Зависший замер обнаружится позже (до 90 мин) |
| 2026-10-26 | Шаг aggregate строгий: цепочка `&&`/`|| exit 1`, перед запуском удаляется прежний `report-data/<run_id>/summary.json`, в конце `test -f summary.json`; `PERF_PY` подменяет python perfkit (только для тестов run-all, `perf/tests/test_run_all.py`), статус пишется всегда venv-питоном | Иначе rc шага определял `ls -R`, и падение `perfkit.aggregate` при возобновлении (каталог report-data уже есть) маскировалось (ревью T10a, I2) | FAIL шага при сбое копирования необязательного файла |
| 2026-10-26 | После шага modals_profiling run-all останавливает preview профилирующей сборки на :8081 (по `perf/.run/web-profiling.pid`), :8080 и :8000 не трогает; код возврата - Playwright | Профилирующий preview не нужен остальным шагам и держит память/CPU во время memory и bench (ревью T10a, M2) | Ручной запуск профилирования после run-all требует снова `stand.sh web-profiling` |
## Decisions made with the human

View File

@ -65,9 +65,9 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1
- Матрица (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).
- Переменные: `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 вообще).
- Каждый замер - новый browser context; CPU-троттлинг включается после загрузки данных (до скролла). Перед замером ждём >= 20 строк `tbody tr`; если не дождались - запись `incomplete: true` с `skip_reason`.
- Метрики: `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 не попадают).
- Метрики: `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 исключает его из статистики. Поэтому trace снимается только если повторов больше одного (`lib/trace.ts` `shouldTrace`): у admin-контроля, control4x и при `PERF_REPEATS=1` trace нет, а единственная запись идёт в статистику. Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.
### Скролл при соавторе
@ -77,7 +77,7 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1
```
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
PERF_RUN_ID=co PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=5 PERF_REPEATS=1 npx playwright test scenarios/scroll-coauthor.spec.ts
cd .. && .venv/bin/python -m pytest -m stand tests/test_coauthor.py # самопроверка бота (нужен стенд)
```
@ -165,7 +165,7 @@ nohup perf/run-all.sh > /tmp/run-all.out 2>&1 & # боевой прогон (м
| 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 мин | — |
| modals_profiling | `stand.sh web-profiling` + профилирующий прогон p1 на :8081; затем preview :8081 останавливается (:8080 и :8000 не трогаются) | ~3 мин | — |
| memory | `memory.spec.ts`, 20 циклов | ~60 мин | — |
| memtrend | `perfkit.memtrend` -> `memtrend.txt` | секунды | — |
| control4x | только по запросу (см. ниже) | ~5 мин на цель | |
@ -173,7 +173,7 @@ nohup perf/run-all.sh > /tmp/run-all.out 2>&1 & # боевой прогон (м
| 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/results/<run_id>/run-all.log`: `START`/`END` шага со временем, кодом возврата и длительностью, вывод команд. Статус `run-all.status.json`: по шагу `OK`/`FAIL`/`SKIP`, `rc`, время, команда. Падение шага не останавливает прогон; падение `reset`, `gen` или `stand` пропускает остаток масштаба (следующий масштаб идёт). Код выхода 1, если был FAIL. Шаг aggregate падает при любом сбое копирования и если `summary.json` не создан заново.
- В `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` передаются как есть.
@ -186,7 +186,7 @@ PERF_SCALES=2 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=scroll,grow,mod
- `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`).
- Масштаб 2: сценарии получают `PERF_LOAD_TIMEOUT_MS=2400000` (40 мин на загрузку, свод 2 ~150-190 МБ) и `PERF_TEST_TIMEOUT_MS=5400000` (90 мин), если они не заданы в окружении. `PERF_S2_REPEATS` (по умолчанию 3, как на масштабе 1; при 1 trace не снимается) и `PERF_S2_TARGETS` (glob целей скролла) — только для скролла масштаба 2. Оценка: 3 повтора ~13 ч, 2 — ~9,6 ч, 1 — ~6,3 ч; основная часть — свод 2 (строк ~в 4 раза больше, ответ ~150-190 МБ, при загрузке дольше 40 мин — `incomplete`).
Контроль на CPU 4x (решение человека: только для топ-3–4 находок, 1 повтор, цифры в отчёте помечаются как контрольные):
@ -195,6 +195,6 @@ PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=control4x,aggre
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_*`.
- `PERF_CONTROL_TARGETS`: `modal:<id>` — модалка, `coauthor:<glob>` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор (без trace); записи идут в тот же run_id (группы с `cpu_rate=4`), SQL — в `sql/control4x_*`.
- Контроль выполняется после `grow`: для FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP цифры 4x несопоставимы со скроллом 1x.

11
perf/e2e/lib/trace.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Трассировать ли замер (scroll, scroll-coauthor). Trace снимается только на повторе 0 и только если повторов > 1:
* трассированная запись помечается traced=1 и aggregate её исключает (O-9), поэтому при единственном повторе
* (admin-контроль, control4x, PERF_S2_REPEATS=1) группа осталась бы без данных. Смена фильтра свода не трассируется.
* PERF_TRACE=0 отключает trace полностью.
*/
export interface TraceOpts { repeat: number; repeats: number; filter?: boolean }
export function shouldTrace(o: TraceOpts, env: Record<string, string | undefined> = process.env): boolean {
return o.repeat === 0 && o.repeats > 1 && !o.filter && env.PERF_TRACE !== '0';
}

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_TRACE=0 (без trace на повторе 0).
* PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python), PERF_TRACE=0 (без trace; по умолчанию trace на повторе 0, если повторов > 1).
* Цели: самый большой лист каждой формы из доступных роли rf (owner=rf).
* Запись не пишется (тест падает), если бот не получил эха своих правок или страница не увидела ни одного cell_updated.
*/
@ -18,6 +18,7 @@ import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough } from '../lib/scroll';
import { shouldTrace } from '../lib/trace';
const CONTAINER = '.MuiTableContainer-root';
const MIN_ROWS = 20;
@ -113,7 +114,7 @@ async function measure(browser: import('@playwright/test').Browser, s: Sheet, cp
await throttle(page, cpu);
bot = await startBot(s, rate);
const trace = repeat === 0 && process.env.PERF_TRACE !== '0'; // PERF_TRACE=0 - без трейса (1 повтор)
const trace = shouldTrace({ repeat, repeats: REPEATS }); // повтор 0 при REPEATS > 1; PERF_TRACE=0 - никогда
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
const pr = await startProbe(page);

View File

@ -13,7 +13,8 @@
* 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 исключает)
* PERF_TRACE=0 не снимать trace вообще (по умолчанию - на повторе 0, если повторов > 1: запись traced=1 aggregate
* исключает, поэтому при 1 повторе, как у admin-контроля, trace не снимается)
*
* Основная метрика: main_thread_ms (CDP TaskDuration за скролл) и main_thread_ms_per_step (на шаг колеса 200 px);
* для смены фильтра - main_thread_ms на действие (шагов нет).
@ -27,6 +28,7 @@ import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough, type Axis } from '../lib/scroll';
import { shouldTrace } from '../lib/trace';
const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
const MIN_ROWS = 20;
@ -156,10 +158,10 @@ const signature = (page: Page) =>
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; filter?: boolean }
interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; repeats: number; filter?: boolean }
async function measure(browser: import('@playwright/test').Browser, o: RunOpts) {
const { t, role, axis, cpu, repeat, filter } = o;
const { t, role, axis, cpu, repeat, repeats: nRepeats, filter } = o;
const m = loadManifest();
const user: User = m.users[role];
const targetName = (filter ? `${t.name}/filter` : t.name) + `@${axis}`;
@ -211,8 +213,8 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
await net.send('Network.disable').catch(() => {});
await throttle(page, cpu);
// PERF_TRACE=0 - без трейса (1 повтор: control4x, PERF_S2_REPEATS=1), иначе единственная запись была бы traced=1
const trace = repeat === 0 && !filter && process.env.PERF_TRACE !== '0';
// trace только на повторе 0 при повторах > 1 (lib/trace.ts): при одном повторе запись traced=1 была бы единственной
const trace = shouldTrace({ repeat, repeats: nRepeats, filter });
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-${role}-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
let complete = true, steps = 0, probe: ProbeResult, stable = true, reason: string | undefined, effect = true;
@ -267,7 +269,7 @@ test.describe('scroll', () => {
for (let repeat = 0; repeat < ra.repeats; repeat++) {
test(`${t.name} ${ra.role} ${axis} cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(testTimeout(t));
await measure(browser, { t, role: ra.role, axis, cpu, repeat });
await measure(browser, { t, role: ra.role, axis, cpu, repeat, repeats: ra.repeats });
});
}
}
@ -275,7 +277,7 @@ test.describe('scroll', () => {
if (t.kind !== 'sheet' && t.kind !== 'project') {
test(`${t.name} ${ra.role} filter cpu${FILTER_CPU}`, async ({ browser }) => {
test.setTimeout(testTimeout(t));
await measure(browser, { t, role: ra.role, axis, cpu: FILTER_CPU, repeat: 0, filter: true });
await measure(browser, { t, role: ra.role, axis, cpu: FILTER_CPU, repeat: 0, repeats: 1, filter: true });
});
}
}

View File

@ -6,6 +6,7 @@ import path from 'node:path';
import { PROBE_SCRIPT, startProbe } from '../lib/probe';
import { throttle } from '../lib/cdp';
import { append } from '../lib/results';
import { shouldTrace } from '../lib/trace';
const BUSY = 'file://' + path.resolve(__dirname, '../fixtures/busy.html');
const PERF_DIR = path.resolve(__dirname, '../..');
@ -102,3 +103,12 @@ test('globalTeardown: PERF_SQLSTATS_TAG -> dump --out <run_dir>/sql/<tag>', asyn
delete process.env.PERF_RESULTS_DIR;
}
});
test('shouldTrace: trace только на повторе 0 и только если повторов > 1 (иначе aggregate исключил бы единственную запись)', () => {
expect(shouldTrace({ repeat: 0, repeats: 3 }, {})).toBe(true);
expect(shouldTrace({ repeat: 1, repeats: 3 }, {})).toBe(false);
expect(shouldTrace({ repeat: 0, repeats: 1 }, {})).toBe(false); // admin-контроль, control4x, PERF_S2_REPEATS=1
expect(shouldTrace({ repeat: 0, repeats: 2 }, {})).toBe(true);
expect(shouldTrace({ repeat: 0, repeats: 3, filter: true }, {})).toBe(false); // смена фильтра не трассируется
expect(shouldTrace({ repeat: 0, repeats: 3 }, { PERF_TRACE: '0' })).toBe(false);
});

View File

@ -32,7 +32,8 @@ set -uo pipefail
PERF="$(cd "$(dirname "$0")" && pwd)"
E2E="$PERF/e2e"
PY="$PERF/.venv/bin/python"
PY="${PERF_PY:-$PERF/.venv/bin/python}" # python для perfkit (подмена - только тесты run-all)
STATUS_PY="$PERF/.venv/bin/python"
WEB_URL="http://localhost:8080"
PROF_URL="http://localhost:8081"
API_URL="${PERF_API:-http://localhost:8000}"
@ -91,7 +92,10 @@ 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"
# масштаб 2: свод 2 ~150-190 МБ - ожидание загрузки 40 мин и таймаут замера 90 мин, если не заданы пользователем
local s2=""
[ "$SCALE" = 2 ] && s2="PERF_LOAD_TIMEOUT_MS=${PERF_LOAD_TIMEOUT_MS:-2400000} PERF_TEST_TIMEOUT_MS=${PERF_TEST_TIMEOUT_MS:-5400000} "
echo "cd '$E2E' && $CLEAN_ENV PERF_SCALE=$SCALE PERF_RUN_ID=$RID PERF_WEB=${3:-$WEB_URL} $s2$1 npx playwright test $2"
}
# Команда шага (строка для eval). Пустая строка - шаг не применим (SKIP).
@ -126,7 +130,10 @@ step_cmd() {
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")" ;;
# после прогона профилирующий preview на :8081 останавливается (prod :8080 и api :8000 не трогаются)
local pid="$PERF/.run/web-profiling.pid"
echo "rc=0; '$PERF/stand.sh' web-profiling && ( $(pw "PERF_SQLSTATS_TAG=modals_profiling PERF_PROFILING=1 PERF_PRIORITY=1" scenarios/modals.spec.ts "$PROF_URL") ) || rc=\$?;" \
"if [ -f '$pid' ]; then pkill -P \$(cat '$pid') 2>/dev/null; kill \$(cat '$pid') 2>/dev/null; rm -f '$pid'; fi; exit \$rc" ;;
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 ;;
@ -135,13 +142,16 @@ step_cmd() {
# в 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'" \
# Любой сбой - FAIL шага: цепочка через && и || exit 1, в конце проверка, что summary.json создан этим шагом
# (каталог report-data при возобновлении может остаться от прошлого прогона, поэтому старый summary.json удаляется).
echo "cd '$PERF' && rm -f '$out/summary.json' && '$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'" ;;
"&& mkdir -p '$out' && for d in results/$RID/sql/*/; do if [ -d \"\$d\" ]; then t=\$(basename \"\$d\");" \
"mkdir -p '$out/sql/'\$t || exit 1; if [ -f \"\$d\"sql_top.json ]; then cp \"\$d\"sql_top.json '$out/sql/'\$t/ || exit 1; fi;" \
"if [ -d \"\$d\"explain ]; then cp -R \"\$d\"explain '$out/sql/'\$t/ || exit 1; fi; fi; 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 if [ -f \"\$f\" ]; then cp \"\$f\" '$out/' || exit 1; fi; done" \
"&& ls -R '$out' && test -f '$out/summary.json'" ;;
esac
}
@ -169,7 +179,7 @@ control_cmd() {
}
status_update() { # step status rc start end cmd
"$PY" - "$STATUS" "$RID" "$SCALE" "$@" <<'EOF'
"$STATUS_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)

View File

@ -0,0 +1,88 @@
"""run-all.sh: сухой режим и шаг aggregate (стенд не нужен: aggregate работает только с файлами)."""
import json
import os
import shutil
import subprocess
import uuid
import pytest
from perfkit import PERF_DIR, REPORT_DATA_DIR, RESULTS_DIR
SCRIPT = PERF_DIR / "run-all.sh"
def _run(**env):
e = {k: v for k, v in os.environ.items() if not k.startswith("PERF_")}
e.update(env)
return subprocess.run(["bash", str(SCRIPT)], env=e, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
def _step_line(out: str, scale: int, step: str) -> str:
block = out.split(f"=== масштаб {scale},", 1)[1].split("ИТОГО масштаб", 1)[0]
return next(l for l in block.splitlines() if l.strip().startswith(step + " "))
def test_dry_run_plan_and_s2_load_timeout():
p = _run(PERF_DRY_RUN="1", PERF_RUN_TAG="t")
assert p.returncode == 0, p.stderr
assert "ИТОГО масштаб 1" in p.stdout and "ИТОГО масштаб 2" in p.stdout
# свод 2 на масштабе 2 (~150-190 МБ): ожидание загрузки 40 мин, если не задано пользователем
assert "PERF_LOAD_TIMEOUT_MS=2400000" in _step_line(p.stdout, 2, "scroll")
assert "PERF_LOAD_TIMEOUT_MS" not in _step_line(p.stdout, 1, "scroll")
p2 = _run(PERF_DRY_RUN="1", PERF_RUN_TAG="t", PERF_SCALES="2", PERF_LOAD_TIMEOUT_MS="123")
assert "PERF_LOAD_TIMEOUT_MS=123" in _step_line(p2.stdout, 2, "scroll")
def test_dry_run_modals_profiling_stops_web_profiling():
p = _run(PERF_DRY_RUN="1", PERF_RUN_TAG="t", PERF_SCALES="1", PERF_STEPS="modals_profiling")
line = _step_line(p.stdout, 1, "modals_profiling")
assert "web-profiling.pid" in line and "kill" in line
@pytest.fixture
def run_id():
rid = f"pytest-runall-{uuid.uuid4().hex[:8]}"
yield rid
shutil.rmtree(RESULTS_DIR / rid, ignore_errors=True)
shutil.rmtree(REPORT_DATA_DIR / rid, ignore_errors=True)
def _write(rid: str, text: str):
d = RESULTS_DIR / rid
d.mkdir(parents=True)
(d / "scroll.jsonl").write_text(text, encoding="utf-8")
def _status(rid: str) -> dict:
return json.loads((RESULTS_DIR / rid / "run-all.status.json").read_text(encoding="utf-8"))["steps"]
def test_aggregate_step_fails_when_aggregate_fails(run_id):
_write(run_id, "не json\n")
(REPORT_DATA_DIR / run_id).mkdir(parents=True) # возобновление: каталог report-data уже есть от прошлого aggregate
p = _run(PERF_SCALES="1", PERF_SKIP_RESET="1", PERF_STEPS="aggregate", PERF_RUN_ID_S1=run_id)
assert p.returncode == 1, p.stdout
assert _status(run_id)["aggregate"]["status"] == "FAIL"
def test_aggregate_step_ok_and_summary_in_report_data(run_id):
rec = {"run_id": run_id, "scenario": "scroll", "target": "x@y", "role": "rf", "scale": 1, "cpu_rate": 1, "repeat": 1,
"metrics": {"main_thread_ms": 10.0}, "artifacts": [], "incomplete": False, "ts": "x"}
_write(run_id, json.dumps(rec) + "\n")
p = _run(PERF_SCALES="1", PERF_SKIP_RESET="1", PERF_STEPS="aggregate", PERF_RUN_ID_S1=run_id)
assert p.returncode == 0, p.stdout
assert _status(run_id)["aggregate"]["status"] == "OK"
assert (REPORT_DATA_DIR / run_id / "summary.json").exists()
def test_aggregate_step_fails_without_summary(run_id, monkeypatch):
"""Если aggregate не создал summary.json (например, отработал «пусто» не туда), шаг - FAIL."""
_write(run_id, "")
(REPORT_DATA_DIR / run_id).mkdir(parents=True)
fake = RESULTS_DIR / run_id / "fakepy"
fake.write_text("#!/bin/sh\nexit 0\n")
fake.chmod(0o755)
p = _run(PERF_SCALES="1", PERF_SKIP_RESET="1", PERF_STEPS="aggregate", PERF_RUN_ID_S1=run_id, PERF_PY=str(fake))
assert p.returncode == 1, p.stdout
assert _status(run_id)["aggregate"]["status"] == "FAIL"