Skip to content

Commit 0ec72c0

Browse files
committed
Add scripts/benchmark_api.py for API regression timing
Hits each `/api/*` endpoint a few times against a local server and optionally diffs against a saved baseline.
1 parent 2d36ec0 commit 0ec72c0

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

scripts/benchmark_api.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env -S uv run
2+
# /// script
3+
# requires-python = ">=3.11"
4+
# dependencies = ["requests", "click"]
5+
# ///
6+
"""
7+
Benchmark disk-tree API endpoints to catch performance regressions.
8+
9+
Usage:
10+
./scripts/benchmark_api.py # Run all benchmarks
11+
./scripts/benchmark_api.py --endpoint scan # Run specific endpoint
12+
./scripts/benchmark_api.py --save # Save results as baseline
13+
./scripts/benchmark_api.py --compare # Compare against baseline
14+
"""
15+
16+
import json
17+
import sys
18+
import time
19+
from pathlib import Path
20+
from statistics import mean, stdev
21+
22+
import click
23+
import requests
24+
25+
BASE_URL = "http://localhost:5001"
26+
BASELINE_FILE = Path(__file__).parent.parent / "tmp" / "benchmark_baseline.json"
27+
28+
# Thresholds (seconds) - fail if exceeded
29+
THRESHOLDS = {
30+
"scans_list": 0.5,
31+
"scan_root": 1.0,
32+
"scan_subdir": 1.0,
33+
"scan_deep_subdir": 1.0,
34+
"history": 1.0,
35+
"compare": 1.0,
36+
}
37+
38+
39+
def time_request(url: str, params: dict | None = None, runs: int = 3) -> dict:
40+
"""Time a request multiple times and return stats."""
41+
times = []
42+
last_status = None
43+
last_error = None
44+
45+
for i in range(runs):
46+
try:
47+
start = time.perf_counter()
48+
resp = requests.get(url, params=params, timeout=30)
49+
elapsed = time.perf_counter() - start
50+
times.append(elapsed)
51+
last_status = resp.status_code
52+
if resp.status_code != 200:
53+
last_error = resp.text[:200]
54+
except Exception as e:
55+
last_error = str(e)
56+
57+
if not times:
58+
return {"error": last_error, "times": [], "mean": None, "status": last_status}
59+
60+
return {
61+
"times": times,
62+
"mean": mean(times),
63+
"stdev": stdev(times) if len(times) > 1 else 0,
64+
"min": min(times),
65+
"max": max(times),
66+
"status": last_status,
67+
"error": last_error if last_status != 200 else None,
68+
}
69+
70+
71+
def get_test_params(db_path: str | None = None) -> dict:
72+
"""Get test parameters from the database."""
73+
# Try to get scan IDs and paths from the API
74+
try:
75+
resp = requests.get(f"{BASE_URL}/api/scans", timeout=10)
76+
if resp.status_code == 200:
77+
scans = resp.json()
78+
if scans:
79+
# Find a local scan (not S3)
80+
local_scans = [s for s in scans if not s["path"].startswith("s3://")]
81+
if local_scans:
82+
# Get the largest scan for more realistic testing
83+
largest = max(local_scans, key=lambda s: s.get("n_desc") or 0)
84+
scan_path = largest["path"]
85+
86+
# Find two scans of the same path for compare test
87+
resp2 = requests.get(
88+
f"{BASE_URL}/api/scans/history",
89+
params={"uri": scan_path},
90+
timeout=10,
91+
)
92+
if resp2.status_code == 200:
93+
history = resp2.json()
94+
if len(history) >= 2:
95+
return {
96+
"scan_path": scan_path,
97+
"scan1_id": history[1]["id"],
98+
"scan2_id": history[0]["id"],
99+
"subdir": f"{scan_path}/Library" if "Users" in scan_path else scan_path,
100+
"deep_subdir": f"{scan_path}/Library/Application Support" if "Users" in scan_path else scan_path,
101+
}
102+
except Exception as e:
103+
print(f"Warning: Could not auto-detect test params: {e}", file=sys.stderr)
104+
105+
# Fallback defaults
106+
return {
107+
"scan_path": "/Users/ryan",
108+
"scan1_id": 56,
109+
"scan2_id": 59,
110+
"subdir": "/Users/ryan/Library",
111+
"deep_subdir": "/Users/ryan/Library/Application Support",
112+
}
113+
114+
115+
def run_benchmarks(params: dict, runs: int = 3) -> dict:
116+
"""Run all benchmark tests."""
117+
results = {}
118+
119+
# 1. List all scans
120+
print(" scans_list: ", end="", flush=True)
121+
results["scans_list"] = time_request(f"{BASE_URL}/api/scans", runs=runs)
122+
print(f"{results['scans_list']['mean']:.3f}s" if results['scans_list']['mean'] else "FAILED")
123+
124+
# 2. Get scan at root path
125+
print(" scan_root: ", end="", flush=True)
126+
results["scan_root"] = time_request(
127+
f"{BASE_URL}/api/scan",
128+
params={"uri": params["scan_path"]},
129+
runs=runs,
130+
)
131+
print(f"{results['scan_root']['mean']:.3f}s" if results['scan_root']['mean'] else "FAILED")
132+
133+
# 3. Get scan at subdir (uses ancestor scan)
134+
print(" scan_subdir: ", end="", flush=True)
135+
results["scan_subdir"] = time_request(
136+
f"{BASE_URL}/api/scan",
137+
params={"uri": params["subdir"]},
138+
runs=runs,
139+
)
140+
print(f"{results['scan_subdir']['mean']:.3f}s" if results['scan_subdir']['mean'] else "FAILED")
141+
142+
# 4. Get scan at deep subdir
143+
print(" scan_deep_subdir: ", end="", flush=True)
144+
results["scan_deep_subdir"] = time_request(
145+
f"{BASE_URL}/api/scan",
146+
params={"uri": params["deep_subdir"]},
147+
runs=runs,
148+
)
149+
print(f"{results['scan_deep_subdir']['mean']:.3f}s" if results['scan_deep_subdir']['mean'] else "FAILED")
150+
151+
# 5. Get scan history (includes ancestor scans)
152+
print(" history: ", end="", flush=True)
153+
results["history"] = time_request(
154+
f"{BASE_URL}/api/scans/history",
155+
params={"uri": params["deep_subdir"]},
156+
runs=runs,
157+
)
158+
print(f"{results['history']['mean']:.3f}s" if results['history']['mean'] else "FAILED")
159+
160+
# 6. Compare two scans
161+
print(" compare: ", end="", flush=True)
162+
results["compare"] = time_request(
163+
f"{BASE_URL}/api/compare",
164+
params={
165+
"uri": params["deep_subdir"],
166+
"scan1": params["scan1_id"],
167+
"scan2": params["scan2_id"],
168+
},
169+
runs=runs,
170+
)
171+
print(f"{results['compare']['mean']:.3f}s" if results['compare']['mean'] else "FAILED")
172+
173+
return results
174+
175+
176+
def check_thresholds(results: dict) -> list[str]:
177+
"""Check if any results exceed thresholds."""
178+
failures = []
179+
for name, threshold in THRESHOLDS.items():
180+
if name in results and results[name].get("mean"):
181+
if results[name]["mean"] > threshold:
182+
failures.append(
183+
f"{name}: {results[name]['mean']:.3f}s > {threshold}s threshold"
184+
)
185+
return failures
186+
187+
188+
def compare_to_baseline(results: dict, baseline: dict) -> list[str]:
189+
"""Compare results to baseline and report regressions."""
190+
regressions = []
191+
for name in results:
192+
if name in baseline and results[name].get("mean") and baseline[name].get("mean"):
193+
current = results[name]["mean"]
194+
base = baseline[name]["mean"]
195+
if current > base * 1.5: # 50% regression threshold
196+
regressions.append(
197+
f"{name}: {current:.3f}s vs baseline {base:.3f}s ({current/base:.1f}x slower)"
198+
)
199+
return regressions
200+
201+
202+
@click.command()
203+
@click.option("-e", "--endpoint", help="Run specific endpoint benchmark only")
204+
@click.option("-r", "--runs", default=3, help="Number of runs per benchmark")
205+
@click.option("-s", "--save", is_flag=True, help="Save results as new baseline")
206+
@click.option("-c", "--compare", is_flag=True, help="Compare against baseline")
207+
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
208+
def main(endpoint: str | None, runs: int, save: bool, compare: bool, verbose: bool):
209+
"""Benchmark disk-tree API endpoints."""
210+
print("Disk-tree API Benchmark")
211+
print("=" * 40)
212+
213+
# Check server is running
214+
try:
215+
requests.get(f"{BASE_URL}/api/scans", timeout=2)
216+
except Exception:
217+
print(f"Error: Server not running at {BASE_URL}", file=sys.stderr)
218+
print("Start with: disk-tree-server", file=sys.stderr)
219+
sys.exit(1)
220+
221+
# Get test parameters
222+
print("\nDetecting test parameters...")
223+
params = get_test_params()
224+
if verbose:
225+
print(f" scan_path: {params['scan_path']}")
226+
print(f" scan IDs: {params['scan1_id']}, {params['scan2_id']}")
227+
print(f" subdir: {params['subdir']}")
228+
print(f" deep_subdir: {params['deep_subdir']}")
229+
230+
# Run benchmarks
231+
print(f"\nRunning benchmarks ({runs} runs each)...")
232+
results = run_benchmarks(params, runs=runs)
233+
234+
# Check thresholds
235+
print("\nThreshold check:")
236+
failures = check_thresholds(results)
237+
if failures:
238+
for f in failures:
239+
print(f" FAIL: {f}")
240+
else:
241+
print(" All endpoints within thresholds")
242+
243+
# Compare to baseline
244+
if compare and BASELINE_FILE.exists():
245+
print("\nBaseline comparison:")
246+
baseline = json.loads(BASELINE_FILE.read_text())
247+
regressions = compare_to_baseline(results, baseline)
248+
if regressions:
249+
for r in regressions:
250+
print(f" REGRESSION: {r}")
251+
else:
252+
print(" No significant regressions")
253+
254+
# Save baseline
255+
if save:
256+
BASELINE_FILE.parent.mkdir(exist_ok=True)
257+
BASELINE_FILE.write_text(json.dumps(results, indent=2))
258+
print(f"\nBaseline saved to {BASELINE_FILE}")
259+
260+
# Summary
261+
print("\nSummary:")
262+
for name, data in results.items():
263+
if data.get("mean"):
264+
status = "OK" if name not in [f.split(":")[0] for f in failures] else "SLOW"
265+
print(f" {name}: {data['mean']:.3f}s [{status}]")
266+
else:
267+
print(f" {name}: FAILED ({data.get('error', 'unknown error')})")
268+
269+
# Exit with error if thresholds exceeded
270+
if failures:
271+
sys.exit(1)
272+
273+
274+
if __name__ == "__main__":
275+
main()

0 commit comments

Comments
 (0)