"""Время ответа API: доля БД против Python (сериализация) без py-spy. cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/20261026-s1/analysis Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:184, summary.py:74,126, projects.py:128): время от начала обработчика до получения строк из БД (поиск формы + SQL-функция + разбор jsonb драйвером). Остаток `полное время - X-DB-Time-Ms` - сборка pydantic-моделей (`_rows_to_sheet_response`, forms.py:27), валидация `response_model`, JSON-кодирование, сжатие GZipMiddleware (api/src/main.py:248, compresslevel=1), передача и распаковка на клиенте (httpx). py-spy на macOS требует root - этот способ его заменяет для вопроса «БД или Python». Запросы: самые тяжёлые из bench (api.jsonl) - листы FORM_2/AHR, FORM_4/CAP и FORM_4/AHR (rf), свод 2 GO и RF и свод 1 MAIN (dfip), отчёт проекта (rf), сводные проектов (dfip). Напрямую на :8000 (без proxy фронта), httpx, тело читается целиком. Вывод: /api-split.json и api-split.md. """ from __future__ import annotations import argparse import json import statistics import time from pathlib import Path import httpx PERF = Path(__file__).resolve().parents[1] def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--scale", type=int, default=1) ap.add_argument("--repeats", type=int, default=10) ap.add_argument("--api", default="http://localhost:8000") ap.add_argument("--out", required=True) a = ap.parse_args(argv) man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text()) rf = [s for s in man["sheets"] if s.get("owner", "rf") == "rf"] def sheet(ft, sh): return next(s for s in rf if s["form_type"] == ft and s["sheet"] == sh) f2, f4c, f4a = sheet("FORM_2", "AHR"), sheet("FORM_4", "CAP"), sheet("FORM_4", "AHR") pr = man["project_reports"][0] year = f2["year"] reqs = [ ("rf", "лист FORM_2/AHR", f"/api/v1/form/{f2['form_id']}/sheet/AHR"), ("rf", "лист FORM_4/CAP", f"/api/v1/form/{f4c['form_id']}/sheet/CAP"), ("rf", "лист FORM_4/AHR", f"/api/v1/form/{f4a['form_id']}/sheet/AHR"), ("rf", f"отчёт проекта {pr['report_type']}", f"/api/v1/projects/{pr['project_id']}/report/{pr['year']}/{pr['report_type']}"), ("dfip", "свод 1 MAIN", f"/api/v1/svod/1/MAIN/{year}"), ("dfip", "свод 2 GO (все организации)", f"/api/v1/svod/2/GO/{year}"), ("dfip", "свод 2 RF (все организации)", f"/api/v1/svod/2/RF/{year}"), ("dfip", "сводные проектов", f"/api/v1/projects/plan-summary?year={year}"), ] tokens = {} with httpx.Client(base_url=a.api, timeout=300) as c: for role in {r for r, _, _ in reqs}: u = man["users"][role] r = c.post("/api/v1/auth/login", json={"username": u["username"], "password": u["password"]}) r.raise_for_status() tokens[role] = r.json()["access_token"] res = [] for role, label, path in reqs: tot, db, size = [], [], 0 for i in range(a.repeats + 1): t0 = time.perf_counter() r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"}) body = r.content dt = (time.perf_counter() - t0) * 1000 r.raise_for_status() if i == 0: rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow) continue # прогрев tot.append(dt) db.append(float(r.headers.get("x-db-time-ms", "nan"))) size = len(body) row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1), "db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")} row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1) row["python_share"] = round(row["python_ms"] / row["total_ms"], 2) row["ratio_total_db"] = round(row["total_ms"] / row["db_ms"], 2) if row["db_ms"] else None res.append(row) print(row, flush=True) out = Path(a.out) out.mkdir(parents=True, exist_ok=True) (out / "api-split.json").write_text(json.dumps(res, ensure_ascii=False, indent=1)) md = [f"# API: БД против Python (api_split.py, масштаб {a.scale}, медиана {a.repeats} повторов после прогрева, напрямую :8000)", "", "| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | Content-Encoding |", "|---|---|---|---|---|---|---|---|---|"] md += [f"| {r['request']} | {r['role']} | {r['total_ms']} | {r['db_ms']} | {r['python_ms']} | {r['python_share']} | {r['ratio_total_db']} | " f"{r['bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res] (out / "api-split.md").write_text("\n".join(md) + "\n") if __name__ == "__main__": main()