perf: замеры бэка

Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN,
aggregate исключает трассировочные повторы (O-9).

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:36:46 +04:00
parent 4a539d803f
commit fdc8ecb2a3
17 changed files with 675 additions and 5 deletions

View File

@ -165,7 +165,13 @@
| 2026-10-26 | Сценарий памяти: вместо «самого большого листа FORM_1» берётся самый большой лист FORM_2 с owner=rf (AHR, 1154 строки; на нём 3 правки за цикл), вместо FORM_2 - самый большой лист FORM_4 с owner=rf (STRUCTURE, 775 строк; только открытие и скролл, правок нет). Переопределение: `PERF_MEM_SHEET1/2` | У роли rf нет листов FORM_1 (они ССП, 404/нет прав) | Нагрузка цикла отличается от формулировки брифа; пара листов меняется одной переменной |
| 2026-10-26 | Правка ячейки в сценарии памяти - двойной клик (material-react-table, editDisplayMode cell), числа 1000+cycle*10+i, Enter; успех = эхо cell_updated is_self без error + закрытие редактора. Скролл `scrollThrough` идёт вниз и обратно | Так редактор открывается в приложении; эхо подтверждает прохождение через фронтовый WS | Значения ячеек rf на листах меняются (стенд тестовый) |
| 2026-10-26 | Навигация между страницами - history.pushState + popstate, перед выборкой проверяется маркер `window.__sessionMark`; перед каждым циклом access-токен перевыпускается через API, если до истечения (60 мин, ACCESS_TOKEN_EXPIRE_MINUTES) меньше 10 мин | Одна SPA-сессия; полный сценарий дольше жизни токена (минор T4.1: init-script не перезаписывает токен без перезагрузок) | Без перелогина правки после 60-й минуты падали бы по токену |
| 2026-10-26 | Сетевой лог подключается не расширением `test` и не правкой `results.ts`, а функцией `attachNetlog(ctx, meta)` в `perf/e2e/lib/netlog.ts` (одна строка + `flush()` перед `ctx.close()` в четырёх сценариях) | Контексты создаются в `measure()` каждого сценария, общей фикстуры `context` нет; фикстуры потребовали бы переписать сценарии | Забытый вызов в новом сценарии - нет лога для bench; ловится по отсутствию `network.jsonl` |
| 2026-10-26 | В лог попадают запросы и к `PERF_WEB/api/` (proxy), и к `PERF_API/api/`; `body_size` - сжатый (`request.sizes()`), `bench.bytes` - после распаковки | Фронт на стенде ходит в API через proxy своего origin; `body()` недоступен для 30-47 МБ | Размеры в логе и в bench различаются в ~19 раз (gzip) - оговорено в README |
| 2026-10-26 | PATCH ячеек в bench повторяется только при `idempotent: true` в строке лога; сейчас никто это поле не ставит, PATCH не повторяются | Правки в сценариях идут по WebSocket, а по HTTP-телу нельзя доказать, что значение совпадает с текущим | Бэк-метрики правок через HTTP не снимаются; WS-правки покрыты сценарием соавтора |
| 2026-10-26 | Запросы, длившиеся в логе > 60 с (`--max-logged-s`), bench пропускает | Свод 2 (31-47 МБ, минуты) x 20 повторов заняли бы часы; единичный замер уже есть в логе | Нет 20-кратной статистики для свода 2 |
| 2026-10-26 | Подстановка параметров EXPLAIN: для `v3.v_form_view`/`v3.v_summary_view` - из сетевого лога по правилам репозитория (`режим analyze`), для остальных - generic plan без ANALYZE; EXPLAIN только для top-level SELECT, в транзакции с ROLLBACK | pg_stat_statements не хранит значения параметров; вложенные запросы функций ссылаются на переменные plpgsql | Для ORM-запросов нет ANALYZE (они дешёвые, 2-5 мс); для новых v3-вызовов нужно дописать builder в `sqlstats.BUILDERS` |
| 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 через границу минуты раньше писал в разные каталоги |
## Decisions made with the human

View File

