perf: правки финального ревью фазы 3 (I-4, I-5, M-11, M-13, M-14, T15.2, T17.1, T17.3)

Селектор строк данных свода 2 исключает скелетоны MRT, settle ждёт 0 скелетонов;
first_rows_ms через waitForFunction(raf); счётчики загрузки не пишутся в запись фильтра;
scale_compare различает view/search; api_split не считает строки для facets;
README: порядок до/после и новые метрики.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-29 00:46:05 +04:00
parent 558ad815e0
commit 9272ea2527
8 changed files with 68 additions and 22 deletions

View File

@ -277,9 +277,12 @@ cd ..
1. `perf/stand.sh reset`, `perfkit.gen.run --scale 1`, `perfkit.gen.verify --scale 1`. Своды мерить до `grow`.
2. Скопировать `results/manifest-s1.json` в оба worktree («до» из AURORA-1842, «после» из ветки PR 3).
3. «До»: поднять `api`, `web-build`, `web` в worktree «до», снять замеры (команды ниже).
Код сценариев один (из ветки PR 3): в временном worktree «до» (вершина 1842) выполнить
`git checkout feat/236-aurora-1843-svod2-server-paging-perf -- perf/` без коммита; иначе «до» снимется старым `scroll.spec.ts` без новых метрик.
3. «До»: `stand.sh reset/gen/verify/api/web-build/web/down` для «до» запускать из worktree «до»: БД остаётся на старой миграции
(иначе 0053 уронит 6-аргументные `v_svod2_go/rf`, и API «до» пойдёт в новые функции). Поднять `api`, `web-build`, `web`, снять замеры (команды ниже).
4. Остановить сервисы.
5. «После»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры.
5. «После»: `stand.sh migrate` и все сервисы «после» запускать из worktree «после»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры.
6. То же на масштабе 2 (`PERF_S2_REPEATS=1`, см. «Полный прогон»).
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836: на стенде не должно быть других изменений кода.
@ -295,6 +298,17 @@ node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \
Код выхода 1 или `CHECK_FAILED` означает расхождение с экраном «до»: замер «после» не снимать, сначала чинить код.
**Новые метрики** (в записях `scroll`, цели `svod/svod2_*`; на сборке «до» часть недоступна):
- `first_rows_ms` - от «Загрузить» до первых 20 строк с данными (без заглушек и скелетонов), опрос каждый кадр;
- `json_bytes_sheet` - прежний смысл: максимум одного ответа; `json_bytes_total` - сумма всех ответов; `json_bytes_first` - сумма до первых строк;
- `pages_requested` - число запросов страниц (`offset=`); на «до» всегда 0 (экран грузит всё одним запросом) - как число страниц с «после» не сравнивать;
- `placeholder_frame_share` - доля кадров прокрутки, где видна строка-заглушка;
- `filter_result_ms` - от ввода в поиск до стабилизации (и ответа сервера, если он был); пишется только в записи фильтра, там же нет `json_bytes_total/first` и `pages_requested` (они включили бы исходную загрузку).
Сценарий `svod2-memory`: записи `scenario=svod2-memory`, `target=svod2/<point>` (`go`, `rf`, `rf_scrolled` при `PERF_SVOD2_MEM_SCROLL=1`, `final`),
метрики `heap_used_mb`, `nodes`, `documents`, `js_event_listeners`, `rows_visible`.
**Команды замера** (из `perf/e2e`, затем `perfkit.aggregate`; `<before|after>` и `<N>` подставить по этапу и масштабу):
| что | команда | время на сторону (оценка) |
@ -313,6 +327,6 @@ node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \
- `--only REGEX` — регулярное выражение по строке `METHOD url` (например `'GET /api/v1/svod/2/'`, или `'svod/2/RF.*facets'` для вариантов фильтров);
- `--repeats N` — число повторов после прогрева (по умолчанию 20; для быстрой проверки достаточно 3–5, для замера цифр в PR — 10–20).
**Сравнение масштабов и разбор.** `scale_compare.py` выводит строки свода 2 тремя ключами: `GET свод 2 <лист>` (полный ответ), `GET свод 2 <лист> страница` (`limit=` в query), `GET свод 2 <лист> facets` (варианты фильтров).
**Сравнение масштабов и разбор.** `scale_compare.py` выводит строки свода 2 ключами: `GET свод 2 <лист>` (полный ответ), `... страница` (`limit=` в query), `... facets` (варианты фильтров); с `view=` добавляется суффикс `compact`/`full`, с `search=` - `поиск`.
**Обновить цифры в PR** (`feat/236-aurora-1843-svod2-server-paging-perf`): PR открываются без цифр; цифры дописываются по готовности из `report-data/<run_id>/analysis/`.

