Andrey Epifancev b612178b12 fix(tests): тесты тяжёлых GET — непустые данные свода 2, gzip, правки докстрингов
Фикстура свода 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>
2026-10-28 21:43:31 +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` (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()