Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN, aggregate исключает трассировочные повторы (O-9). Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
7.2 KiB
Python
163 lines
7.2 KiB
Python
"""Бенч запросов бэка: 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())
|