View File

@ -76,12 +76,13 @@ def main(argv=None):
dt = (time.perf_counter() - t0) * 1000
r.raise_for_status()
if i == 0:
rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow)
res_j = r.json().get("result")
rows = len(res_j) if isinstance(res_j, list) else None # строк ответа: только для списка (facets - dict)
continue # прогрев
tot.append(dt)
db.append(float(r.headers.get("x-db-time-ms", "nan")))
size = len(body)
row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
row = {"role": role, "request": (f"{label} ({rows} строк ответа)" if rows is not None else f"{label} (—)"), "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
"db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")}
row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1)
row["python_share"] = round(row["python_ms"] / row["total_ms"], 2)

View File

@ -16,6 +16,7 @@ import re
import statistics
from collections import defaultdict
from pathlib import Path
from urllib.parse import parse_qs
PERF = Path(__file__).resolve().parents[1]
@ -31,13 +32,21 @@ def med(g, k):
def svod2_key(path: str) -> str:
"""Ключ свода 2 в таблице API: полный ответ, страница (limit в query) или варианты фильтров (/facets)."""
"""Ключ свода 2 в таблице API: полный ответ, страница (limit в query; compact/full по view=), поиск (search=) или /facets."""
m = re.match(r"/api/v1/svod/2/(\w+)/", path)
base = "GET свод 2 " + m.group(1)
if "/facets" in path:
route, _, query = path.partition("?")
q = parse_qs(query, keep_blank_values=True)
if route.endswith("/facets"):
return base + " facets"
if "limit=" in path:
return base + " страница"
if "limit" in q:
base += " страница"
if "view" in q:
base += " " + q["view"][0]
elif "view" in q:
base += " " + q["view"][0]
if "search" in q:
base += " поиск"
return base

View File

@ -1,6 +1,7 @@
/** Чистые помощники замеров свода 2 (без импортов, стираемый TS: проверяются `node --test`). */
export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])';
/** Строка данных: не заглушка окна и без скелетонов MRT (isLoading рисует .MuiSkeleton-root в ячейках даже при непустых данных). */
export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))';
export const SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]';
/** Ответы свода 2: страница `/svod/2/<sheet>/...` и `/svod/2/<sheet>/<year>/facets`. */

View File

