Andrey Epifancev 61541c74a7 Merge origin/feat/236 (фронт) в feat/236 (perf): api_split через measure() с поддержкой ответа-словаря (facets)
Конфликт perf/analysis/api_split.py: взят measure() из test (wire_bytes, AURORA-1848),
в него перенесена обработка нечислового result (facets возвращает объект, строк нет).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:04:41 +04:00

117 lines
7.4 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.

"""Время ответа 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` (GET: api/src/api/v1/forms.py:198 get_sheet;
summary.py:75,126; projects.py:139,272,305,329): время от начала обработчика до получения строк из БД (поиск
формы + SQL-функция + отдача готового JSON драйвером). После задач A ответ собирается из строк jsonb без pydantic, поэтому
остаток `полное время - X-DB-Time-Ms` - это склейка строк в `list_json_response` (api/src/api/v1/json_response.py), сжатие
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, тело читается
целиком. Колонка «на проводе» - `num_bytes_downloaded` httpx (байты после сжатия, как передано по сети), «размер» - декодированное тело.
Вывод: <out>/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 measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict:
"""Один запрос-прогрев и `repeats` замеров; медианы времени, размеры из последнего замера."""
tot, db, size, wire = [], [], 0, 0
rows, r = 0, None
for i in range(repeats + 1):
t0 = time.perf_counter()
r = c.get(path, headers={"Authorization": f"Bearer {token}"})
body = r.content
dt = (time.perf_counter() - t0) * 1000
r.raise_for_status()
if i == 0:
result = r.json().get("result")
rows = len(result) if isinstance(result, list) else None # строк ответа: только для списка (facets - dict)
continue # прогрев
tot.append(dt)
db.append(float(r.headers.get("x-db-time-ms", "nan")))
size = len(body)
wire = r.num_bytes_downloaded
return {"rows": rows, "total_ms": statistics.median(tot), "db_ms": statistics.median(db), "bytes": size,
"wire_bytes": wire, "content_encoding": r.headers.get("content-encoding")}
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", "свод 2 GO полный, страница 300", f"/api/v1/svod/2/GO/{year}?view=full&offset=0&limit=300"),
("dfip", "свод 2 RF полный, страница 300", f"/api/v1/svod/2/RF/{year}?view=full&offset=0&limit=300"),
("dfip", "свод 2 GO compact, страница 300", f"/api/v1/svod/2/GO/{year}?view=compact&offset=0&limit=300"),
("dfip", "свод 2 RF compact, страница 300", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300"),
("dfip", "свод 2 GO варианты фильтров", f"/api/v1/svod/2/GO/{year}/facets"),
("dfip", "свод 2 RF варианты фильтров", f"/api/v1/svod/2/RF/{year}/facets"),
("dfip", "свод 2 RF compact, страница 300, поиск 1", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300&search=1"),
("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:
m = measure(c, path, tokens[role], a.repeats)
row = {"role": role, "request": (f"{label} ({m['rows']} строк ответа)" if m["rows"] is not None else f"{label} (—)"),
"rows": m["rows"], "path": path,
"total_ms": round(m["total_ms"], 1), "db_ms": round(m["db_ms"], 1), "bytes": m["bytes"],
"wire_bytes": m["wire_bytes"], "content_encoding": m["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['wire_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()