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

Refs #202

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

163 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Бенч запросов бэка: 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())