Skip to main content

max / makenotwork

15.4 KB · 496 lines History Blame Raw
1 //! Load test scenarios: realistic user journeys executed in a loop until deadline.
2
3 use crate::harness::client::TestClient;
4 use axum::Router;
5 use axum::http::StatusCode;
6 use std::sync::atomic::{AtomicU64, Ordering};
7 use std::time::{Duration, Instant};
8 use tokio::time::sleep;
9
10 use super::metrics::MetricsCollector;
11
12 /// Global counter for unique usernames across VUs.
13 static USER_COUNTER: AtomicU64 = AtomicU64::new(0);
14
15 /// Seed data created during setup, shared (read-only) across all VUs.
16 pub(super) struct SeedData {
17 pub usernames: Vec<String>,
18 pub project_slugs: Vec<String>,
19 pub item_ids: Vec<String>,
20 }
21
22 // Timed request helpers
23
24 async fn timed_get(
25 client: &mut TestClient,
26 uri: &str,
27 label: &str,
28 metrics: &MetricsCollector,
29 ) -> StatusCode {
30 let start = Instant::now();
31 let resp = client.get(uri).await;
32 metrics.record(label.to_string(), start.elapsed(), resp.status);
33 resp.status
34 }
35
36 async fn timed_post_form(
37 client: &mut TestClient,
38 uri: &str,
39 body: &str,
40 label: &str,
41 metrics: &MetricsCollector,
42 ) -> (StatusCode, String) {
43 let start = Instant::now();
44 let resp = client.post_form(uri, body).await;
45 metrics.record(label.to_string(), start.elapsed(), resp.status);
46 (resp.status, resp.text)
47 }
48
49 async fn timed_put_form(
50 client: &mut TestClient,
51 uri: &str,
52 body: &str,
53 label: &str,
54 metrics: &MetricsCollector,
55 ) -> (StatusCode, String) {
56 let start = Instant::now();
57 let resp = client.put_form(uri, body).await;
58 metrics.record(label.to_string(), start.elapsed(), resp.status);
59 (resp.status, resp.text)
60 }
61
62 async fn timed_put_json(
63 client: &mut TestClient,
64 uri: &str,
65 body: &str,
66 label: &str,
67 metrics: &MetricsCollector,
68 ) -> (StatusCode, String) {
69 let start = Instant::now();
70 let resp = client.put_json(uri, body).await;
71 metrics.record(label.to_string(), start.elapsed(), resp.status);
72 (resp.status, resp.text)
73 }
74
75 async fn timed_htmx_get(
76 client: &mut TestClient,
77 uri: &str,
78 label: &str,
79 metrics: &MetricsCollector,
80 ) -> StatusCode {
81 let start = Instant::now();
82 let resp = client.htmx_get(uri).await;
83 metrics.record(label.to_string(), start.elapsed(), resp.status);
84 resp.status
85 }
86
87 #[allow(dead_code)]
88 async fn timed_delete(
89 client: &mut TestClient,
90 uri: &str,
91 label: &str,
92 metrics: &MetricsCollector,
93 ) -> StatusCode {
94 let start = Instant::now();
95 let resp = client.delete(uri).await;
96 metrics.record(label.to_string(), start.elapsed(), resp.status);
97 resp.status
98 }
99
100 // Helpers
101
102 fn next_username(prefix: &str) -> String {
103 let n = USER_COUNTER.fetch_add(1, Ordering::Relaxed);
104 format!("{prefix}_{n}")
105 }
106
107 /// Sign up a new user via the app's /join endpoint. Returns true on success.
108 async fn signup(client: &mut TestClient, username: &str, metrics: &MetricsCollector) -> bool {
109 client.fetch_csrf_token().await;
110
111 let body = format!(
112 "username={}&email={}%40loadtest.local&password=loadtest123",
113 urlencoding::encode(username),
114 urlencoding::encode(username),
115 );
116 // `/join` is the GET-only wizard page; the form posts to the account step.
117 // Aimed at `/join` this returned 405 and every creator scenario gave up on
118 // its first call, so the load numbers were measuring a rejected request.
119 let (status, _) = timed_post_form(
120 client,
121 "/join/step/account",
122 &body,
123 "POST /join/step/account",
124 metrics,
125 )
126 .await;
127 status.is_success() || status.is_redirection()
128 }
129
130 /// Log in as an existing user.
131 async fn login(client: &mut TestClient, username: &str, metrics: &MetricsCollector) -> bool {
132 client.fetch_csrf_token().await;
133
134 let body = format!(
135 "login={}&password=loadtest123",
136 urlencoding::encode(username),
137 );
138 let (status, _) = timed_post_form(client, "/login", &body, "POST /login", metrics).await;
139 status.is_success() || status.is_redirection()
140 }
141
142 // Scenarios
143
144 /// Anonymous browsing: no auth, cycles through public pages using seed data.
145 pub(super) async fn anonymous_browse(
146 app: Router,
147 ip: String,
148 deadline: Instant,
149 think_time: Duration,
150 metrics: MetricsCollector,
151 seed: &SeedData,
152 ) {
153 let mut client = TestClient::new(app);
154 client.set_forwarded_ip(&ip);
155 let mut cycle = 0usize;
156
157 while Instant::now() < deadline {
158 let u_idx = cycle % seed.usernames.len();
159 let p_idx = cycle % seed.project_slugs.len();
160 let i_idx = cycle % seed.item_ids.len();
161
162 timed_get(&mut client, "/", "GET /", &metrics).await;
163 sleep(think_time).await;
164
165 timed_get(&mut client, "/discover", "GET /discover", &metrics).await;
166 sleep(think_time).await;
167
168 timed_htmx_get(
169 &mut client,
170 "/discover/results",
171 "HTMX /discover/results",
172 &metrics,
173 )
174 .await;
175 sleep(think_time).await;
176
177 let user_url = format!("/u/{}", seed.usernames[u_idx]);
178 timed_get(&mut client, &user_url, "GET /u/{username}", &metrics).await;
179 sleep(think_time).await;
180
181 let proj_url = format!("/p/{}", seed.project_slugs[p_idx]);
182 timed_get(&mut client, &proj_url, "GET /p/{slug}", &metrics).await;
183 sleep(think_time).await;
184
185 let item_url = format!("/i/{}", seed.item_ids[i_idx]);
186 timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await;
187 sleep(think_time).await;
188
189 cycle += 1;
190 }
191 }
192
193 /// Buyer flow: signup, browse discover, add a free item to library.
194 pub(super) async fn buyer_flow(
195 app: Router,
196 ip: String,
197 deadline: Instant,
198 think_time: Duration,
199 metrics: MetricsCollector,
200 seed: &SeedData,
201 ) {
202 let mut cycle = 0usize;
203
204 while Instant::now() < deadline {
205 // Fresh client per cycle (new session)
206 let mut client = TestClient::new(app.clone());
207 client.set_forwarded_ip(&ip);
208
209 let username = next_username("buyer");
210 if !signup(&mut client, &username, &metrics).await {
211 sleep(think_time).await;
212 cycle += 1;
213 continue;
214 }
215 sleep(think_time).await;
216
217 timed_get(&mut client, "/discover", "GET /discover", &metrics).await;
218 sleep(think_time).await;
219
220 timed_htmx_get(
221 &mut client,
222 "/discover/results",
223 "HTMX /discover/results",
224 &metrics,
225 )
226 .await;
227 sleep(think_time).await;
228
229 let i_idx = cycle % seed.item_ids.len();
230 let item_url = format!("/i/{}", seed.item_ids[i_idx]);
231 timed_get(&mut client, &item_url, "GET /i/{item_id}", &metrics).await;
232 sleep(think_time).await;
233
234 let add_url = format!("/api/library/add/{}", seed.item_ids[i_idx]);
235 timed_post_form(&mut client, &add_url, "", "POST /api/library/add", &metrics).await;
236 sleep(think_time).await;
237
238 timed_get(&mut client, "/library", "GET /library", &metrics).await;
239 sleep(think_time).await;
240
241 cycle += 1;
242 }
243 }
244
245 /// Creator flow: signup, grant creator via SQL, create project + items, publish.
246 pub(super) async fn creator_flow(
247 app: Router,
248 ip: String,
249 deadline: Instant,
250 think_time: Duration,
251 metrics: MetricsCollector,
252 pool: sqlx::PgPool,
253 ) {
254 let mut cycle = 0usize;
255
256 while Instant::now() < deadline {
257 let mut client = TestClient::new(app.clone());
258 client.set_forwarded_ip(&ip);
259
260 let username = next_username("creator");
261 if !signup(&mut client, &username, &metrics).await {
262 sleep(think_time).await;
263 cycle += 1;
264 continue;
265 }
266
267 // Grant creator via SQL
268 let user_id: Option<uuid::Uuid> =
269 sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
270 .bind(&username)
271 .fetch_optional(&pool)
272 .await
273 .ok()
274 .flatten();
275
276 let Some(user_id) = user_id else {
277 sleep(think_time).await;
278 cycle += 1;
279 continue;
280 };
281
282 let _ = sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
283 .bind(user_id)
284 .execute(&pool)
285 .await;
286
287 // Re-login to pick up creator permissions
288 timed_post_form(&mut client, "/logout", "", "POST /logout", &metrics).await;
289 sleep(think_time).await;
290
291 if !login(&mut client, &username, &metrics).await {
292 sleep(think_time).await;
293 cycle += 1;
294 continue;
295 }
296 sleep(think_time).await;
297
298 // Create project
299 let slug = format!("proj-{username}");
300 let body = format!(
301 "slug={}&title=Load+Test+Project",
302 urlencoding::encode(&slug)
303 );
304 let (status, text) = timed_post_form(
305 &mut client,
306 "/api/projects",
307 &body,
308 "POST /api/projects",
309 &metrics,
310 )
311 .await;
312
313 if !status.is_success() {
314 sleep(think_time).await;
315 cycle += 1;
316 continue;
317 }
318
319 let project_id = serde_json::from_str::<serde_json::Value>(&text)
320 .ok()
321 .and_then(|v| v["id"].as_str().map(String::from));
322
323 let Some(project_id) = project_id else {
324 sleep(think_time).await;
325 cycle += 1;
326 continue;
327 };
328 sleep(think_time).await;
329
330 // Create 3 items
331 let mut item_ids = Vec::new();
332 for i in 0..3 {
333 let item_body = format!("title=Item+{cycle}+{i}&price_cents=0&item_type=digital");
334 let (status, text) = timed_post_form(
335 &mut client,
336 &format!("/api/projects/{project_id}/items"),
337 &item_body,
338 "POST /api/projects/{id}/items",
339 &metrics,
340 )
341 .await;
342
343 if status.is_success()
344 && let Some(id) = serde_json::from_str::<serde_json::Value>(&text)
345 .ok()
346 .and_then(|v| v["id"].as_str().map(String::from))
347 {
348 item_ids.push(id);
349 }
350 sleep(think_time).await;
351 }
352
353 // Publish project
354 timed_put_json(
355 &mut client,
356 &format!("/api/projects/{project_id}"),
357 r#"{"is_public": true}"#,
358 "PUT /api/projects/{id}",
359 &metrics,
360 )
361 .await;
362 sleep(think_time).await;
363
364 // Publish items
365 for item_id in &item_ids {
366 timed_put_form(
367 &mut client,
368 &format!("/api/items/{item_id}"),
369 "is_public=true",
370 "PUT /api/items/{id}",
371 &metrics,
372 )
373 .await;
374 sleep(think_time).await;
375 }
376
377 // View dashboard
378 timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await;
379 sleep(think_time).await;
380
381 cycle += 1;
382 }
383 }
384
385 /// Every address a described screen can claim, paired with the tab label the
386 /// report files it under.
387 ///
388 /// Each is served by Askama or by the description layer depending on
389 /// `QUASI_SCREENS`, at the same address either way, which is what makes the
390 /// alternating-pairs protocol a comparison rather than two unrelated runs.
391 ///
392 /// Each gets its OWN label. The undescribed tabs share one on purpose (they are
393 /// one population); these are the population being compared, and folding them
394 /// together would average away the number the run is after. S3 measured one of
395 /// them, so its numbers say nothing about a mix where six are on at once and
396 /// blocking-pool occupancy accumulates across them.
397 const DESCRIBED: &[(&str, &str)] = &[
398 ("/dashboard/tabs/ssh-keys", "HTMX described:ssh-keys"),
399 ("/dashboard/tabs/contacts", "HTMX described:buyer-contacts"),
400 ("/dashboard/tabs/analytics", "HTMX described:analytics"),
401 // The two Multithreaded-backed ones. Their work is an outbound HTTP call
402 // rather than a query, so under the description layer they hold a blocking
403 // thread for the upstream latency rather than for a sub-millisecond round
404 // trip. That is the regime nothing had measured.
405 ("/dashboard/tabs/forums", "HTMX described:forums"),
406 (
407 "/library/tabs/communities",
408 "HTMX described:library-communities",
409 ),
410 ("/library/tabs/contacts", "HTMX described:library-contacts"),
411 ];
412
413 /// Dashboard session: one-time signup, then loop through dashboard and library
414 /// tabs, described ones included.
415 ///
416 /// Takes the pool because two of the described screens are creator-only. A plain
417 /// signup gets 403 on them, and a 403 is fast: without the grant this scenario
418 /// would have reported the two creator screens as the quickest routes in the run
419 /// on both sides of the comparison, and the `Rej` column exists to make that
420 /// visible if it ever regresses.
421 pub(super) async fn dashboard_session(
422 app: Router,
423 ip: String,
424 deadline: Instant,
425 think_time: Duration,
426 metrics: MetricsCollector,
427 pool: sqlx::PgPool,
428 ) {
429 let mut client = TestClient::new(app.clone());
430 client.set_forwarded_ip(&ip);
431
432 let username = next_username("dash");
433 if !signup(&mut client, &username, &metrics).await {
434 return;
435 }
436
437 // Grant creator, then re-login on a fresh client so the session carries it.
438 // Same dance as the seed path, and for the same reason: `authenticate`
439 // caches its touch for a few seconds, so the live session predates the grant.
440 let granted = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE username = $1")
441 .bind(&username)
442 .fetch_optional(&pool)
443 .await
444 .ok()
445 .flatten();
446 if let Some(user_id) = granted {
447 let _ = sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
448 .bind(user_id)
449 .execute(&pool)
450 .await;
451 let mut fresh = TestClient::new(app);
452 fresh.set_forwarded_ip(&ip);
453 if login(&mut fresh, &username, &metrics).await {
454 client = fresh;
455 }
456 }
457 sleep(think_time).await;
458
459 // The undescribed tabs the described ones are compared against. Each reports
460 // under its own label rather than sharing one: the shared label was carrying
461 // a `promotions` entry that has no route at all (a 404 on every cycle,
462 // averaged into the baseline the described screens are measured against) and
463 // a tab returning 500, and neither was visible while five tabs were one row.
464 // The old aggregate is still recoverable by summing these.
465 let plain_tabs = ["details", "payments", "projects", "creator"];
466
467 while Instant::now() < deadline {
468 timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await;
469 sleep(think_time).await;
470
471 for tab in &plain_tabs {
472 let url = format!("/dashboard/tabs/{tab}");
473 let label = format!("HTMX /dashboard/tabs/{tab}");
474 timed_htmx_get(&mut client, &url, &label, &metrics).await;
475 sleep(think_time).await;
476 }
477
478 timed_get(&mut client, "/library", "GET /library", &metrics).await;
479 sleep(think_time).await;
480
481 for (path, label) in DESCRIBED {
482 timed_htmx_get(&mut client, path, label, &metrics).await;
483 sleep(think_time).await;
484 }
485
486 timed_get(
487 &mut client,
488 "/dashboard/transactions",
489 "GET /dashboard/transactions",
490 &metrics,
491 )
492 .await;
493 sleep(think_time).await;
494 }
495 }
496