@ -108,3 +108,19 @@ cd .. && .venv/bin/python -m perfkit.memtrend results/mem [--from-cycle 1]
- Вторым блоком печатается наклон по второй половине (циклы 11-20) - индикатор плато: если он заметно меньше общего, рост затухает.
- Snapshots на циклах 10 и 20 слегка влияют на heap следующего замера (после снимка остаются служебные структуры/GC), поэтому единичные скачки в точках 11 и 21 не интерпретировать.
- Результат прогона t8-mem (с цикла 1): heap 0.046 МБ/цикл (вторая половина 0.027), nodes 0, listeners 0, ws_open = 1 всегда. DOM, listeners и WS стабильны; остаточный рост JS-heap ~27-46 КБ/цикл неубедителен на 20 циклах - кандидат на diff retainers снимков 10 и 20 (Task 10).
### Бэк
Сценарии Task 5-8 пишут сетевой лог (`perf/e2e/lib/netlog.ts`, подключён в каждом сценарии после `newContext`) в `perf/results/<run_id>/network.jsonl`: метод, полный URL с query, путь, статус, `duration_ms`/`ttfb_ms`, `body_size` (сжатый, по CDP), тело не-GET, `scenario`/`target`/`role`. Фронт ходит в API через proxy `:8080/api`, оба origin учитываются. `globalSetup` Playwright фиксирует `PERF_RUN_ID` и делает `pg_stat_statements_reset()`, `globalTeardown` (если был сетевой лог) пишет `sql_top.json` и `explain/*.json`; `PERF_SQLSTATS=0` отключает, при недоступном Postgres печатается предупреждение, прогон не падает.
```
cd perf
.venv/bin/python -m perfkit.bench results/<run_id> --repeats 20 # api.jsonl: scenario api, metrics ms/bytes
.venv/bin/python -m perfkit.aggregate results/<run_id> # таблицы, в т.ч. scenario api
.venv/bin/python -m perfkit.sqlstats dump results/<run_id> # вручную, если нужно перегенерировать
```
- `bench`: повторяет уникальные GET (роль и токен из манифеста), `target` = `METHOD шаблон` (числовые сегменты -> `{id}`; разные form_id сливаются в одну группу, полный URL - в поле `url` записи). `ms` - полное время ответа с чтением тела, `bytes` - размер после распаковки (в логе `body_size` сжатый). PATCH ячеек повторяются только при `"idempotent": true` в строке лога; сценарии такой пометки не ставят (правки идут по WebSocket), поэтому PATCH не повторяются. POST/DELETE - никогда. Запросы, длившиеся в логе дольше `--max-logged-s` (60), пропускаются (свод 2). Повторный запуск заменяет `api.jsonl`.
- `sql_top.json`: топ по `total_exec_time` и `mean_exec_time`; поле `toplevel=false` - вложенные запросы внутри plpgsql-функций (их `p_*` в тексте - переменные функции, отдельно не выполнить). Собственные запросы статистики (EXPLAIN, `perf-sqlstats`) исключены.
- `explain/<n>.json`: топ-5 по `total_exec_time` среди top-level SELECT (не `v3.add_/upd_/del_`, не DML), в транзакции с ROLLBACK. Параметры: для `v3.v_form_view` и `v3.v_summary_view` берутся из сетевого лога (тот же разбор query, что у роутера), режим `analyze` (`EXPLAIN (ANALYZE, BUFFERS)`, `SET LOCAL app.user_id` роли из лога). Для остальных - `generic_plan` (PREPARE + `force_generic_plan` + EXPLAIN без выполнения), поле `mode`. План вызова функции - один Function Scan: внутренности функции видны только в `sql_top.json` (nested-строки).
- `aggregate` исключает записи с `metrics.traced == 1` (повтор под трассировкой, O-9) и не выводит метрику `traced`; число исключённых печатается в заголовке группы.

View File

@ -0,0 +1,21 @@
/** Playwright globalSetup: фиксирует run_id для всех воркеров и сбрасывает pg_stat_statements. PERF_SQLSTATS=0 - отключить. */
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { PERF_DIR } from './manifest';
import { runId } from './results';
export const PYTHON = process.env.PERF_PYTHON ?? path.join(PERF_DIR, '.venv', 'bin', 'python');
export function sqlstats(args: string[]): void {
const r = spawnSync(PYTHON, ['-m', 'perfkit.sqlstats', ...args], { cwd: PERF_DIR, encoding: 'utf-8', timeout: 10 * 60_000 });
if (r.stdout?.trim()) console.log(r.stdout.trim());
if (r.error || r.status !== 0 || /WARNING \[perf sqlstats\]/.test(r.stderr ?? '')) {
console.warn(`[perf] ПРЕДУПРЕЖДЕНИЕ: sqlstats ${args[0]} не отработал (Postgres недоступен?): ${r.error ?? ''} ${r.stderr ?? ''}`.trim());
}
}
export default async function globalSetup(): Promise<void> {
process.env.PERF_RUN_ID ??= runId(); // один run_id на весь прогон (воркеры наследуют env)
if (process.env.PERF_SQLSTATS === '0') return;
sqlstats(['reset']);
}

View File

@ -0,0 +1,11 @@
/** Playwright globalTeardown: sql_top.json + explain/*.json в каталог прогона. Только если был сетевой лог (т.е. гонялись сценарии). */
import { existsSync } from 'node:fs';
import { sqlstats } from './global-setup';
import { runDir } from './results';
import { netlogFile } from './netlog';
export default async function globalTeardown(): Promise<void> {
if (process.env.PERF_SQLSTATS === '0') return;
if (!existsSync(netlogFile())) return;
sqlstats(['dump', runDir()]);
}

