Skip to content

Commit c126227

Browse files
test: add summary-cache pytest-benchmark suite with CI regression gate
1 parent 793b6f2 commit c126227

7 files changed

Lines changed: 366 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,40 @@ jobs:
213213
--verbose \
214214
--redact \
215215
--exit-code 1
216+
217+
# ── Performance benchmarks: summary cache (issue #7) ───────────────────────
218+
benchmarks:
219+
name: Performance benchmarks (gated)
220+
runs-on: ubuntu-latest
221+
steps:
222+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
223+
with:
224+
persist-credentials: false
225+
226+
- name: Set up Python
227+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
228+
with:
229+
python-version: "3.12"
230+
231+
- name: Install runtime + benchmark dependencies
232+
run: |
233+
python -m pip install --upgrade pip
234+
python -m pip install -r requirements-lock.txt
235+
python -m pip install 'pytest>=8,<9' 'pytest-benchmark>=4,<5'
236+
237+
- name: Run summary-cache benchmarks
238+
run: >
239+
python -m pytest tests/benchmarks/
240+
--benchmark-only
241+
--benchmark-json=benchmark-results.json
242+
--benchmark-columns=min,max,mean,stddev,rounds
243+
-o addopts=
244+
245+
- name: Regression gate
246+
run: python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
247+
248+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
249+
if: always()
250+
with:
251+
name: benchmark-results
252+
path: benchmark-results.json

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,5 @@ Thumbs.db
4444
htmlcov/
4545
coverage.xml
4646
.hypothesis/
47+
benchmark-results.json
48+
benchmarks/_raw.json

benchmarks/baselines.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"_note": "Gated means from local reference run with 1.5x slack (Windows dev host). Refresh from ubuntu-latest CI artifact after first green benchmark job.",
3+
"updated": "2026-06-25T00:00:00Z",
4+
"machine": "Windows",
5+
"groups": {
6+
"summary-cache": {
7+
"test_summary_cache_hit": 8.91e-05,
8+
"test_summary_cache_miss": 8.13e-05,
9+
"test_fingerprint_workspace_entries[10]": 0.001708,
10+
"test_fingerprint_workspace_entries[50]": 0.005457,
11+
"test_fingerprint_workspace_entries[200]": 0.01715,
12+
"test_summary_cache_round_trip": 0.001667
13+
}
14+
}
15+
}

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,18 @@ desktop = ["pywebview>=5.0,<6"]
3131
# Development tooling: testing + type checking.
3232
dev = [
3333
"pytest>=8,<9",
34+
"pytest-benchmark>=4,<5",
3435
"mypy>=1.10,<2",
3536
"hypothesis>=6.100,<7",
3637
]
3738

