Skip to content

Commit 13b0431

Browse files
feat(loadtest): add load testing script for Cloud Run services
- Introduce load test for frontend and backend services - Implement concurrency levels to assess performance limits - Include warmup requests and degradation detection - Output detailed results including latency and error rates - Update cloudbuild.yaml to set concurrency for deployments
1 parent 4992883 commit 13b0431

File tree

3 files changed

+200
-0
lines changed

3 files changed

+200
-0
lines changed

api/cloudbuild.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ steps:
5959
- "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
6060
- "--set-env-vars=GCS_BUCKET=pyplots-images"
6161
- "--cpu-throttling"
62+
- "--concurrency=15"
6263
- "--timeout=600"
6364
id: "deploy"
6465
waitFor: ["push-image"]

app/cloudbuild.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ steps:
5454
- "--execution-environment"
5555
- "gen2"
5656
- "--cpu-throttling"
57+
- "--concurrency"
58+
- "15"
5759

5860
# Store images in Container Registry
5961
images:

scripts/loadtest.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Load test for Cloud Run services (pyplots frontend + backend).
3+
Tests increasing concurrency levels to find the performance breaking point.
4+
5+
Usage:
6+
uv run python scripts/loadtest.py frontend
7+
uv run python scripts/loadtest.py backend
8+
uv run python scripts/loadtest.py all
9+
"""
10+
11+
import asyncio
12+
import statistics
13+
import sys
14+
import time
15+
16+
import httpx
17+
18+
CONCURRENCY_LEVELS = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100]
19+
DURATION_PER_LEVEL = 30 # seconds
20+
WARMUP_REQUESTS = 5
21+
DEGRADATION_STREAK_LIMIT = 3 # abort after N consecutive degraded levels
22+
23+
TARGETS = {
24+
"frontend": {
25+
"name": "Frontend (pyplots.ai)",
26+
"urls": ["https://pyplots.ai/"],
27+
},
28+
"backend": {
29+
"name": "Backend (api.pyplots.ai)",
30+
"urls": [
31+
"https://api.pyplots.ai/specs",
32+
"https://api.pyplots.ai/stats",
33+
],
34+
},
35+
}
36+
37+
38+
async def make_request(client: httpx.AsyncClient, url: str) -> tuple[float, int]:
39+
"""Make a single request, return (latency_ms, status_code)."""
40+
start = time.monotonic()
41+
try:
42+
resp = await client.get(url, timeout=30.0)
43+
latency = (time.monotonic() - start) * 1000
44+
return latency, resp.status_code
45+
except Exception:
46+
latency = (time.monotonic() - start) * 1000
47+
return latency, 0
48+
49+
50+
async def run_level(
51+
client: httpx.AsyncClient, urls: list[str], concurrency: int, duration: float
52+
) -> dict:
53+
"""Run load at a given concurrency level for `duration` seconds."""
54+
latencies: list[float] = []
55+
errors = 0
56+
total = 0
57+
url_idx = 0
58+
start = time.monotonic()
59+
60+
async def worker():
61+
nonlocal errors, total, url_idx
62+
while time.monotonic() - start < duration:
63+
url = urls[url_idx % len(urls)]
64+
url_idx += 1
65+
latency, status = await make_request(client, url)
66+
total += 1
67+
latencies.append(latency)
68+
if status < 200 or status >= 400:
69+
errors += 1
70+
71+
workers = [asyncio.create_task(worker()) for _ in range(concurrency)]
72+
await asyncio.gather(*workers)
73+
elapsed = time.monotonic() - start
74+
75+
if not latencies:
76+
return {"concurrency": concurrency, "error": "no responses"}
77+
78+
latencies.sort()
79+
return {
80+
"concurrency": concurrency,
81+
"requests": total,
82+
"rps": round(total / elapsed, 1),
83+
"p50": round(statistics.median(latencies), 1),
84+
"p95": round(latencies[int(len(latencies) * 0.95)], 1),
85+
"p99": round(latencies[int(len(latencies) * 0.99)], 1),
86+
"max": round(max(latencies), 1),
87+
"errors": errors,
88+
"error_rate": round(errors / total * 100, 1) if total else 0,
89+
}
90+
91+
92+
def is_degraded(result: dict, baseline_p95: float | None) -> bool:
93+
"""Check if this level shows significant degradation vs baseline."""
94+
if "error" in result:
95+
return True
96+
if result["error_rate"] > 5:
97+
return True
98+
if baseline_p95 and result["p95"] > baseline_p95 * 3:
99+
return True
100+
return False
101+
102+
103+
def print_results(name: str, results: list[dict]):
104+
print(f"\n{'=' * 90}")
105+
print(f" {name}")
106+
print(f"{'=' * 90}")
107+
header = f"{'Conc':>6} {'Reqs':>7} {'RPS':>8} {'p50ms':>8} {'p95ms':>8} {'p99ms':>8} {'MaxMs':>8} {'Errs':>6} {'Err%':>6}"
108+
print(header)
109+
print("-" * 90)
110+
111+
prev_p95 = None
112+
for r in results:
113+
if "error" in r:
114+
print(f"{r['concurrency']:>6} ERROR: {r['error']}")
115+
continue
116+
117+
flag = ""
118+
if prev_p95 and r["p95"] > prev_p95 * 2:
119+
flag = " << DEGRADATION"
120+
if r["error_rate"] > 5:
121+
flag = " << HIGH ERRORS"
122+
123+
print(
124+
f"{r['concurrency']:>6} {r['requests']:>7} {r['rps']:>8} "
125+
f"{r['p50']:>8} {r['p95']:>8} {r['p99']:>8} {r['max']:>8} "
126+
f"{r['errors']:>6} {r['error_rate']:>5}%{flag}"
127+
)
128+
prev_p95 = r["p95"]
129+
130+
print()
131+
132+
133+
async def warmup(client: httpx.AsyncClient, urls: list[str]):
134+
"""Send a few warmup requests to ensure caches are primed."""
135+
print(" Warming up...", end="", flush=True)
136+
for _ in range(WARMUP_REQUESTS):
137+
for url in urls:
138+
await make_request(client, url)
139+
print(" done")
140+
141+
142+
async def test_target(target_key: str):
143+
target = TARGETS[target_key]
144+
print(f"\nTesting: {target['name']}")
145+
print(f"Endpoints: {', '.join(target['urls'])}")
146+
print(f"Levels: {CONCURRENCY_LEVELS}, {DURATION_PER_LEVEL}s each")
147+
print(f"Early abort: after {DEGRADATION_STREAK_LIMIT} consecutive degraded levels")
148+
149+
results = []
150+
baseline_p95 = None
151+
degradation_streak = 0
152+
153+
async with httpx.AsyncClient(follow_redirects=True) as client:
154+
await warmup(client, target["urls"])
155+
156+
for level in CONCURRENCY_LEVELS:
157+
print(f" Concurrency {level:>3} ({DURATION_PER_LEVEL}s)...", end="", flush=True)
158+
result = await run_level(client, target["urls"], level, DURATION_PER_LEVEL)
159+
results.append(result)
160+
rps = result.get("rps", "?")
161+
p95 = result.get("p95", "?")
162+
print(f" {rps} rps, p95={p95}ms")
163+
164+
# Track baseline from first stable level
165+
if baseline_p95 is None and not is_degraded(result, None):
166+
baseline_p95 = result.get("p95")
167+
168+
# Check for degradation streak
169+
if is_degraded(result, baseline_p95):
170+
degradation_streak += 1
171+
if degradation_streak >= DEGRADATION_STREAK_LIMIT:
172+
print(f"\n EARLY ABORT: {DEGRADATION_STREAK_LIMIT} consecutive degraded levels detected")
173+
break
174+
else:
175+
degradation_streak = 0
176+
177+
print_results(target["name"], results)
178+
return results
179+
180+
181+
async def main():
182+
targets = sys.argv[1:] if len(sys.argv) > 1 else ["all"]
183+
184+
if "all" in targets:
185+
targets = ["frontend", "backend"]
186+
187+
for t in targets:
188+
if t not in TARGETS:
189+
print(f"Unknown target: {t}. Choose from: {', '.join(TARGETS.keys())}, all")
190+
sys.exit(1)
191+
192+
for t in targets:
193+
await test_target(t)
194+
195+
196+
if __name__ == "__main__":
197+
asyncio.run(main())

0 commit comments

Comments
 (0)