Фикстура свода 2 достроена до 6 строк на GO и RF по двум орг. единицам с разными section_code; все случаи SVOD2_CASES проверяют непустоту. Тест rf-rollup переименован (фиксирует существующий дефект), добавлен тест gzip, в api_split.py оставлены только GET-места. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
6.5 KiB
Python
108 lines
6.5 KiB
Python
"""Время ответа 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:
|
||
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)
|
||
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", "сводные проектов", 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']} строк ответа)", "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()
|