@ -204,8 +204,19 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
let rows: number;
let firstRowsMs = 0;
try {
rows = await waitRows(page, rowSel);
if (t.kind === 'svod2') { firstRowsMs = Date.now() - tLoad; bytes.markFirstRows(); }
if (t.kind === 'svod2') {
// опрос каждый кадр (raf), а не раз в 500 мс: иначе first_rows_ms квантуется шагом опроса
await page.waitForFunction(
([sel, rs, min]) => document.querySelectorAll(`${sel} ${rs}`).length >= (min as number),
[CONTAINER, rowSel, MIN_ROWS] as const,
{ polling: 'raf', timeout: LOAD_TIMEOUT },
);
firstRowsMs = Date.now() - tLoad;
bytes.markFirstRows();
rows = await waitRows(page, rowSel);
} else {
rows = await waitRows(page, rowSel);
}
} catch (e) {
if (!(e instanceof RowsError)) throw e;
const have = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]).catch(() => -1);
@ -275,10 +286,15 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
const svod2Metrics = t.kind === 'svod2'
? {
first_rows_ms: firstRowsMs,
json_bytes_total: bytes.total,
json_bytes_first: bytes.first,
pages_requested: countPagesRequested(pageUrls),
...(filter ? { filter_result_ms: filterResultMs ?? 0 } : { placeholder_frame_share: placeholderShare(placeholderSamples) }),
// в записи фильтра счётчики включали бы исходную загрузку (до fill) - пишем их только в записи скролла
...(filter
? { filter_result_ms: filterResultMs ?? 0 }
: {
json_bytes_total: bytes.total,
json_bytes_first: bytes.first,
pages_requested: countPagesRequested(pageUrls),
placeholder_frame_share: placeholderShare(placeholderSamples),
}),
}
: {};
append({

View File

@ -1,7 +1,7 @@
/**
* Сценарий памяти свода 2 (Task 16): роль dfip, свежий контекст на каждый повтор, одна SPA-сессия внутри повтора.
* Точки (lib/svod2.ts memoryPoints): go (вкладка GO, «Все ССП/РФ», «Загрузить») -> rf (вкладка RF, то же)
* [-> rf_scrolled при PERF_SVOD2_MEM_SCROLL=1] -> final (возврат на GO, «Загрузить»).
* [-> rf_scrolled при PERF_SVOD2_MEM_SCROLL=1] -> final (возврат на вкладку GO; «Загрузить» не нажимается: выбор и данные хранятся по вкладкам).
* В каждой точке gc -> heapUsed, domCounters. Запись: scenario "svod2-memory", target "svod2/<point>".
* Env: PERF_SCALE, PERF_RUN_ID, PERF_REPEATS (3), PERF_SVOD2_MEM_SCROLL, PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS,
* PERF_TEST_TIMEOUT_MS.
@ -30,19 +30,19 @@ async function waitRows(page: Page, what: string): Promise<number> {
}
}
/** Ждём окончания загрузки: число строк и число заглушек не меняются 3 опроса подряд (интервал 300 мс). */
/** Ждём окончания загрузки: число строк, заглушек и скелетонов не меняются 3 опроса подряд (интервал 300 мс). */
async function settle(page: Page): Promise<number> {
let prev = '';
let same = 0;
const t0 = Date.now();
for (;;) {
const cur = await page.evaluate(
([sel, rs, ph]) => `${document.querySelectorAll(`${sel} ${rs}`).length}/${document.querySelectorAll(`${sel} ${ph}`).length}`,
([sel, rs, ph]) => `${document.querySelectorAll(`${sel} ${rs}`).length}/${document.querySelectorAll(`${sel} ${ph}`).length}/${document.querySelectorAll(`${sel} .MuiSkeleton-root`).length}`,
[CONTAINER, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR],
);
same = cur === prev ? same + 1 : 0;
prev = cur;
if (same >= 3) return Number(cur.split('/')[0]);
if (same >= 3 && cur.endsWith('/0')) return Number(cur.split('/')[0]); // готово: строки стабильны и в контейнере 0 скелетонов
if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`settle: строки не стабилизировались за ${LOAD_TIMEOUT} мс (${cur})`);
await page.waitForTimeout(300);
}

View File

@ -61,7 +61,7 @@ test('countPagesRequested', () => {
});
test('селекторы', () => {
assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder])');
assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))');
assert.equal(SVOD2_PLACEHOLDER_SELECTOR, 'tbody tr[data-svod2-placeholder]');
});

View File

@ -10,7 +10,12 @@ import scale_compare # noqa: E402
def test_svod2_key():
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026") == "GET свод 2 GO"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница compact"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=full&offset=0&limit=300") == "GET свод 2 RF страница full"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?offset=0&limit=300") == "GET свод 2 RF страница"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&limit=300&search=1") == "GET свод 2 RF страница compact поиск"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?view=full") == "GET свод 2 GO full"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?search=1") == "GET свод 2 GO поиск"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets"