From 258f058cc1bc02c28f1487ddd7da28566cb3c417 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:25:23 +0900 Subject: [PATCH] feat(perf): versioned report envelope around the baseline aggregation (#951 increment 4) app/perf/baseline_report.py build_baseline_report(profile_name, *, repeat, seed=None) wraps the raw aggregate_baseline output in a buyer-facing envelope -- the same pattern app.spec.normalization_report (#947) applies to the normalization assessment: - report_version (REPORT_VERSION="1"), generated_at (UTC ISO-8601); - schema_fingerprint: "sha256:"-prefixed digest of the exact workload snapshot that was measured, so a report ties back to its schema; - summary: {headline, path_count, slowest_path_by_wall_p95} -- names the path with the largest wall-time 95th percentile using names and counts only, never a duration value; - statistics: the full aggregate_baseline output, unmodified. Observations only -- no threshold, no verdict (meta-test enforces the module has no ms/percentile/throughput literal). The schema_fingerprint helper is a local copy of the #947 one until both branches land. 8 tests; mypy app clean; interrogate 100%. Stacked on #1045 (feat/perf-baseline-stats-20260901). Blocked from merge by ContextualWisdomLab/.github#1531. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013SeQS8tSee5QVeyGpJ9SaY --- CHANGELOG.md | 1 + backend/app/perf/baseline_report.py | 128 ++++++++++++++++++ backend/tests/test_perf_baseline_report.py | 86 ++++++++++++ .../performance-and-capacity-profile.md | 19 ++- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 backend/app/perf/baseline_report.py create mode 100644 backend/tests/test_perf_baseline_report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 242c4dd0..cde80f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [BE] ๐Ÿงพ **์„ฑ๋Šฅ baseline ๋ฒ„์ „๋“œ ๋ฆฌํฌํŠธ ์—”๋ฒจ๋กœํ”„ (#951 4์ฐจ ์ฆ๋ถ„)**: `app.perf.baseline_report.build_baseline_report(profile_name, *, repeat, seed=None)`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. `aggregate_baseline` ์›๋ณธ ์ถœ๋ ฅ์„ ๊ตฌ๋งค์ž์šฉ ์—”๋ฒจ๋กœํ”„๋กœ ๊ฐ์Œ‰๋‹ˆ๋‹ค โ€” `report_version`, `generated_at`(UTC ISO-8601), `schema_fingerprint`(๊ณ„์ธกํ•œ workload ์Šค๋ƒ…์ƒท์˜ `"sha256:"` ๋‹ค์ด์ œ์ŠคํŠธ, ๋ฆฌํฌํŠธ๋ฅผ ์Šคํ‚ค๋งˆ์— ์†Œ๊ธ‰ ๊ฒฐ์†), `summary`(`{headline, path_count, slowest_path_by_wall_p95}` โ€” wall p95 ์ตœ๋Œ“๊ฐ’ ๊ฒฝ๋กœ๋ฅผ ์ด๋ฆ„ยท๊ฐœ์ˆ˜๋งŒ์œผ๋กœ ์ง€๋ชฉ, ์†Œ์š”์‹œ๊ฐ„ ๊ฐ’ ์—†์Œ). ์ „์ฒด ํ†ต๊ณ„๋Š” `statistics` ํ‚ค์— ๊ทธ๋Œ€๋กœ ๋ณด์กด. `app.spec.normalization_report`(#947)์˜ ์—”๋ฒจ๋กœํ”„ ํŒจํ„ด์„ ๊ทธ๋Œ€๋กœ ๋”ฐ๋ฅด๋ฉฐ ์ž„๊ณ„๊ฐ’ยทํŒ์ • ์—†์Œ. ํ…Œ์ŠคํŠธ 8์ข…. - [BE] ๐Ÿ“Š **์„ฑ๋Šฅ baseline ๋ฐ˜๋ณต ์ง‘๊ณ„ (#951 3์ฐจ ์ฆ๋ถ„)**: `app.perf.baseline_stats`๊ฐ€ ๊ณ ์ • ์‹œ๋“œ์˜ ๋™์ผ workload์— ๋Œ€ํ•ด `run_baseline`์„ `repeat`ํšŒ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฝ๋กœ๋ณ„ `wall_seconds`ยท`peak_bytes`๋ฅผ minยทmaxยทmeanยทp50ยทp95ยทp99 ๋ถ„ํฌ ์š”์•ฝ์œผ๋กœ ์ถ•์•ฝํ•ฉ๋‹ˆ๋‹ค(`statistics.quantiles`, ํ‘œ์ค€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋งŒ). ์ž„๊ณ„๊ฐ’ยทํ•ฉ๊ฒฉ ํŒ์ • ์—†์Œ. `python -m app.perf.baseline_stats --profile small --repeat 5 [--json]` CLI, `repeat < 1`์€ `ValueError`, ์ทจ์†Œ ์‹œ ๋ถ€๋ถ„ ์ง‘๊ณ„๋ฅผ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. - [BE] ๐Ÿ“ **์ธก์ • ๊ธฐ๋ฐ˜ ์„ฑ๋Šฅ baseline ํ•˜๋„ค์Šค (#951 2์ฐจ ์ฆ๋ถ„)**: `app.perf.baseline`์ด ์ƒ์„ฑ๋œ workload ์Šค๋ƒ…์ƒท์— ๋Œ€ํ•ด ์ˆœ์ˆ˜(๋ถ€์ˆ˜ํšจ๊ณผ ์—†๋Š”) ์ฒ˜๋ฆฌ ๊ฒฝ๋กœ โ€” canonical ํ•ด์‹œ, JSON ์™•๋ณต, self-diff, PostgreSQL/Snowflake DDL export, ๋ฐ์ดํ„ฐ ๋”•์…”๋„ˆ๋ฆฌ Markdown โ€” ๋ฅผ ๊ณ„์ธกํ•ด ๊ฒฝ๋กœ๋ณ„ `wall_seconds`ยท`peak_bytes`ยท`result_size_bytes`๋งŒ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ์ž„๊ณ„๊ฐ’ยทํ•ฉ๊ฒฉ ํŒ์ • ์—†์Œ(์šฉ๋Ÿ‰ ๋ชฉํ‘œ๋Š” ์ด ํ•˜๋„ค์Šค์˜ ์ธก์ •๊ฐ’์œผ๋กœ ์‚ฐ์ถœ). `python -m app.perf.baseline --profile small --json` CLI ํฌํ•จ, ์ทจ์†Œ ์‹œ `tracemalloc`์„ ์ •๋ฆฌํ•˜๊ณ  ๋ถ€๋ถ„ ๋ฆฌํฌํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. - [BE] ๐Ÿ“ˆ **์„ฑ๋Šฅยท์šฉ๋Ÿ‰ ํ”„๋กœํŒŒ์ผ (1์ฐจ ์ฆ๋ถ„ โ€” ์›Œํฌ๋กœ๋“œ ์ƒ์„ฑ๊ธฐ)**: `app.perf.workload_profiles`์— ๊ฒฐ์ •๋ก ์ ยท์ต๋ช… ์Šคํ‚ค๋งˆ ์Šค๋ƒ…์ƒท ์ƒ์„ฑ๊ธฐ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. `small`/`medium`/`large` ํ”„๋กœํŒŒ์ผ์ด ์ด์Šˆ #951 ํ‘œ์˜ ์Šคํ‚ค๋งˆยทrelationยท์ปฌ๋ŸผยทFKยท์ธ๋ฑ์Šค ๊ฐœ์ˆ˜๋ฅผ ์ •ํ™•ํžˆ ๋งž์ถ”๋ฉฐ, ์‹œ๋“œ ๊ณ ์ • ์‹œ ๋ฐ”์ดํŠธ ๋‹จ์œ„๋กœ ์žฌํ˜„๋ฉ๋‹ˆ๋‹ค. ํŽธํ–ฅ ์ผ€์ด์Šค๋„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค โ€” ๋‹จ์ผ 5,000์ปฌ๋Ÿผ relation, ๋ฐ€์ง‘ FK ํด๋Ÿฌ์Šคํ„ฐ, ๊นŠ์€ ์ข…์† ์ฒด์ธ, ๋ถ„๋ฆฌ๋œ ์ปดํฌ๋„ŒํŠธ, ๋‹ค๊ตญ์–ดยท๋”ฐ์˜ดํ‘œ ์‹๋ณ„์ž + ๋Œ€ํ˜• ์ฝ”๋ฉ˜ํŠธ, RANGE ํŒŒํ‹ฐ์…˜ ๊ณ„์ธต. ์‹ค์ œ ์ธ๋ช…ยท๊ธฐ๊ด€๋ช…ยท์šด์˜ ๋ฐ์ดํ„ฐ ๊ฐ’ ์—†์Œ. ์ง€์—ฐยท์ฒ˜๋ฆฌ๋Ÿ‰ยท๋ฉ”๋ชจ๋ฆฌ ์ž„๊ณ„๊ฐ’์€ ์ด ๋ชจ๋“ˆ์— ๋„ฃ์ง€ ์•Š์œผ๋ฉฐ(๊ณ„์ธก๋œ baseline์—์„œ ์‚ฐ์ถœ), `docs/doctoring/performance-and-capacity-profile.md`์— ๊ณ„์•ฝ๊ณผ ํ›„์† ์ฆ๋ถ„(baseline ํ•˜๋„ค์Šคยท`docs/PERFORMANCE.md`ยท๋ฒค์น˜ ์›Œํฌํ”Œ๋กœยทRust ๊ฒฐ์ • ๊ฒŒ์ดํŠธ)์„ ๊ธฐ๋กํ–ˆ์Šต๋‹ˆ๋‹ค. diff --git a/backend/app/perf/baseline_report.py b/backend/app/perf/baseline_report.py new file mode 100644 index 00000000..59ced22b --- /dev/null +++ b/backend/app/perf/baseline_report.py @@ -0,0 +1,128 @@ +"""Versioned report envelope for the measured baseline (issue #951). + +:mod:`app.perf.baseline_stats` produces the per-path distribution summary +from repeated baseline runs. This module wraps that raw statistics block in +a buyer-facing envelope: a stable schema fingerprint of the workload it +measured, a generation timestamp, a contract version, and a plain-language +summary an engineer can read without opening the JSON. + +The envelope is additive -- the full :func:`aggregate_baseline` output is +preserved verbatim under ``statistics`` -- so a downstream consumer can +ignore the envelope entirely. + +Like everything under :mod:`app.perf`, this records observations only. It +carries **no latency, throughput, or memory threshold** and makes no +pass/fail judgement; capacity targets are set from measured baseline runs +and never invented here. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +from app.perf.baseline_stats import aggregate_baseline +from app.perf.workload_profiles import generate_workload_snapshot + +#: Report envelope contract version. Distinct from the statistics block's own +#: fields; bump when the envelope shape changes. +REPORT_VERSION = "1" + + +def _schema_fingerprint(snapshot: dict[str, Any] | None) -> str: + """Return a stable ``"sha256:"``-prefixed fingerprint of a snapshot. + + The snapshot is serialized with sorted keys and a string fallback for + non-JSON values, so the same schema always yields the same fingerprint + regardless of dict ordering. This matches + ``app.spec.normalization_report.schema_fingerprint``; the two should be + unified into one shared helper once both land on ``main``. + """ + canonical = json.dumps( + snapshot or {}, sort_keys=True, default=str, separators=(",", ":") + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _summarize(statistics: dict[str, Any]) -> dict[str, Any]: + """Build the plain-language summary block from an ``aggregate_baseline`` result. + + Picks the path with the largest 95th-percentile wall time as the one an + engineer should look at first. Reports names and counts only -- never a + duration value, so the summary carries no implied threshold. + """ + paths: dict[str, Any] = statistics.get("paths", {}) + path_names = sorted(paths) + if path_names: + slowest = max( + path_names, key=lambda name: paths[name]["wall_seconds"]["p95"] + ) + else: + slowest = "" + + profile = statistics.get("profile", "?") + repeat = statistics.get("repeat", 0) + if not path_names: + headline = f"{profile} profile: no measured paths." + else: + headline = ( + f"{profile} profile, {len(path_names)} measured paths over " + f"{repeat} run(s); slowest by wall-time 95th percentile is " + f"{slowest}." + ) + return { + "headline": headline, + "path_count": len(path_names), + "slowest_path_by_wall_p95": slowest, + } + + +def build_baseline_report( + profile_name: str, *, repeat: int, seed: int | None = None +) -> dict[str, Any]: + """Run the repeat-baseline aggregation and wrap it in a versioned envelope. + + Args: + profile_name: One of + :func:`app.perf.workload_profiles.list_profiles`. + repeat: Number of baseline runs to aggregate (forwarded to + :func:`app.perf.baseline_stats.aggregate_baseline`; must be >= 1). + seed: Optional PRNG seed forwarded to both the aggregation and the + fingerprinted workload snapshot, so the fingerprint identifies + exactly the schema that was measured. + + Returns: + A dict with: + + ``report_version`` + :data:`REPORT_VERSION`. + ``generated_at`` + UTC ISO-8601 timestamp of this envelope. + ``schema_fingerprint`` + ``"sha256:"``-prefixed fingerprint of the generated workload + snapshot that was measured. + ``summary`` + ``{headline, path_count, slowest_path_by_wall_p95}`` -- names and + counts only, no duration values. + ``statistics`` + The full :func:`aggregate_baseline` output, unmodified. + + The report contains only observations; it has no thresholds and no + verdict. + + Raises: + ValueError: Propagated from :func:`aggregate_baseline` if ``repeat`` + is less than 1. + KeyError: If ``profile_name`` is not a known profile. + """ + statistics = aggregate_baseline(profile_name, repeat=repeat, seed=seed) + snapshot = generate_workload_snapshot(profile_name, seed=seed) + return { + "report_version": REPORT_VERSION, + "generated_at": datetime.now(timezone.utc).isoformat(), + "schema_fingerprint": _schema_fingerprint(snapshot), + "summary": _summarize(statistics), + "statistics": statistics, + } diff --git a/backend/tests/test_perf_baseline_report.py b/backend/tests/test_perf_baseline_report.py new file mode 100644 index 00000000..e6237fa6 --- /dev/null +++ b/backend/tests/test_perf_baseline_report.py @@ -0,0 +1,86 @@ +"""Tests for :mod:`app.perf.baseline_report`. + +The envelope must wrap the raw statistics additively, fingerprint the exact +workload it measured, pick a real slowest path, stay deterministic under a +fixed seed, and carry no invented performance threshold. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path + +import pytest + +from app.perf.baseline_report import REPORT_VERSION, build_baseline_report + +_EXPECTED_PATHS = { + "canonical_hash", + "json_round_trip", + "schema_self_diff", + "ddl_export_postgresql", + "ddl_export_snowflake", + "data_dictionary_markdown", +} + + +def test_report_has_the_full_envelope_and_preserves_statistics() -> None: + report = build_baseline_report("small", repeat=2) + assert report["report_version"] == REPORT_VERSION + assert set(report) == { + "report_version", + "generated_at", + "schema_fingerprint", + "summary", + "statistics", + } + assert set(report["statistics"]["paths"]) == _EXPECTED_PATHS + assert report["statistics"]["repeat"] == 2 + + +def test_schema_fingerprint_is_sha256_prefixed_and_seed_stable() -> None: + a = build_baseline_report("small", repeat=1, seed=11) + b = build_baseline_report("small", repeat=1, seed=11) + assert a["schema_fingerprint"].startswith("sha256:") + assert a["schema_fingerprint"] == b["schema_fingerprint"] + + +def test_different_seeds_fingerprint_differently() -> None: + a = build_baseline_report("small", repeat=1, seed=1) + b = build_baseline_report("small", repeat=1, seed=2) + assert a["schema_fingerprint"] != b["schema_fingerprint"] + + +def test_summary_names_a_real_slowest_path() -> None: + report = build_baseline_report("small", repeat=2) + summary = report["summary"] + assert summary["path_count"] == 6 + assert summary["slowest_path_by_wall_p95"] in _EXPECTED_PATHS + assert "small" in summary["headline"] + + +def test_generated_at_is_timezone_aware() -> None: + report = build_baseline_report("small", repeat=1) + assert datetime.fromisoformat(report["generated_at"]).tzinfo is not None + + +def test_repeat_below_one_propagates_value_error() -> None: + with pytest.raises(ValueError): + build_baseline_report("small", repeat=0) + + +def test_unknown_profile_raises_key_error() -> None: + with pytest.raises(KeyError): + build_baseline_report("enterprise", repeat=1) + + +def test_module_states_targets_are_measured_and_invents_no_threshold() -> None: + raw = Path("app/perf/baseline_report.py").read_text(encoding="utf-8").lower() + prose = re.sub(r"\s+", " ", raw) + assert "measured baseline runs and never invented" in prose + assert "no latency, throughput, or memory threshold" in prose + assert "makes no pass/fail judgement" in prose + assert re.search(r"\b\d+(\.\d+)?\s*(ms|milliseconds|seconds)\b", raw) is None + assert re.search(r"p9[59]\s*[<>=:]", raw) is None + assert re.search(r"\b\d+\s*(rps|qps|req/s)\b", raw) is None diff --git a/docs/doctoring/performance-and-capacity-profile.md b/docs/doctoring/performance-and-capacity-profile.md index 591f2a25..afda371f 100644 --- a/docs/doctoring/performance-and-capacity-profile.md +++ b/docs/doctoring/performance-and-capacity-profile.md @@ -1,7 +1,8 @@ # Performance & capacity profile Status: **in progress** โ€” increments 1 (workload generators), 2 (measured -baseline harness), and 3 (repeat-run aggregation) landed. Tracks issue +baseline harness), 3 (repeat-run aggregation), and 4 (versioned report +envelope) landed. Tracks issue [#951](https://github.com/ContextualWisdomLab/pg-erd-cloud/issues/951) ("[Performance Gap] Establish large-schema SLOs, workload benchmarks, and a measured Rust boundary"). @@ -89,6 +90,22 @@ Still observations only: no threshold, no verdict. The percentile targets a capacity profile eventually publishes are set from measured baseline runs and never invented here. +## Decision โ€” versioned report envelope (this increment) + +`app/perf/baseline_report.py` `build_baseline_report(profile_name, *, +repeat, seed=None)` wraps the raw `aggregate_baseline` output in a +buyer-facing envelope, mirroring what `app.spec.normalization_report` +(#947) does for the normalization assessment: `report_version`, +`generated_at` (UTC ISO-8601), a `schema_fingerprint` (`"sha256:"`-prefixed +digest of the exact workload snapshot that was measured, so a report can be +tied back to its schema), and a `summary` block โ€” +`{headline, path_count, slowest_path_by_wall_p95}` โ€” that names the path an +engineer should look at first (largest wall-time 95th percentile) using +**names and counts only, never a duration value**. The full statistics +block is preserved verbatim under `statistics`. The `schema_fingerprint` +helper is a local copy of `app.spec.normalization_report.schema_fingerprint` +for now (the two branches are unmerged); unify them once both land. + ## Deferred (later increments on #951) - **Baseline harness โ€” remaining paths** โ€” DBML/Mermaid/Prisma/spec export,