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:
parent
4a539d803f
commit
fdc8ecb2a3
@ -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
|
||||
|
||||
|
||||
@ -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`; число исключённых печатается в заголовке группы.
|
||||
|
||||
21
perf/e2e/lib/global-setup.ts
Normal file
21
perf/e2e/lib/global-setup.ts
Normal 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']);
|
||||
}
|
||||
11
perf/e2e/lib/global-teardown.ts
Normal file
11
perf/e2e/lib/global-teardown.ts
Normal 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
48
perf/e2e/lib/netlog.ts
Normal 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]); } };
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
162
perf/perfkit/bench.py
Normal 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())
|
||||
@ -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
264
perf/perfkit/sqlstats.py
Normal 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=§ions=,
|
||||
/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())
|
||||
@ -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
60
perf/tests/test_bench.py
Normal 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}")
|
||||
41
perf/tests/test_sqlstats.py
Normal file
41
perf/tests/test_sqlstats.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user