39+
[tool.pytest.ini_options]
40+
addopts = "--benchmark-skip"
41+
testpaths = ["tests"]
42+
markers = [
43+
"benchmark: performance benchmarks (pytest-benchmark)",
44+
]
45+
3846
[project.scripts]
3947
# Primary CLI: export Cursor chat histories to Markdown / zip.
4048
# Usage: cursor-chat-export [--since all|last] [--out DIR] [--no-zip] [--help]
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Compare pytest-benchmark JSON output against stored baselines."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import sys
8+
from pathlib import Path
9+
10+
THRESHOLD = 1.20
11+
12+
13+
class BenchmarkDataError(ValueError):
14+
"""Raised when benchmark JSON input is malformed or missing required fields."""
15+
16+
17+
def load_results(results_path: str | Path) -> dict[str, float]:
18+
path = Path(results_path)
19+
try:
20+
data = json.loads(path.read_text(encoding="utf-8"))
21+
except OSError as exc:
22+
raise BenchmarkDataError(f"cannot read {path}: {exc}") from exc
23+
except json.JSONDecodeError as exc:
24+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
25+
try:
26+
benchmarks = data["benchmarks"]
27+
except (KeyError, TypeError) as exc:
28+
raise BenchmarkDataError(f"{path} missing top-level 'benchmarks' array") from exc
29+
if not isinstance(benchmarks, list):
30+
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
31+
32+
results: dict[str, float] = {}
33+
for index, entry in enumerate(benchmarks):
34+
if not isinstance(entry, dict):
35+
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
36+
try:
37+
name = entry["name"]
38+
mean = float(entry["stats"]["mean"])
39+
except (KeyError, TypeError, ValueError) as exc:
40+
raise BenchmarkDataError(
41+
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
42+
) from exc
43+
name = str(name)
44+
if name in results:
45+
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r}")
46+
results[name] = mean
47+
return results
48+
49+
50+
def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
51+
path = Path(baselines_path)
52+
try:
53+
data = json.loads(path.read_text(encoding="utf-8"))
54+
except OSError as exc:
55+
raise BenchmarkDataError(f"cannot read {path}: {exc}") from exc
56+
except json.JSONDecodeError as exc:
57+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
58+
if not isinstance(data, dict):
59+
raise BenchmarkDataError(f"{path} root value must be an object")
60+
61+
if "groups" not in data:
62+
raise BenchmarkDataError(f"{path} missing required 'groups' key")
63+
groups = data["groups"]
64+
if not isinstance(groups, dict):
65+
raise BenchmarkDataError(f"{path} 'groups' must be an object")
66+
67+
means: dict[str, float] = {}
68+
for group_name, value in groups.items():
69+
if not isinstance(value, dict):
70+
continue
71+
for name, mean in value.items():
72+
name = str(name)
73+
if name in means:
74+
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r} across groups")
75+
try:
76+
means[name] = float(mean)
77+
except (TypeError, ValueError) as exc:
78+
raise BenchmarkDataError(
79+
f"{path} groups[{group_name!r}][{name!r}] is not a numeric mean"
80+
) from exc
81+
return means
82+
83+
84+
def check_regression(
85+
results_path: str | Path,
86+
baselines_path: str | Path,
87+
*,
88+
threshold: float = THRESHOLD,
89+
) -> int:
90+
"""Return 0 when within threshold; 1 when any gated benchmark regresses."""
91+
flat = load_results(results_path)
92+
baseline_means = load_baseline_means(baselines_path)
93+
94+
failures: list[str] = []
95+
for name, base in baseline_means.items():
96+
cur = flat.get(name)
97+
if cur is None:
98+
print(f"WARN: no current result for baseline {name!r}; skipping")
99+
continue
100+
if base == 0:
101+
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
102+
continue
103+
ratio = cur / base
104+
tag = "FAIL" if ratio > threshold else "ok"
105+
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
106+
if ratio > threshold:
107+
failures.append(name)
108+
109+
for name in flat:
110+
if name not in baseline_means:
111+
print(f"WARN: {name!r} has no baseline yet; not gated")
112+
113+
if failures:
114+
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {threshold:.0%}")
115+
return 1
116+
return 0
117+
118+
119+
def main(argv: list[str] | None = None) -> int:
120+
parser = argparse.ArgumentParser(description=__doc__)
121+
parser.add_argument("results_path", help="pytest-benchmark --benchmark-json output")
122+
parser.add_argument("baselines_path", help="path to benchmarks/baselines.json")
123+
parser.add_argument(
124+
"--threshold",
125+
type=float,
126+
default=THRESHOLD,
127+
help="fail when current mean exceeds baseline by more than this ratio (default: 1.20)",
128+
)
129+
args = parser.parse_args(argv)
130+
try:
131+
return check_regression(
132+
args.results_path,
133+
args.baselines_path,
134+
threshold=args.threshold,
135+
)
136+
except BenchmarkDataError as exc:
137+
print(f"ERROR: {exc}", file=sys.stderr)
138+
return 2
139+
140+
141+
if __name__ == "__main__":
142+
sys.exit(main())

