gabriel / musehub public
locustfile.py python
310 lines 10.3 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """MuseHub load-test scenarios — run with Locust against a live staging instance.
2
3 Install:
4 pip install locust
5
6 Usage (all scenarios share this file; select via --tags or --class):
7
8 # Baseline: 100 concurrent users, read-heavy, verify p99 < 500 ms
9 locust -f locustfile.py --class-picker \
10 --host https://staging.musehub.ai \
11 --users 100 --spawn-rate 10 \
12 --run-time 5m --headless \
13 --html baseline-report.html
14
15 # Spike: ramp to 1000 users in 30 s, hold 60 s — expect 429s, no crashes
16 locust -f locustfile.py BaselineUser SpikeBurst \
17 --host https://staging.musehub.ai \
18 --users 1000 --spawn-rate 100 \
19 --run-time 90s --headless \
20 --html spike-report.html
21
22 # Soak: sustained moderate load 12 h — watch for memory / connection leaks
23 locust -f locustfile.py SoakUser \
24 --host https://staging.musehub.ai \
25 --users 30 --spawn-rate 2 \
26 --run-time 12h --headless \
27 --html soak-report.html
28
29 # Write-heavy: 50 concurrent push users for 5 min
30 locust -f locustfile.py WritePushUser \
31 --host https://staging.musehub.ai \
32 --users 50 --spawn-rate 5 \
33 --run-time 5m --headless \
34 --html write-report.html
35
36 Environment:
37 MUSEHUB_TOKEN — MSign token for authenticated requests
38 MUSEHUB_OWNER — repo owner (default: gabriel)
39 MUSEHUB_REPO — target repo slug (default: muse)
40 PUSH_OBJECT_KB — size of synthetic push payload in KiB (default: 4)
41
42 Success criteria:
43 Baseline : p99 < 500 ms, error rate < 0.1 %
44 Spike : no 5xx; 429s expected and counted, not failures; Retry-After present
45 Soak : RSS growth < 50 MiB over 12 h (check via /debug/memory if enabled)
46 Write : no object-store corruption; DB row counts consistent post-run
47 """
48 from __future__ import annotations
49
50 import hashlib
51 import os
52 import time
53 import uuid
54
55 import msgpack
56 from locust import HttpUser, between, constant, events, task
57
58 # ── config from environment ───────────────────────────────────────────────────
59 _TOKEN = os.getenv("MUSEHUB_TOKEN", "")
60 _OWNER = os.getenv("MUSEHUB_OWNER", "gabriel")
61 _REPO = os.getenv("MUSEHUB_REPO", "muse")
62 _PUSH_KB = int(os.getenv("PUSH_OBJECT_KB", "4"))
63
64
65 def _auth_headers() -> dict[str, str]:
66 if _TOKEN:
67 return {"Authorization": f"Bearer {_TOKEN}"}
68 return {}
69
70
71 def _mp(data: object) -> bytes:
72 return msgpack.packb(data, use_bin_type=True)
73
74
75 def _make_object(size_kb: int = _PUSH_KB) -> tuple[str, bytes]:
76 """Return (sha256_hex, raw_bytes) for a synthetic blob of ``size_kb`` KiB."""
77 payload = os.urandom(size_kb * 1024)
78 sha = hashlib.sha256(payload).hexdigest()
79 return sha, payload
80
81
82 # ── baseline: read-heavy, mixed public endpoints ──────────────────────────────
83
84 class BaselineUser(HttpUser):
85 """Simulates a normal human browsing repos, commits, and issues.
86
87 Target: p99 < 500 ms at 100 concurrent users.
88 """
89
90 wait_time = between(0.5, 2.0)
91 weight = 8 # 80 % of users in mixed runs
92
93 def on_start(self) -> None:
94 self._owner = _OWNER
95 self._repo = _REPO
96
97 @task(5)
98 def view_repo_home(self) -> None:
99 self.client.get(f"/{self._owner}/{self._repo}", name="/owner/repo")
100
101 @task(4)
102 def list_commits(self) -> None:
103 self.client.get(
104 f"/{self._owner}/{self._repo}/commits",
105 name="/owner/repo/commits",
106 )
107
108 @task(3)
109 def list_issues(self) -> None:
110 self.client.get(
111 f"/{self._owner}/{self._repo}/issues",
112 name="/owner/repo/issues",
113 )
114
115 @task(2)
116 def view_proposals(self) -> None:
117 self.client.get(
118 f"/{self._owner}/{self._repo}/proposals",
119 name="/owner/repo/proposals",
120 )
121
122 @task(2)
123 def browse_tree(self) -> None:
124 self.client.get(
125 f"/{self._owner}/{self._repo}/tree/main",
126 name="/owner/repo/tree/ref",
127 )
128
129 @task(1)
130 def api_repo_info(self) -> None:
131 self.client.get(
132 f"/api/v1/repos/{self._owner}/{self._repo}",
133 name="/api/v1/repos/owner/repo",
134 headers=_auth_headers(),
135 )
136
137 @task(1)
138 def api_list_commits(self) -> None:
139 self.client.get(
140 f"/api/v1/repos/{self._owner}/{self._repo}/commits",
141 name="/api/v1/repos/owner/repo/commits",
142 headers=_auth_headers(),
143 )
144
145
146 # ── spike: intentional rate-limit testing ────────────────────────────────────
147
148 class SpikeBurst(HttpUser):
149 """Hammers a single lightweight endpoint to provoke 429s.
150
151 Expect: 429 responses with Retry-After header, no 5xx.
152 """
153
154 wait_time = constant(0) # fire as fast as possible
155 weight = 2 # 20 % of users in mixed runs
156
157 @task
158 def burst_search(self) -> None:
159 with self.client.get(
160 "/api/v1/search",
161 params={"q": "test"},
162 name="/api/v1/search [burst]",
163 catch_response=True,
164 headers=_auth_headers(),
165 ) as resp:
166 if resp.status_code == 429:
167 assert "retry-after" in resp.headers, "429 missing Retry-After"
168 resp.success() # 429 is expected, not a failure
169 elif resp.status_code >= 500:
170 resp.failure(f"Server error {resp.status_code}")
171
172
173 # ── soak: sustained moderate load ─────────────────────────────────────────────
174
175 class SoakUser(HttpUser):
176 """Steady-state moderate load for 12-hour leak detection.
177
178 Watch metrics:
179 - RSS growth via /debug/memory (if MUSEHUB_DEBUG_MEMORY is enabled on staging)
180 - DB connection count (pg_stat_activity)
181 - Error rate must stay < 0.05 %
182 """
183
184 wait_time = between(2.0, 5.0)
185
186 def on_start(self) -> None:
187 self._owner = _OWNER
188 self._repo = _REPO
189
190 @task(4)
191 def view_repo(self) -> None:
192 self.client.get(f"/{self._owner}/{self._repo}", name="/owner/repo [soak]")
193
194 @task(2)
195 def list_commits(self) -> None:
196 self.client.get(
197 f"/{self._owner}/{self._repo}/commits",
198 name="/owner/repo/commits [soak]",
199 )
200
201 @task(1)
202 def api_health(self) -> None:
203 # Lightweight endpoint to verify no connection leak
204 self.client.get(
205 "/api/v1/openapi.json",
206 name="/api/v1/openapi.json [soak]",
207 )
208
209
210 # ── write-heavy: 50 concurrent push users ─────────────────────────────────────
211
212 class WritePushUser(HttpUser):
213 """Simulates concurrent muse push clients.
214
215 Each user: pre-upload one object, then push a commit referencing it.
216 Verify: no 5xx, no object corruption, DB row counts consistent.
217 """
218
219 wait_time = between(1.0, 3.0)
220
221 def on_start(self) -> None:
222 self._owner = _OWNER
223 self._repo = _REPO
224 self._headers = {
225 **_auth_headers(),
226 "Content-Type": "application/x-msgpack",
227 }
228
229 # Resolve repo_id once per virtual user
230 resp = self.client.get(f"/api/v1/repos/{self._owner}/{self._repo}")
231 if resp.status_code == 200:
232 self._repo_id = resp.json().get("repo_id", "")
233 else:
234 self._repo_id = ""
235
236 @task
237 def push_one_commit(self) -> None:
238 if not self._repo_id:
239 return
240
241 obj_id, obj_bytes = _make_object()
242 commit_id = uuid.uuid4().hex * 2 # 64-char hex
243
244 # Phase 1 — pre-upload object
245 pre = self.client.post(
246 f"/wire/{self._repo_id}/push/objects",
247 data=_mp({"objects": [{
248 "object_id": obj_id,
249 "size_bytes": len(obj_bytes),
250 "path": f"blob/{commit_id[:8]}.bin",
251 "repo_id": self._repo_id,
252 }]}),
253 headers=self._headers,
254 name="/wire/push/objects [write]",
255 )
256 if pre.status_code not in (200, 409): # 409 = already exists
257 return
258
259 # Phase 2 — push commit
260 self.client.post(
261 f"/wire/{self._repo_id}/push",
262 data=_mp({
263 "commits": [{
264 "commit_id": commit_id,
265 "parent_ids": [],
266 "branch": "load-test",
267 "message": f"load-test commit {commit_id[:8]}",
268 "author": "load-test",
269 "timestamp": int(time.time()),
270 "snapshot": {},
271 "tags": [],
272 }],
273 "snapshots": [],
274 "refs": {"load-test": commit_id},
275 }),
276 headers=self._headers,
277 name="/wire/push [write]",
278 )
279
280
281 # ── event hooks: print summary thresholds after run ──────────────────────────
282
283 @events.quitting.add_listener
284 def _check_thresholds(environment, **kwargs): # type: ignore[no-untyped-def]
285 stats = environment.stats.total
286 if stats.num_requests == 0:
287 return
288
289 p99_ms = stats.get_response_time_percentile(0.99)
290 error_pct = 100 * stats.num_failures / stats.num_requests
291
292 print("\n── Load test summary ────────────────────────────────")
293 print(f" Requests : {stats.num_requests}")
294 print(f" Failures : {stats.num_failures} ({error_pct:.2f} %)")
295 print(f" p50 : {stats.get_response_time_percentile(0.50):.0f} ms")
296 print(f" p95 : {stats.get_response_time_percentile(0.95):.0f} ms")
297 print(f" p99 : {p99_ms:.0f} ms")
298 print("─────────────────────────────────────────────────────")
299
300 if p99_ms > 500:
301 print(f" ⚠️ p99 {p99_ms:.0f} ms EXCEEDS 500 ms target")
302 environment.process_exit_code = 1
303 else:
304 print(f" ✅ p99 {p99_ms:.0f} ms within 500 ms target")
305
306 if error_pct > 0.1:
307 print(f" ⚠️ error rate {error_pct:.2f} % EXCEEDS 0.1 % target")
308 environment.process_exit_code = 1
309 else:
310 print(f" ✅ error rate {error_pct:.2f} % within 0.1 % target")
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago