Andrey Epifancev d9aa47388a perf(analysis): api_split — размер ответа на проводе (wire_bytes)
Цикл замера вынесен в measure(); добавлен wire_bytes (num_bytes_downloaded,
байты после сжатия) рядом с декодированным размером. В .md - колонка
«на проводе, МБ». Docstring: актуальные места X-DB-Time-Ms и остаток после
задач A (склейка строк, gzip, передача, распаковка; без pydantic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:27:08 +04:00

108 lines
6.5 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` (api/src/api/v1/forms.py:198,241,304,
368,424,473; 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()