Andrey Epifancev fdc8ecb2a3 perf: замеры бэка
Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN,
aggregate исключает трассировочные повторы (O-9).

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:36:46 +04:00

274 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Сценарии скролла (Task 5).
*
* Параметры (env):
* PERF_SCALE масштаб манифеста (по умолчанию 1)
* PERF_TARGETS glob(ы) через запятую по target, напр. 'FORM_1/*,svod/*' (по умолчанию все);
* сопоставляется с target без суффикса оси и с полным (`.../@y`)
* PERF_RUN_ID каталог прогона perf/results/<run_id>
* PERF_REPEATS число повторов вместо матричного (3 для rf/свод, 1 для admin-контроля)
* PERF_CPU_RATES '1' по умолчанию (без троттлинга, решение человека); '1,4,6' включает троттлинг вручную
* PERF_ROLES фильтр ролей через запятую (rf,admin,dfip)
* PERF_AXES 'y,x' - оси для rf (по умолчанию y,x)
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000)
* PERF_TEST_TIMEOUT_MS / PERF_TEST_TIMEOUT_SVOD2_MS таймаут теста (45 мин / 3 ч для свода 2)
*/
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 { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough, type Axis } from '../lib/scroll';
const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
const MIN_ROWS = 20;
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000);
const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d);
const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number);
const FILTER_CPU = Math.max(...CPU_RATES); // замер смены фильтра: на самом тяжёлом из заданных CPU (по умолчанию 1)
const ROLES = envList('PERF_ROLES', ['rf', 'admin', 'dfip']);
const AXES = envList('PERF_AXES', ['y', 'x']) as Axis[];
const repeats = (dflt: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : dflt);
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const TARGET_RES = envList('PERF_TARGETS', ['*']).map(globRe);
const wanted = (t: string, axis: Axis) => TARGET_RES.some((r) => r.test(t) || r.test(`${t}@${axis}`));
type Kind = 'sheet' | 'project' | 'svod1' | 'svod2' | 'summaries';
interface Target {
name: string; // target без оси
kind: Kind;
url: string; // без hash: приложение его не читает
tabIndex?: number; // вкладка свода (клик по role=tab)
respRe: RegExp; // ответ загрузки данных (для json_bytes_sheet)
rolesAxes: Array<{ role: string; axes: Axis[]; repeats: number }>;
}
const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
function buildTargets(): Target[] {
const m = loadManifest();
const out: Target[] = [];
const hasRole = (r: string) => ROLES.includes(r) && !!m.users[r];
for (const s of m.sheets) {
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 });
}
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 });
}
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'];
svod1.forEach((sh, i) => out.push({ name: `svod/svod1_${sh}`, kind: 'svod1', url: '/svod', tabIndex: i, respRe: new RegExp(`/api/v1/svod/1/${sh}/`), rolesAxes: svodRoles }));
['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: new RegExp(`/api/v1/svod/2/${sh}/`), rolesAxes: svodRoles }));
out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles });
return out;
}
/**
* Ждём >= MIN_ROWS строк тела таблицы; возвращает фактическое число.
* Если строки появились, но их число не растёт SETTLE_MS (на странице реально < MIN_ROWS строк) - бросаем ошибку,
* не дожидаясь LOAD_TIMEOUT (сам по себе он нужен для медленной обработки свода 2, когда строк ещё 0).
*/
const SETTLE_MS = 20_000;
class RowsError extends Error {}
const skipLog: string[] = [];
async function waitRows(page: Page): Promise<number> {
const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
const t0 = Date.now();
let last = -1, lastChange = t0;
for (;;) {
const n = await count();
if (n >= MIN_ROWS) return n;
const now = Date.now();
if (n !== last) { last = n; lastChange = now; }
if (n > 0 && now - lastChange > SETTLE_MS) throw new RowsError(`only ${n} rows`);
if (now - t0 > LOAD_TIMEOUT) throw new RowsError(`timeout, rows=${n}`);
await page.waitForTimeout(500);
}
}
/**
* Стабилизация после действия: armStable() ставит наблюдатели ДО действия (иначе первая мутация теряется),
* awaitStable() ждёт первой мутации контейнера и затем 2 подряд кадра без long task и без мутаций.
*/
async function armStable(page: Page): Promise<void> {
await page.evaluate((sel) => {
const root = (document.querySelector(sel) ?? document.body) as HTMLElement;
const st = { mutated: false, dirty: false, mo: null as MutationObserver | null, po: null as PerformanceObserver | null };
st.mo = new MutationObserver(() => { st.mutated = true; st.dirty = true; });
st.mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true });
st.po = new PerformanceObserver(() => { st.dirty = true; });
try { st.po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ }
(window as any).__stable = st;
}, CONTAINER);
}
async function awaitStable(page: Page, timeoutMs: number): Promise<boolean> {
return page.evaluate(
(tmo) =>
new Promise<boolean>((resolve) => {
const st = (window as any).__stable;
let quiet = 0, done = false;
const finish = (v: boolean) => { if (done) return; done = true; st.mo.disconnect(); st.po.disconnect(); resolve(v); };
setTimeout(() => finish(false), tmo);
const tick = () => {
if (done) return;
if (st.mutated && !st.dirty) quiet++; else quiet = 0;
st.dirty = false;
if (quiet >= 2) return finish(true);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
timeoutMs,
);
}
/** Сигнатура видимого содержимого таблицы + подпись «Найдено N из M» (если есть). */
const signature = (page: Page) =>
page.evaluate((sel) => {
const c = document.querySelector(sel) as HTMLElement | null;
const rows = Array.from(c?.querySelectorAll('tbody tr') ?? []).slice(0, 6).map((r) => r.textContent).join('|');
const found = /Найдено[^\n]*/.exec(document.body.innerText)?.[0] ?? '';
return rows + '#' + found;
}, CONTAINER);
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; filter?: boolean }
async function measure(browser: import('@playwright/test').Browser, o: RunOpts) {
const { t, role, axis, cpu, repeat, filter } = o;
const m = loadManifest();
const user: User = m.users[role];
const targetName = (filter ? `${t.name}/filter` : t.name) + `@${axis}`;
const rec = { scenario: 'scroll', target: targetName, role, scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const artifacts: string[] = [];
const nl = attachNetlog(ctx, { scenario: rec.scenario, target: targetName, role });
try {
await loginAs(ctx, user);
await ctx.addInitScript(PROBE_SCRIPT);
const page = await ctx.newPage();
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
// (response.body() для 30-47 МБ недоступен: "evicted from inspector cache")
let jsonBytes = 0;
const net = await ctx.newCDPSession(page);
await net.send('Network.enable');
const reqUrl = new Map<string, string>();
const reqBytes = new Map<string, number>();
net.on('Network.requestWillBeSent', (e: any) => { reqUrl.set(e.requestId, e.request.url); });
net.on('Network.dataReceived', (e: any) => {
const u = reqUrl.get(e.requestId);
if (u && t.respRe.test(u)) {
const v = (reqBytes.get(e.requestId) ?? 0) + e.dataLength;
reqBytes.set(e.requestId, v);
jsonBytes = Math.max(jsonBytes, v);
}
});
await page.goto(WEB + t.url);
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
if (t.kind === 'svod2') {
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
await page.getByLabel('ССП/РФ').click();
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Загрузить' }).click();
}
let rows: number;
try {
rows = await waitRows(page);
} catch (e) {
if (!(e instanceof RowsError)) throw e;
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
const skip_reason = `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})`;
skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${skip_reason}`);
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason });
return;
}
await page.waitForTimeout(500);
await net.send('Network.disable').catch(() => {});
await throttle(page, cpu);
const trace = repeat === 0 && !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: ProbeMetrics, 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());
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
await input.fill('1');
stable = await awaitStable(page, 120_000);
effect = (await signature(page)) !== sigBefore;
if (!stable) reason = 'filter did not stabilize in 120s';
else if (!effect) reason = 'filter_no_effect';
} else {
const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS);
({ complete, steps } = r);
if (r.reason) reason = r.reason;
else if (!complete) reason = 'scroll_incomplete';
}
probe = await page.evaluate(() => (window as any).__perf.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 },
artifacts,
incomplete: !complete || !stable || !effect,
...(reason ? { skip_reason: reason } : {}),
});
if (reason) skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${reason}`);
} finally {
await nl.flush();
await ctx.close();
}
}
/** Таймаут теста: 45 мин по умолчанию, свод 2 (загрузка до 15 мин + скролл на CPU 6 ~40 мин, если включён вручную) - 3 ч. */
const testTimeout = (t: Target) =>
t.kind === 'svod2'
? Number(process.env.PERF_TEST_TIMEOUT_SVOD2_MS ?? 3 * 3600_000)
: Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000);
test.describe('scroll', () => {
test.afterAll(() => {
if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n'));
});
for (const t of buildTargets()) {
for (const ra of t.rolesAxes) {
for (const axis of ra.axes) {
if (!wanted(t.name, axis)) continue;
for (const cpu of CPU_RATES) {
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 });
});
}
}
// смена фильтра - только своды, один раз на FILTER_CPU
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 });
});
}
}
}
}
});