From fdc8ecb2a313388f400773e5039473d04e1bc9bf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:36:46 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN, aggregate исключает трассировочные повторы (O-9). Refs #202 Co-Authored-By: Claude Opus 5.5 --- openspec/changes/perf-profiling/design.md | 8 +- perf/README.md | 16 ++ perf/e2e/lib/global-setup.ts | 21 ++ perf/e2e/lib/global-teardown.ts | 11 + perf/e2e/lib/netlog.ts | 48 ++++ perf/e2e/playwright.config.ts | 2 + perf/e2e/scenarios/memory.spec.ts | 3 + perf/e2e/scenarios/modals.spec.ts | 3 + perf/e2e/scenarios/scroll-coauthor.spec.ts | 3 + perf/e2e/scenarios/scroll.spec.ts | 3 + perf/perfkit/aggregate.py | 12 +- perf/perfkit/bench.py | 162 +++++++++++++ perf/perfkit/results.py | 6 + perf/perfkit/sqlstats.py | 264 +++++++++++++++++++++ perf/tests/test_aggregate.py | 17 ++ perf/tests/test_bench.py | 60 +++++ perf/tests/test_sqlstats.py | 41 ++++ 17 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 perf/e2e/lib/global-setup.ts create mode 100644 perf/e2e/lib/global-teardown.ts create mode 100644 perf/e2e/lib/netlog.ts create mode 100644 perf/perfkit/bench.py create mode 100644 perf/perfkit/sqlstats.py create mode 100644 perf/tests/test_bench.py create mode 100644 perf/tests/test_sqlstats.py diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index f357224..17ea88a 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -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 diff --git a/perf/README.md b/perf/README.md index 29d0010..014e8bb 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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//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/ --repeats 20 # api.jsonl: scenario api, metrics ms/bytes +.venv/bin/python -m perfkit.aggregate results/ # таблицы, в т.ч. scenario api +.venv/bin/python -m perfkit.sqlstats dump results/ # вручную, если нужно перегенерировать +``` + +- `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/.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`; число исключённых печатается в заголовке группы. diff --git a/perf/e2e/lib/global-setup.ts b/perf/e2e/lib/global-setup.ts new file mode 100644 index 0000000..bc7ba3f --- /dev/null +++ b/perf/e2e/lib/global-setup.ts @@ -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 { + process.env.PERF_RUN_ID ??= runId(); // один run_id на весь прогон (воркеры наследуют env) + if (process.env.PERF_SQLSTATS === '0') return; + sqlstats(['reset']); +} diff --git a/perf/e2e/lib/global-teardown.ts b/perf/e2e/lib/global-teardown.ts new file mode 100644 index 0000000..cb752d6 --- /dev/null +++ b/perf/e2e/lib/global-teardown.ts @@ -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 { + if (process.env.PERF_SQLSTATS === '0') return; + if (!existsSync(netlogFile())) return; + sqlstats(['dump', runDir()]); +} diff --git a/perf/e2e/lib/netlog.ts b/perf/e2e/lib/netlog.ts new file mode 100644 index 0000000..c09a24e --- /dev/null +++ b/perf/e2e/lib/netlog.ts @@ -0,0 +1,48 @@ +/** + * Сетевой лог сценариев (Task 9): все запросы браузера к API -> perf/results//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 } { + const pending = new Set>(); + 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]); } }; +} diff --git a/perf/e2e/playwright.config.ts b/perf/e2e/playwright.config.ts index 821a01c..e8cc0cf 100644 --- a/perf/e2e/playwright.config.ts +++ b/perf/e2e/playwright.config.ts @@ -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', diff --git a/perf/e2e/scenarios/memory.spec.ts b/perf/e2e/scenarios/memory.spec.ts index 5898e95..daada28 100644 --- a/perf/e2e/scenarios/memory.spec.ts +++ b/perf/e2e/scenarios/memory.spec.ts @@ -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(); } }); diff --git a/perf/e2e/scenarios/modals.spec.ts b/perf/e2e/scenarios/modals.spec.ts index c05f49a..e4c1bc0 100644 --- a/perf/e2e/scenarios/modals.spec.ts +++ b/perf/e2e/scenarios/modals.spec.ts @@ -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(); } } diff --git a/perf/e2e/scenarios/scroll-coauthor.spec.ts b/perf/e2e/scenarios/scroll-coauthor.spec.ts index c237e96..663d7d1 100644 --- a/perf/e2e/scenarios/scroll-coauthor.spec.ts +++ b/perf/e2e/scenarios/scroll-coauthor.spec.ts @@ -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(); } } diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts index 4c3ff56..0e6931e 100644 --- a/perf/e2e/scenarios/scroll.spec.ts +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -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(); } } diff --git a/perf/perfkit/aggregate.py b/perf/perfkit/aggregate.py index 87d10ce..f97edae 100644 --- a/perf/perfkit/aggregate.py +++ b/perf/perfkit/aggregate.py @@ -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("|---|---|---|---|---|---|") diff --git a/perf/perfkit/bench.py b/perf/perfkit/bench.py new file mode 100644 index 0000000..2c22171 --- /dev/null +++ b/perf/perfkit/bench.py @@ -0,0 +1,162 @@ +"""Бенч запросов бэка: python -m perfkit.bench [--repeats 20] [--api URL] [--only REGEX] [--max-logged-s 60]. + +Берёт сетевой лог сценариев /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 МБ) заняли бы часы; единичный замер уже есть в логе. + +Запись в /api.jsonl: scenario "api", target " <шаблон пути>" (числовые сегменты -> {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()) diff --git a/perf/perfkit/results.py b/perf/perfkit/results.py index 8eae60a..6c19cf1 100644 --- a/perf/perfkit/results.py +++ b/perf/perfkit/results.py @@ -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)) diff --git a/perf/perfkit/sqlstats.py b/perf/perfkit/sqlstats.py new file mode 100644 index 0000000..f672d54 --- /dev/null +++ b/perf/perfkit/sqlstats.py @@ -0,0 +1,264 @@ +"""Статистика SQL из pg_stat_statements и EXPLAIN самых тяжёлых чтений. + +CLI (его зовут globalSetup/globalTeardown Playwright): + python -m perfkit.sqlstats reset + python -m perfkit.sqlstats dump [--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/.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)) параметры берутся из + сетевого лога /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 [--top N]", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/perf/tests/test_aggregate.py b/perf/tests/test_aggregate.py index 4a0e961..d30bf55 100644 --- a/perf/tests/test_aggregate.py +++ b/perf/tests/test_aggregate.py @@ -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 diff --git a/perf/tests/test_bench.py b/perf/tests/test_bench.py new file mode 100644 index 0000000..5310f3f --- /dev/null +++ b/perf/tests/test_bench.py @@ -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}") diff --git a/perf/tests/test_sqlstats.py b/perf/tests/test_sqlstats.py new file mode 100644 index 0000000..073d68c --- /dev/null +++ b/perf/tests/test_sqlstats.py @@ -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