perf(analysis): compare_responses — снимок и сравнение ответов тяжёлых GET
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d9aa47388a
commit
b3899cf6dc
169
perf/analysis/compare_responses.py
Normal file
169
perf/analysis/compare_responses.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Сравнение ответов тяжёлых GET до и после задач A и B (AURORA-1848).
|
||||
|
||||
Порядок использования (D7.1):
|
||||
1. `dump` на базовом коммите (до A): python analysis/compare_responses.py dump --scale 1 --out /path/base
|
||||
2. `dump` на коммите A: ... dump --scale 1 --out /path/a
|
||||
3. `compare /path/base /path/a --mode exact` - A не меняет ответы ни на байт значения
|
||||
4. `dump` на коммите B: ... dump --scale 1 --out /path/b
|
||||
5. `compare /path/base /path/b --mode strip-nulls` - B: листы и своды = «до» с вырезанными null в data
|
||||
(семантика jsonb_strip_nulls PG 15), остальные ответы сравниваются точно.
|
||||
Снимок: <out>/<имя>.json.gz (распакованное тело) и <out>/index.json ({имя: статус}). Код возврата compare: 1 при расхождении.
|
||||
Стенд запускает человек; тесты работают на tmp_path и MockTransport.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
PERF = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def build_requests(man: dict) -> list[tuple[str, str, str]]:
|
||||
"""Список (роль, имя, путь с query) по манифесту."""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
sheets = man.get("sheets", [])
|
||||
for s in sheets:
|
||||
role = "rf" if s.get("owner", "rf") == "rf" else "dfip"
|
||||
d = s.get("direction")
|
||||
path = f"/api/v1/form/{s['form_id']}/sheet/{s['sheet']}" + (f"?direction={d}" if d is not None else "")
|
||||
name = f"sheet-{s['form_type']}-{s['sheet']}-{d or 'none'}-{s['form_id']}"
|
||||
out.append((role, name, path))
|
||||
if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and role == "rf":
|
||||
sep = "&" if "?" in path else "?"
|
||||
out.append((role, f"{name}-sections", f"{path}{sep}sections=plan,q1"))
|
||||
f2 = next((s for s in sheets if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and s.get("owner", "rf") == "rf"), None)
|
||||
year = (f2 or sheets[0])["year"] if sheets else None
|
||||
if year is not None:
|
||||
for k in ("FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"):
|
||||
out.append(("dfip", f"svod1-{k}", f"/api/v1/svod/1/{k}/{year}"))
|
||||
for k in ("GO", "RF"):
|
||||
out.append(("dfip", f"svod2-{k}", f"/api/v1/svod/2/{k}/{year}"))
|
||||
for kind in ("plan", "fact", "detail"):
|
||||
out.append(("dfip", f"projects-{kind}-summary", f"/api/v1/projects/{kind}-summary?year={year}"))
|
||||
for r in man.get("project_reports", []):
|
||||
out.append(("rf", f"report-{r['project_id']}-{r['year']}-{r['report_type']}",
|
||||
f"/api/v1/projects/{r['project_id']}/report/{r['year']}/{r['report_type']}"))
|
||||
for pid in dict.fromkeys(r["project_id"] for r in man.get("project_reports", [])):
|
||||
out.append(("rf", f"project-summary-{pid}", f"/api/v1/projects/{pid}/summary"))
|
||||
return out
|
||||
|
||||
|
||||
def strip_nulls(value):
|
||||
"""Как jsonb_strip_nulls: убирает None-поля объектов рекурсивно; None в списках и {} остаются."""
|
||||
if isinstance(value, dict):
|
||||
return {k: strip_nulls(v) for k, v in value.items() if v is not None}
|
||||
if isinstance(value, list):
|
||||
return [strip_nulls(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def strip_response(body: dict) -> dict:
|
||||
"""Копия тела, где strip_nulls применён только к row['data'] строк result."""
|
||||
out = dict(body)
|
||||
out["result"] = [{**row, "data": strip_nulls(row["data"])} if isinstance(row, dict) and "data" in row else row
|
||||
for row in body.get("result") or []]
|
||||
return out
|
||||
|
||||
|
||||
def is_stripped(name: str) -> bool:
|
||||
return name.startswith(("sheet-", "svod1-", "svod2-"))
|
||||
|
||||
|
||||
def first_diff(a, b, path: str = "$") -> str | None:
|
||||
"""Путь до первого отличия с обоими значениями; None при равенстве."""
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
if set(a) != set(b):
|
||||
return f"{path}: ключи {sorted(set(a) ^ set(b))} есть только в одном из ответов"
|
||||
for k in a:
|
||||
d = first_diff(a[k], b[k], f"{path}.{k}")
|
||||
if d:
|
||||
return d
|
||||
return None
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
if len(a) != len(b):
|
||||
return f"{path}: длина {len(a)} != {len(b)}"
|
||||
for i, (x, y) in enumerate(zip(a, b)):
|
||||
d = first_diff(x, y, f"{path}[{i}]")
|
||||
if d:
|
||||
return d
|
||||
return None
|
||||
if type(a) is type(b) and a == b:
|
||||
return None
|
||||
return f"{path}: {a!r} != {b!r}"
|
||||
|
||||
|
||||
def _load(d: Path, name: str):
|
||||
return json.loads(gzip.decompress((d / f"{name}.json.gz").read_bytes()))
|
||||
|
||||
|
||||
def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]:
|
||||
bi = json.loads((before / "index.json").read_text())
|
||||
ai_p = after / "index.json"
|
||||
ai = json.loads(ai_p.read_text()) if ai_p.exists() else {}
|
||||
ok, bad = 0, []
|
||||
for name, st in bi.items():
|
||||
if name not in ai or not (after / f"{name}.json.gz").exists():
|
||||
bad.append(f"{name}: нет в after")
|
||||
continue
|
||||
if st != ai[name]:
|
||||
bad.append(f"{name}: статус {st} != {ai[name]}")
|
||||
continue
|
||||
b, a = _load(before, name), _load(after, name)
|
||||
if mode == "strip-nulls" and is_stripped(name):
|
||||
b = strip_response(b)
|
||||
d = first_diff(b, a)
|
||||
if d:
|
||||
bad.append(f"{name}: {d}")
|
||||
else:
|
||||
ok += 1
|
||||
return ok, bad
|
||||
|
||||
|
||||
def dump(man: dict, api: str, out: Path) -> None:
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
index, tokens = {}, {}
|
||||
reqs = build_requests(man)
|
||||
with httpx.Client(base_url=api, timeout=300) as c:
|
||||
for role in sorted({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"]
|
||||
for role, name, path in reqs:
|
||||
r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"})
|
||||
(out / f"{name}.json.gz").write_bytes(gzip.compress(r.content))
|
||||
index[name] = r.status_code
|
||||
print(name, r.status_code, len(r.content), flush=True)
|
||||
(out / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
d = sub.add_parser("dump")
|
||||
d.add_argument("--scale", type=int, default=1)
|
||||
d.add_argument("--out", required=True)
|
||||
d.add_argument("--api", default="http://localhost:8000")
|
||||
c = sub.add_parser("compare")
|
||||
c.add_argument("before")
|
||||
c.add_argument("after")
|
||||
c.add_argument("--mode", choices=("exact", "strip-nulls"), default="exact")
|
||||
a = ap.parse_args(argv)
|
||||
if a.cmd == "dump":
|
||||
man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text())
|
||||
dump(man, a.api, Path(a.out))
|
||||
return 0
|
||||
ok, bad = compare(Path(a.before), Path(a.after), a.mode)
|
||||
print(f"совпало {ok} из {ok + len(bad)}")
|
||||
for line in bad:
|
||||
print(line)
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
perf/tests/test_compare_responses.py
Normal file
94
perf/tests/test_compare_responses.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Самопроверка compare_responses (без стенда)."""
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))
|
||||
|
||||
import compare_responses as cr # noqa: E402
|
||||
|
||||
|
||||
def test_strip_nulls_pg_semantics():
|
||||
v = {"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}}
|
||||
assert cr.strip_nulls(v) == {"b": {}, "d": [None, {"f": 1}], "g": {}}
|
||||
|
||||
|
||||
def test_strip_response_keeps_wrapper_and_sort_order():
|
||||
body = {"success": True, "message": None, "count": 1,
|
||||
"result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}]}
|
||||
out = cr.strip_response(body)
|
||||
assert out["message"] is None
|
||||
assert out["result"][0]["sort_order"] is None
|
||||
assert out["result"][0]["data"] == {"y": 1}
|
||||
assert body["result"][0]["data"] == {"x": None, "y": 1} # исходное не изменено
|
||||
|
||||
|
||||
def test_first_diff_path():
|
||||
a = {"result": [{"data": {"q1": {"m1": 1}}}]}
|
||||
b = {"result": [{"data": {"q1": {"m1": 2}}}]}
|
||||
assert cr.first_diff(a, b).startswith("$.result[0].data.q1.m1")
|
||||
assert cr.first_diff(a, a) is None
|
||||
assert cr.first_diff({"a": [1]}, {"a": [1, 2]}).startswith("$.a")
|
||||
assert cr.first_diff({"a": 1}, {"a": 1, "b": 2}).startswith("$")
|
||||
|
||||
|
||||
def _write(d: Path, name: str, body: dict, status: int = 200):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{name}.json.gz").write_bytes(gzip.compress(json.dumps(body).encode()))
|
||||
idx = d / "index.json"
|
||||
cur = json.loads(idx.read_text()) if idx.exists() else {}
|
||||
cur[name] = status
|
||||
idx.write_text(json.dumps(cur))
|
||||
|
||||
|
||||
def _body(data):
|
||||
return {"success": True, "message": None, "count": 1,
|
||||
"result": [{"row_type": "INPUT", "depth": 1, "sort_order": 1, "data": data}]}
|
||||
|
||||
|
||||
def test_compare_exact_and_strip_modes(tmp_path):
|
||||
b, a = tmp_path / "before", tmp_path / "after"
|
||||
for name in ("sheet-FORM_2-AHR-none-1", "report-5-2026-LIMIT"):
|
||||
_write(b, name, _body({"x": None, "y": 1}))
|
||||
_write(a, name, _body({"y": 1}))
|
||||
n, bad = cr.compare(b, a, "exact")
|
||||
assert n == 0 and len(bad) == 2
|
||||
n, bad = cr.compare(b, a, "strip-nulls")
|
||||
assert n == 1 and len(bad) == 1 and "report-5-2026-LIMIT" in bad[0]
|
||||
|
||||
|
||||
def test_compare_status_mismatch(tmp_path):
|
||||
b, a = tmp_path / "before", tmp_path / "after"
|
||||
_write(b, "svod1-MAIN", _body({"y": 1}), 200)
|
||||
_write(a, "svod1-MAIN", _body({"y": 1}), 500)
|
||||
n, bad = cr.compare(b, a, "exact")
|
||||
assert n == 0 and len(bad) == 1
|
||||
|
||||
|
||||
def test_compare_missing_after(tmp_path):
|
||||
b, a = tmp_path / "before", tmp_path / "after"
|
||||
_write(b, "svod1-MAIN", _body({}))
|
||||
a.mkdir()
|
||||
n, bad = cr.compare(b, a, "exact")
|
||||
assert n == 0 and len(bad) == 1
|
||||
|
||||
|
||||
def test_build_requests_minimal_manifest():
|
||||
man = {"sheets": [
|
||||
{"form_id": 1, "form_type": "FORM_2", "sheet": "AHR", "direction": None, "year": 2026, "url": "", "rows": 1, "owner": "rf"},
|
||||
{"form_id": 2, "form_type": "FORM_1", "sheet": "AHR", "direction": "Support", "year": 2026, "url": "", "rows": 1, "owner": "ssp"},
|
||||
], "project_reports": [{"project_id": 5, "year": 2026, "report_type": "LIMIT", "url": "", "rows": 1}]}
|
||||
reqs = cr.build_requests(man)
|
||||
by = {n: (r, p) for r, n, p in reqs}
|
||||
assert by["sheet-FORM_2-AHR-none-1"] == ("rf", "/api/v1/form/1/sheet/AHR")
|
||||
assert by["sheet-FORM_2-AHR-none-1-sections"] == ("rf", "/api/v1/form/1/sheet/AHR?sections=plan,q1")
|
||||
assert by["sheet-FORM_1-AHR-Support-2"] == ("dfip", "/api/v1/form/2/sheet/AHR?direction=Support")
|
||||
assert sum(n.startswith("svod1-") for n in by) == 5
|
||||
assert sum(n.startswith("svod2-") for n in by) == 2
|
||||
assert by["svod1-MAIN"] == ("dfip", "/api/v1/svod/1/MAIN/2026")
|
||||
assert by["report-5-2026-LIMIT"][0] == "rf"
|
||||
assert by["project-summary-5"] == ("rf", "/api/v1/projects/5/summary")
|
||||
assert {"projects-plan-summary", "projects-fact-summary", "projects-detail-summary"} <= set(by)
|
||||
assert not any("rollup" in n for n in by)
|
||||
assert cr.is_stripped("sheet-x") and cr.is_stripped("svod2-GO") and not cr.is_stripped("report-1")
|
||||
Loading…
x
Reference in New Issue
Block a user