48
perf/e2e/lib/netlog.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* Сетевой лог сценариев (Task 9): все запросы браузера к API -> perf/results/<run_id>/network.jsonl.
* Формат строки: 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 КБ.
*/
import type { BrowserContext, Request } from '@playwright/test';
import { appendFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { API, WEB } from './auth';
import { runDir, runId } from './results';
export interface NetMeta { scenario: string; target: string; role: string }
const MAX_POST = 64 * 1024;
export function netlogFile(): string { return path.join(runDir(), 'network.jsonl'); }
export function attachNetlog(ctx: BrowserContext, meta: NetMeta): { flush: () => Promise<void> } {
const pending = new Set<Promise<void>>();
const handle = async (req: Request) => {
const url = req.url();
// фронт ходит в API через proxy своего origin (:8080/api), прямые вызовы - на PERF_API
if (!(url.startsWith(WEB + '/api/') || url.startsWith(API + '/api/'))) return;
let status = 0, body_size = -1;
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 rec = {
run_id: runId(), ts: new Date().toISOString(), ...meta,
method: req.method(), url, path: u.pathname, query: u.search.replace(/^\?/, ''), status,
resource_type: req.resourceType(),
post_data: post && post.length <= MAX_POST ? post : null,
duration_ms: t.responseEnd >= 0 ? Math.round(t.responseEnd * 10) / 10 : null,
ttfb_ms: t.responseStart >= 0 ? Math.round(t.responseStart * 10) / 10 : null,
body_size,
};
mkdirSync(runDir(), { recursive: true });
appendFileSync(netlogFile(), JSON.stringify(rec) + '\n', 'utf-8');
};
ctx.on('requestfinished', (req) => {
const p = handle(req).catch((e) => { if (process.env.PERF_NETLOG_DEBUG) console.log("netlog err", e); }).finally(() => pending.delete(p));
pending.add(p);
});
return { flush: async () => { await Promise.all([...pending]); } };
}

View File

@ -8,6 +8,8 @@ export default defineConfig({
retries: 0,
timeout: 10 * 60 * 1000,
reporter: 'list',
globalSetup: './lib/global-setup.ts',
globalTeardown: './lib/global-teardown.ts',
use: {
browserName: 'chromium',
baseURL: process.env.PERF_WEB ?? 'http://localhost:8080',

View File

@ -19,6 +19,7 @@ import path from 'node:path';
import { loadManifest, scale, type Sheet } from '../lib/manifest';
import { loginAs, WEB, API, ACCESS_KEY, REFRESH_KEY } from '../lib/auth';
import { throttle, heapUsed, domCounters, heapSnapshot } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough } from '../lib/scroll';
@ -172,6 +173,7 @@ test('memory: 20 циклов, rf, CPU 1', async ({ browser }) => {
const sheet1 = pickSheet('FORM_2', process.env.PERF_MEM_SHEET1);
const sheet2 = pickSheet('FORM_4', process.env.PERF_MEM_SHEET2);
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const nl = attachNetlog(ctx, { scenario: 'memory', target: `${safe(sheetName(sheet1))}+${safe(sheetName(sheet2))}`, role: 'rf' });
try {
await loginAs(ctx, user);
await ctx.addInitScript(WS_SCRIPT);
@ -227,6 +229,7 @@ test('memory: 20 циклов, rf, CPU 1', async ({ browser }) => {
await sample(cycle, { edits_ok: EDITS_PER_SHEET, relogin: relogin ? 1 : 0, cycle_s: (Date.now() - t0) / 1000 });
}
} finally {
await nl.flush();
await ctx.close();
}
});

View File

@ -21,6 +21,7 @@ import { loginAs, WEB } from '../lib/auth';
import { throttle } from '../lib/cdp';
import { measureOpen, type Appear } from '../lib/modal';
import { REACT_HOOK_SCRIPT, hookInfo, profileStart, profileStop } from '../lib/reactProfile';
import { attachNetlog } from '../lib/netlog';
import { append } from '../lib/results';
interface Step { selector: string; action: 'click' }
@ -70,6 +71,7 @@ async function measure(browser: Browser, d: ModalDef, target: string, sheetRef:
const user: User = m.users[role];
const rec = { scenario: PROFILING ? 'modal_renders' : 'modal', target, role, scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role });
try {
await loginAs(ctx, user);
if (PROFILING) await ctx.addInitScript(REACT_HOOK_SCRIPT);
@ -110,6 +112,7 @@ async function measure(browser: Browser, d: ModalDef, target: string, sheetRef:
console.log(`INCOMPLETE ${target} ${role} cpu${cpu} #${repeat}: ${msg}`);
}
} finally {
await nl.flush();
await ctx.close();
}
}

View File

@ -15,6 +15,7 @@ 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 { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough } from '../lib/scroll';
@ -94,6 +95,7 @@ async function measure(browser: import('@playwright/test').Browser, s: Sheet, cp
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const artifacts: string[] = [];
let bot: Bot | undefined;
const nl = attachNetlog(ctx, { scenario: 'scroll_coauthor', target: targetName, role: 'rf' });
try {
await loginAs(ctx, m.users['rf']);
await ctx.addInitScript(PROBE_SCRIPT);
@ -137,6 +139,7 @@ async function measure(browser: import('@playwright/test').Browser, s: Sheet, cp
});
} finally {
if (bot) await bot.stop().catch(() => {});
await nl.flush();
await ctx.close();
}
}

View File

@ -20,6 +20,7 @@ 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';
@ -155,6 +156,7 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
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);
@ -231,6 +233,7 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
});
if (reason) skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${reason}`);
} finally {
await nl.flush();
await ctx.close();
}
}

View File

@ -32,13 +32,17 @@ def summarize(run_dir) -> dict:
groups: dict[tuple, dict] = {}
for r in read_all(run_dir):
key = tuple(r[k] for k in GROUP_KEYS)
g = groups.setdefault(key, {"vals": {}, "n": 0, "incomplete_n": 0})
g = groups.setdefault(key, {"vals": {}, "n": 0, "incomplete_n": 0, "traced_n": 0})
if r["metrics"].get("traced") == 1:
# повтор под трассировкой (O-9): искажён накладными расходами trace, в статистику не входит
g["traced_n"] += 1
continue
if r.get("incomplete"):
g["incomplete_n"] += 1
continue
g["n"] += 1
for m, v in r["metrics"].items():
if _num(v):
if m != "traced" and _num(v):
g["vals"].setdefault(m, []).append(v)
out = []
for key in sorted(groups, key=lambda k: tuple(str(x) for x in k)):
@ -47,7 +51,7 @@ def summarize(run_dir) -> dict:
for m, vals in sorted(g["vals"].items()):
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"], "metrics": metrics})
out.append({**dict(zip(GROUP_KEYS, key)), "n": g["n"], "incomplete_n": g["incomplete_n"], "traced_n": g["traced_n"], "metrics": metrics})
return {"run_id": run_dir.name, "groups": out}
@ -60,7 +64,7 @@ def render_md(summary: dict) -> str:
for g in summary["groups"]:
lines.append(f"## {g['scenario']} | target={g['target']} | role={g['role']} | scale={g['scale']} | cpu_rate={g['cpu_rate']}")
lines.append("")
lines.append(f"Замеров: {g['n']}, незавершённых: {g['incomplete_n']}")
lines.append(f"Замеров: {g['n']}, незавершённых: {g['incomplete_n']}" + (f", исключено трассировочных: {g['traced_n']}" if g.get("traced_n") else ""))
lines.append("")
lines.append("| metric | median | p95 | min | max | n |")
lines.append("|---|---|---|---|---|---|")

162
perf/perfkit/bench.py Normal file
View File

@ -0,0 +1,162 @@
"""Бенч запросов бэка: python -m perfkit.bench <run_dir> [--repeats 20] [--api URL] [--only REGEX] [--max-logged-s 60].
Берёт сетевой лог сценариев <run_dir>/network.jsonl (пишет e2e/lib/netlog.ts), выбирает уникальные
безопасные запросы и повторяет их через httpx под той же ролью (логин по манифесту).
Строка network.jsonl (JSON): run_id, ts, scenario, target, role, method, url (полный, с query), path, query,
status, resource_type, post_data (тело не-GET или null), duration_ms, ttfb_ms, body_size.
Что повторяется:
* GET со статусом < 400, кроме /auth/ и не-API; один раз на (role, method, url);
* PATCH ячеек (.../cell, .../cells) - только если строка лога помечена "idempotent": true (значение в теле
заведомо равно текущему). Сейчас сценарии такую пометку не ставят (правки идут по WebSocket), поэтому PATCH
не повторяется: идемпотентность доказать нечем;
* POST/PUT/DELETE и всё остальное - никогда (POST .../line создаёт строку).
Запросы, которые в логе длились дольше --max-logged-s (по умолчанию 60 с, свод 2 грузится минуты), пропускаются
и печатаются в stdout: x20 повторов такого ответа (31-47 МБ) заняли бы часы; единичный замер уже есть в логе.
Запись в <run_dir>/api.jsonl: scenario "api", target "<METHOD> <шаблон пути>" (числовые сегменты -> {id}), role,
metrics {ms: полное время ответа с чтением тела, bytes: размер тела после распаковки}, плюс поле url.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
import httpx
from . import RESULTS_DIR
from .manifest import load as load_manifest, manifest_path
from .results import append
SAFE_PATCH = re.compile(r"/sheet/[^/]+/cells?$|/report/[^/]+/[^/]+/cells?$")
NUM_SEG = re.compile(r"(?<=/)\d+(?=/|$)")
def path_template(path: str) -> str:
return NUM_SEG.sub("{id}", path.split("?", 1)[0])
def _path(url: str) -> str:
return httpx.URL(url).path
def parse_target(target: str) -> tuple[str, str]:
m, p = target.split(" ", 1)
return m, p
def select_requests(entries, max_logged_ms: float | None = None) -> list[dict]:
seen: set[tuple] = set()
out = []
for e in entries:
method, url = e["method"], e["url"]
p = _path(url)
if not p.startswith("/api/") or "/auth/" in p:
continue
if not (0 < e.get("status", 0) < 400):
continue
if method == "GET":
pass
elif method == "PATCH" and SAFE_PATCH.search(p) and e.get("idempotent") is True and e.get("post_data"):
pass
else:
continue
if max_logged_ms is not None and (e.get("duration_ms") or 0) > max_logged_ms:
continue
key = (e["role"], method, url, e.get("post_data"))
if key in seen:
continue
seen.add(key)
out.append(e)
return out
def record_for(e: dict, repeat: int, ms: float, nbytes: int, run_id: str, scale: int, cpu_rate=1,
incomplete: bool = False, skip_reason: str | None = None) -> dict:
r = {
"run_id": run_id, "scenario": "api",
"target": f"{e['method']} {path_template(_path(e['url']))}",
"role": e["role"], "scale": scale, "cpu_rate": cpu_rate, "repeat": repeat,
"metrics": {"ms": ms, "bytes": nbytes} if not incomplete else {},
"artifacts": [], "incomplete": incomplete,
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"url": e["url"],
}
if skip_reason:
r["skip_reason"] = skip_reason
return r
def read_netlog(run_dir) -> list[dict]:
f = Path(run_dir) / "network.jsonl"
if not f.exists():
return []
return [json.loads(l) for l in f.read_text(encoding="utf-8").splitlines() if l.strip()]
def _login(client: httpx.Client, api: str, user) -> str:
r = client.post(f"{api}/api/v1/auth/login", json={"username": user.username, "password": user.password})
r.raise_for_status()
return r.json()["access_token"]
def run(run_dir, repeats: int = 20, api: str = "http://localhost:8000", only: str | None = None,
max_logged_s: float = 60.0, scale: int = 1) -> int:
run_dir = Path(run_dir)
m = load_manifest(manifest_path(scale))
reqs = select_requests(read_netlog(run_dir), max_logged_s * 1000)
if only:
rx = re.compile(only)
reqs = [e for e in reqs if rx.search(f"{e['method']} {e['url']}")]
skipped = [e for e in read_netlog(run_dir) if e["method"] == "GET" and (e.get("duration_ms") or 0) > max_logged_s * 1000]
for e in skipped:
print(f"SKIP slow ({e['duration_ms']:.0f} ms in log): {e['role']} GET {e['url']}")
(run_dir / "api.jsonl").unlink(missing_ok=True) # повторный бенч заменяет прошлый, а не дублирует
tokens: dict[str, str] = {}
n = 0
with httpx.Client(timeout=300.0) as c:
for e in reqs:
role = e["role"]
if role not in tokens:
tokens[role] = _login(c, api, m.users[role])
hdr = {"Authorization": f"Bearer {tokens[role]}"}
url = api + httpx.URL(e["url"]).raw_path.decode()
for i in range(repeats):
t0 = time.perf_counter()
try:
r = c.request(e["method"], url, headers=hdr, content=e.get("post_data") or None)
ms = (time.perf_counter() - t0) * 1000
if r.status_code >= 400:
append(run_dir, record_for(e, i, 0, 0, run_dir.name, m.scale, incomplete=True, skip_reason=f"HTTP {r.status_code}"))
continue
append(run_dir, record_for(e, i, round(ms, 2), len(r.content), run_dir.name, m.scale))
n += 1
except httpx.HTTPError as ex:
append(run_dir, record_for(e, i, 0, 0, run_dir.name, m.scale, incomplete=True, skip_reason=str(ex)[:200]))
print(f"{e['role']:5} {e['method']} {path_template(_path(e['url']))} x{repeats}")
print(f"api records: {n} in {run_dir / 'api.jsonl'}")
return 0
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="perfkit.bench")
ap.add_argument("run_dir")
ap.add_argument("--repeats", type=int, default=20)
ap.add_argument("--api", default="http://localhost:8000")
ap.add_argument("--only", default=None, help="regex по 'METHOD url'")
ap.add_argument("--max-logged-s", type=float, default=60.0)
ap.add_argument("--scale", type=int, default=1)
a = ap.parse_args(argv)
rd = Path(a.run_dir)
if not rd.is_absolute() and not rd.exists():
rd = RESULTS_DIR / a.run_dir
return run(rd, a.repeats, a.api, a.only, a.max_logged_s, a.scale)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -28,6 +28,10 @@ REQUIRED = ("run_id", "scenario", "target", "role", "scale", "cpu_rate", "repeat
"metrics", "artifacts", "incomplete", "ts")
# сетевой лог (Task 9) лежит рядом, но это не записи замеров
NON_RESULT_FILES = ("network.jsonl",)
def append(run_dir, record: dict) -> Path:
missing = [k for k in REQUIRED if k not in record]
if missing:
@ -43,6 +47,8 @@ def append(run_dir, record: dict) -> Path:
def read_all(run_dir) -> list[dict]:
out = []
for f in sorted(Path(run_dir).glob("*.jsonl")):
if f.name in NON_RESULT_FILES:
continue
for line in f.read_text(encoding="utf-8").splitlines():
if line.strip():
out.append(json.loads(line))

264
perf/perfkit/sqlstats.py Normal file
View File

@ -0,0 +1,264 @@
"""Статистика SQL из pg_stat_statements и EXPLAIN самых тяжёлых чтений.
CLI (его зовут globalSetup/globalTeardown Playwright):
python -m perfkit.sqlstats reset
python -m perfkit.sqlstats dump <run_dir> [--top 15]
Окружение: PERF_DB_DSN (по умолчанию postgresql://perf:perf@localhost:55432/perf). Если Postgres недоступен,
команды печатают предупреждение в stderr и завершаются с кодом 0 (прогон сценариев не должен падать из-за статистики).
dump(run_dir, top):
* sql_top.json: {"by_total": [...], "by_mean": [...]}; строка: query, calls, total_exec_time, mean_exec_time,
rows, shared_blks_hit/read, toplevel.
* explain/<n>.json для топ-5 по total_exec_time среди ВЫПОЛНЯЕМЫХ кандидатов (toplevel, SELECT/WITH, не писатели:
v3.add_*/upd_*/del_*, не служебные set_config/pg_*; DML не берётся никогда).
Подстановка параметров. pg_stat_statements хранит нормализованный текст ($1..$n), значения не хранит. Механизм:
1. Для известных вызовов репозитория (api/src/repository/sheet_repository.py -> v3.v_form_view(form_id, sheet,
sections[, direction]); summary_repository.py -> v3.v_summary_view(year, sheet, org_ids)) параметры берутся из
сетевого лога <run_dir>/network.jsonl: подходящий GET (/api/v1/form/{id}/sheet/{sheet}?direction=&sections=,
/api/v1/svod/1/{sheet}/{year}?org_ids=) разбирается так же, как это делает роутер. Режим "analyze":
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) с реальными значениями; перед ним SET LOCAL app.user_id = id роли из лога.
2. Для остальных кандидатов нет надёжного соответствия лог -> параметры, поэтому режим "generic_plan":
PREPARE + plan_cache_mode=force_generic_plan + EXPLAIN (FORMAT JSON) EXECUTE с NULL (без выполнения, без ANALYZE) - план показывает только вызов
функции; помечается "analyze": false. Если и он не строится - кандидат пропускается, причина в explain/skipped.json.
Всё выполняется в транзакции, которая откатывается (ROLLBACK), statement_timeout 180 с.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
import urllib.parse
from pathlib import Path
DSN = os.environ.get("PERF_DB_DSN", "postgresql://perf:perf@localhost:55432/perf")
WRITER_PREFIXES = ("add_", "upd_", "del_")
SKIP_FUNCS = ("set_config", "pg_stat_statements", "current_setting", "pg_sleep")
V3_CALL = re.compile(r"\bv3\.(\w+)\s*\(", re.I)
STATEMENT_TIMEOUT_MS = 180_000
def v3_functions(query: str) -> list[str]:
return V3_CALL.findall(query)
def is_explainable(query: str) -> bool:
q = query.strip()
if not re.match(r"(?is)^(select|with)\b", q):
return False
low = q.lower()
if re.search(r"\bfor\s+(update|share|no\s+key\s+update)\b", low):
return False
if any(f in low for f in SKIP_FUNCS):
return False
if re.search(r"(?is)\b(insert\s+into|update\s+\w|delete\s+from)\b", q) and not v3_functions(q):
return False
return not any(f.lower().startswith(WRITER_PREFIXES) for f in v3_functions(q))
def explain_candidates(rows, n: int = 5) -> list[dict]:
"""Топ-n по total_exec_time среди строк, которые можно EXPLAIN-ить."""
ok = [r for r in rows if is_explainable(r["query"])]
return sorted(ok, key=lambda r: r["total_exec_time"], reverse=True)[:n]
def pick_params(query: str, samples: dict[str, list]) -> list | None:
return samples.get(query)
# ---------- параметры из сетевого лога ----------
def _sections(qs: dict) -> list[str] | None:
v = qs.get("sections", [""])[0]
return [s for s in v.split(",") if s] or None
def _params_form_view(e: dict, nparams: int) -> list | None:
m = re.fullmatch(r"/api/v1/form/(\d+)/sheet/(\w+)", e["path"])
if not m or e["method"] != "GET":
return None
qs = urllib.parse.parse_qs(e.get("query") or "")
direction = qs.get("direction", [None])[0]
if (nparams == 4) != bool(direction):
return None
p = [int(m.group(1)), m.group(2), _sections(qs)]
return p + [direction] if nparams == 4 else p
def _params_summary(e: dict, nparams: int) -> list | None:
m = re.fullmatch(r"/api/v1/svod/1/(\w+)/(\d+)", e["path"])
if not m or e["method"] != "GET" or nparams != 3:
return None
qs = urllib.parse.parse_qs(e.get("query") or "")
ids = [int(x) for x in qs.get("org_ids", [""])[0].split(",") if x.strip()] or None
return [int(m.group(2)), m.group(1), ids]
BUILDERS = {"v_form_view": _params_form_view, "v_summary_view": _params_summary}
def build_samples(rows, netlog) -> dict[str, tuple[list, dict]]:
"""query -> (params, лог-запись). Берётся лог-запись самого долгого подходящего запроса (худший случай)."""
out = {}
for r in rows:
funcs = v3_functions(r["query"])
if len(funcs) != 1 or funcs[0] not in BUILDERS:
continue
n = len(set(re.findall(r"\$(\d+)", r["query"].split("FROM", 1)[-1])))
best = None
for e in netlog:
p = BUILDERS[funcs[0]](e, n)
if p is not None and (best is None or (e.get("duration_ms") or 0) > (best[1].get("duration_ms") or 0)):
best = (p, e)
if best:
out[r["query"]] = best
return out
# ---------- работа с БД ----------
def _warn(msg: str) -> None:
print(f"WARNING [perf sqlstats]: {msg}", file=sys.stderr)
async def _connect():
import asyncpg
return await asyncpg.connect(DSN, timeout=5)
def reset() -> bool:
async def go():
c = await _connect()
try:
await c.execute("SELECT pg_stat_statements_reset()")
finally:
await c.close()
try:
asyncio.run(go())
return True
except Exception as ex: # noqa: BLE001
_warn(f"reset не выполнен, SQL-статистика не будет собрана: {type(ex).__name__}: {ex}")
return False
STAT_SQL = """
SELECT query, calls, total_exec_time, mean_exec_time, rows, shared_blks_hit, shared_blks_read, toplevel
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query NOT ILIKE '%pg_stat_statements%'
AND query NOT ILIKE 'EXPLAIN%'
AND query NOT LIKE '%perf-sqlstats%'
"""
async def _explain_one(conn, query: str, params, user_id: int | None) -> tuple[str, list]:
tr = conn.transaction()
await tr.start()
prepared = False
try:
await conn.execute(f"SET LOCAL statement_timeout = {STATEMENT_TIMEOUT_MS}")
if user_id is not None:
await conn.execute(f"SET LOCAL app.user_id = {int(user_id)}")
if params is not None:
raw = await conn.fetchval("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + query, *params)
return "analyze", json.loads(raw) if isinstance(raw, str) else raw
# без значений параметров: PREPARE + force_generic_plan + EXPLAIN EXECUTE с NULL (GENERIC_PLAN не работает
# по расширенному протоколу asyncpg). План показывает общую форму запроса, без выполнения.
n = max([int(x) for x in re.findall(r"\$(\d+)", query)] or [0])
await conn.execute("SET LOCAL plan_cache_mode = force_generic_plan")
await conn.execute("PREPARE perf_gp AS " + query)
prepared = True
args = ", ".join(["NULL"] * n)
raw = await conn.fetchval("EXPLAIN (FORMAT JSON) EXECUTE perf_gp" + (f"({args})" if n else ""))
return "generic_plan", json.loads(raw) if isinstance(raw, str) else raw
finally:
await tr.rollback()
if prepared:
await conn.execute("DEALLOCATE perf_gp") # PREPARE живёт в сессии, а не в транзакции
async def _dump(run_dir: Path, top: int) -> dict:
conn = await _connect()
try:
rows = [dict(r) for r in await conn.fetch(STAT_SQL)]
for r in rows:
for k in ("total_exec_time", "mean_exec_time"):
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(
json.dumps({"by_total": by_total, "by_mean": by_mean}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
netlog = []
nf = run_dir / "network.jsonl"
if nf.exists():
netlog = [json.loads(l) for l in nf.read_text(encoding="utf-8").splitlines() if l.strip()]
users = {r["username"]: r["id"] for r in await conn.fetch("SELECT /* perf-sqlstats */ id, username FROM v3.app_user")}
manifest_users = {}
try:
from .manifest import load, manifest_path
manifest_users = {k: u.username for k, u in load(manifest_path(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 в том же каталоге
old.unlink()
done, skipped = 0, []
for r in cands:
if done >= 5:
break
smp = pick_params(r["query"], samples)
params, entry = smp if smp else (None, None)
uid = users.get(manifest_users.get(entry["role"], "")) if entry else None
try:
mode, plan = await _explain_one(conn, r["query"], params, uid)
except Exception as ex: # noqa: BLE001
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({
"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")
return {"explained": done, "skipped": len(skipped)}
finally:
await conn.close()
def dump(run_dir, top: int = 15) -> bool:
run_dir = Path(run_dir)
run_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}")
return True
except Exception as ex: # noqa: BLE001
_warn(f"dump не выполнен, sql_top.json/explain не созданы: {type(ex).__name__}: {ex}")
return False
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if argv and argv[0] == "reset":
reset()
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)
return 0
print("usage: python -m perfkit.sqlstats reset | dump <run_dir> [--top N]", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -39,3 +39,20 @@ def test_append_path_and_md(tmp_path):
assert "flag" not in s["groups"][0]["metrics"]
md = render_md(s)
assert "| a |" in md and "median" in md
def test_traced_records_excluded_and_metric_hidden(tmp_path):
results.append(tmp_path, rec(repeat=0, metrics={"long_task_ms": 999, "traced": 1}))
results.append(tmp_path, rec(repeat=1, metrics={"long_task_ms": 10, "traced": 0}))
results.append(tmp_path, rec(repeat=2, metrics={"long_task_ms": 20, "traced": 0}))
g = summarize(tmp_path)["groups"][0]
assert "traced" not in g["metrics"]
assert g["n"] == 2 and g["traced_n"] == 1
assert g["metrics"]["long_task_ms"]["max"] == 20
assert "| traced |" not in render_md(summarize(tmp_path))
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

60
perf/tests/test_bench.py Normal file
View File

@ -0,0 +1,60 @@
from perfkit.bench import path_template, select_requests, record_for, parse_target
def e(method="GET", url="http://localhost:8000/api/v1/form/1/sheet/AHR", role="rf", status=200, **kw):
d = {"method": method, "url": url, "role": role, "status": status}
d.update(kw)
return d
def test_path_template():
assert path_template("/api/v1/form/123/sheet/AHR/cells") == "/api/v1/form/{id}/sheet/AHR/cells"
def test_path_template_ignores_query_and_digits_inside_names():
assert path_template("/api/v1/form/5/sheet/AHR_2?year=2026") == "/api/v1/form/{id}/sheet/AHR_2"
assert path_template("/api/v1/projects/7/report/2026/PLAN") == "/api/v1/projects/{id}/report/{id}/PLAN"
def test_select_requests_skips_non_idempotent():
got = select_requests([
e("GET"),
e("POST", "http://localhost:8000/api/v1/form/1/sheet/AHR/line", post_data='{"x":1}'),
e("DELETE", "http://localhost:8000/api/v1/form/1/sheet/AHR/line/9"),
])
assert [g["method"] for g in got] == ["GET"]
def test_select_requests_dedups_by_role_method_url():
got = select_requests([e(), e(), e(role="admin"), e(url="http://localhost:8000/api/v1/form/1/sheet/AHR?direction=x")])
assert len(got) == 3
def test_select_requests_skips_errors_and_auth():
got = select_requests([e(status=404), e(status=500), e(url="http://localhost:8000/api/v1/auth/login"),
e(url="http://localhost:8000/assets/app.js")])
assert got == []
def test_select_requests_patch_only_when_proven_idempotent():
p = "http://localhost:8000/api/v1/form/1/sheet/AHR/cell"
assert select_requests([e("PATCH", p, post_data='{"line_id":1,"column":"c","value":5}')]) == []
got = select_requests([e("PATCH", p, post_data='{"line_id":1,"column":"c","value":5}', idempotent=True)])
assert len(got) == 1
def test_select_requests_skips_slow_logged():
got = select_requests([e(duration_ms=120_000)], max_logged_ms=60_000)
assert got == []
def test_record_for_shape():
r = record_for(e(), repeat=3, ms=12.5, nbytes=100, run_id="r1", scale=1)
assert r["scenario"] == "api" and r["target"] == "GET /api/v1/form/{id}/sheet/AHR"
assert r["role"] == "rf" and r["repeat"] == 3
assert r["metrics"] == {"ms": 12.5, "bytes": 100}
assert r["incomplete"] is False and r["cpu_rate"] == 1
def test_parse_target():
assert parse_target("GET /api/v1/x/{id}") == ("GET", "/api/v1/x/{id}")

View File

@ -0,0 +1,41 @@
from perfkit.sqlstats import explain_candidates, v3_functions, is_explainable, pick_params
def row(q, total=1.0):
return {"query": q, "total_exec_time": total}
def test_v3_functions_extracts_names():
assert v3_functions("SELECT * FROM v3.v_sheet_rows($1, $2)") == ["v_sheet_rows"]
assert v3_functions("select v3.upd_cell($1,$2)") == ["upd_cell"]
assert v3_functions("SELECT 1") == []
def test_is_explainable_select_and_read_functions():
assert is_explainable("SELECT * FROM v3.v_sheet_rows($1)")
assert is_explainable(" select a from t where id = $1")
assert is_explainable("WITH x AS (SELECT 1) SELECT * FROM x")
def test_is_explainable_rejects_dml_and_writers():
assert not is_explainable("UPDATE t SET a = $1")
assert not is_explainable("INSERT INTO t VALUES ($1)")
assert not is_explainable("DELETE FROM t")
assert not is_explainable("SELECT * FROM v3.add_line($1)")
assert not is_explainable("SELECT v3.upd_cell($1, $2)")
assert not is_explainable("SELECT v3.del_line($1)")
assert not is_explainable("SELECT * FROM t FOR UPDATE")
assert not is_explainable("SELECT set_config('a', $1, false)")
assert not is_explainable("SELECT pg_stat_statements_reset()")
assert not is_explainable("EXPLAIN SELECT 1")
def test_explain_candidates_top5_by_total_in_order():
rows = [row("UPDATE t SET a=$1", 100)] + [row(f"SELECT {i} FROM t WHERE a=$1", i) for i in range(10)]
got = explain_candidates(rows, n=5)
assert [r["total_exec_time"] for r in got] == [9, 8, 7, 6, 5]
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