tests/benchmarks/conftest.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Synthetic workspace trees for summary-cache performance benchmarks."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import pytest
11+
12+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13+
if REPO_ROOT not in sys.path:
14+
sys.path.insert(0, REPO_ROOT)
15+
16+
from services import summary_cache # noqa: E402
17+
from services.summary_cache import fingerprint_workspace_storage # noqa: E402
18+
19+
20+
def make_workspace_entries(workspace_root: Path, count: int) -> list[dict[str, Any]]:
21+
"""Build *count* synthetic workspace entries with on-disk state files."""
22+
entries: list[dict[str, Any]] = []
23+
for i in range(count):
24+
name = f"ws_{i:04d}"
25+
entry_dir = workspace_root / name
26+
entry_dir.mkdir(parents=True, exist_ok=True)
27+
(entry_dir / "state.vscdb").write_bytes(b"bench")
28+
workspace_json = entry_dir / "workspace.json"
29+
workspace_json.write_text('{"folder": "/bench"}', encoding="utf-8")
30+
entries.append(
31+
{
32+
"name": name,
33+
"workspaceJsonPath": str(workspace_json),
34+
}
35+
)
36+
return entries
37+
38+
39+
@pytest.fixture
40+
def summary_cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
41+
"""Redirect summary-cache files to an isolated temp directory."""
42+
cache_dir = tmp_path / "cache"
43+
cache_dir.mkdir()
44+
monkeypatch.setattr(summary_cache, "CACHE_DIR", cache_dir)
45+
monkeypatch.setattr(summary_cache, "PROJECTS_CACHE_FILE", cache_dir / "projects.json")
46+
monkeypatch.setattr(
47+
summary_cache,
48+
"COMPOSER_MAP_CACHE_FILE",
49+
cache_dir / "composer-id-to-ws.json",
50+
)
51+
return cache_dir
52+
53+
54+
@pytest.fixture
55+
def sample_projects() -> list[dict[str, Any]]:
56+
return [
57+
{
58+
"id": "ws_0000",
59+
"name": "Bench Project",
60+
"conversationCount": 3,
61+
"lastModified": "2026-06-24T00:00:00Z",
62+
}
63+
]
64+
65+
66+
@pytest.fixture
67+
def synthetic_workspace(tmp_path: Path, request: pytest.FixtureRequest) -> tuple[str, list[dict[str, Any]]]:
68+
"""Workspace path + entries. Parametrize via indirect ``workspace_entry_count``."""
69+
count = getattr(request, "param", 10)
70+
workspace_root = tmp_path / "workspaceStorage"
71+
workspace_root.mkdir()
72+
entries = make_workspace_entries(workspace_root, count)
73+
return str(workspace_root), entries
74+
75+
76+
@pytest.fixture
77+
def workspace_fingerprint(synthetic_workspace: tuple[str, list[dict[str, Any]]]) -> dict[str, Any]:
78+
workspace_path, entries = synthetic_workspace
79+
return fingerprint_workspace_storage(
80+
workspace_path,
81+
entries,
82+
global_db_path=None,
83+
rules=[],
84+
)
85+
86+
87+
@pytest.fixture
88+
def stale_fingerprint(workspace_fingerprint: dict[str, Any]) -> dict[str, Any]:
89+
return {**workspace_fingerprint, "global_db_mtime_ns": 9_999_999_999}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""pytest-benchmark coverage for services/summary_cache.py hot paths."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import pytest
9+
10+
from services.summary_cache import (
11+
fingerprint_workspace_storage,
12+
get_cached_projects,
13+
set_cached_projects,
14+
)
15+
16+
@pytest.mark.benchmark(group="summary-cache")
17+
def test_summary_cache_hit(
18+
benchmark,
19+
summary_cache_dir: Path,
20+
workspace_fingerprint: dict[str, Any],
21+
sample_projects: list[dict[str, Any]],
22+
) -> None:
23+
set_cached_projects(workspace_fingerprint, sample_projects, [])
24+
benchmark(get_cached_projects, workspace_fingerprint)
25+
26+
27+
@pytest.mark.benchmark(group="summary-cache")
28+
def test_summary_cache_miss(
29+
benchmark,
30+
summary_cache_dir: Path,
31+
workspace_fingerprint: dict[str, Any],
32+
stale_fingerprint: dict[str, Any],
33+
sample_projects: list[dict[str, Any]],
34+
) -> None:
35+
set_cached_projects(workspace_fingerprint, sample_projects, [])
36+
benchmark(get_cached_projects, stale_fingerprint)
37+
38+
39+
@pytest.mark.benchmark(group="summary-cache")
40+
@pytest.mark.parametrize(
41+
"synthetic_workspace",
42+
[10, 50, 200],
43+
indirect=True,
44+
)
45+
def test_fingerprint_workspace_entries(
46+
benchmark,
47+
synthetic_workspace: tuple[str, list[dict[str, Any]]],
48+
) -> None:
49+
workspace_path, entries = synthetic_workspace
50+
benchmark(
51+
fingerprint_workspace_storage,
52+
workspace_path,
53+
entries,
54+
global_db_path=None,
55+
rules=[],
56+
)
57+
58+
59+
@pytest.mark.benchmark(group="summary-cache")
60+
def test_summary_cache_round_trip(
61+
benchmark,
62+
summary_cache_dir: Path,
63+
workspace_fingerprint: dict[str, Any],
64+
sample_projects: list[dict[str, Any]],
65+
) -> None:
66+
fp = workspace_fingerprint
67+
projects = sample_projects
68+
69+
def _run() -> None:
70+
set_cached_projects(fp, projects, [])
71+
get_cached_projects(fp)
72+
73+
benchmark(_run)

0 commit comments

Comments
 (0)