Skip to main content

max / makenotwork

Fix the load harness's seed, and let its mix be set Three things, all in tests/load, none of them shipping code. The seed could not create a project: 403 on every one, so the harness has been unable to run at all. It reads as an authorization failure and is not one. The seed fetches a CSRF token, then logs in, then posts — and logging in rotates the session, so the token belongs to a session that no longer exists. A rejected CSRF check answers 403 Forbidden with the same generic message an authorization failure uses, which is what made this look for a while like the seeded user not being a creator. Its flags were right the whole time. Re-fetch after login. The seed also logs in on a fresh client rather than logging the signup session out. The grant it makes by SQL is invisible to a session whose SessionUser predates it, and `authenticate` skips its refreshing touch query for SESSION_TOUCH_CACHE_SECS, which this dance runs well inside. A new cookie jar cannot carry a stale user. LOAD_MIX sets the scenario distribution. The default mix is the right thing to measure the server against and the wrong thing to measure one route with: 5% of 20 virtual users is one user, and one user reaches no contention. It refuses a mix that does not sum to 100 rather than renormalising, because a run under a mix nobody meant is worse than one that would not start.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 20:56 UTC
Signed with PGP, not checked
Commit: 620f82e9e4ce6a104b6a44f466a96531e8c3c25c
Parent: 05d851d
2 files changed, +70 insertions, -3 deletions
@@ -35,7 +35,7 @@
35 35 think_time,
36 36 db_max_connections: 10,
37 37 db_acquire_timeout: Duration::from_secs(3),
38 - scenario_mix: ScenarioMix::default(),
38 + scenario_mix: ScenarioMix::from_env(),
39 39 }
40 40 }
41 41 }
@@ -60,6 +60,59 @@
60 60 }
61 61 }
62 62
63 + impl ScenarioMix {
64 + /// Read the mix from the environment, falling back to the default shape.
65 + ///
66 + /// `LOAD_MIX=anon:20,buyer:10,creator:10,dash:60`. The default is what a
67 + /// normal day is thought to look like and is the right thing to measure the
68 + /// server against; it is the wrong thing to measure ONE ROUTE with, because
69 + /// 5% of 20 virtual users is one, and one user reaches no contention at all.
70 + ///
71 + /// Added for the S3 conversion measurement (wiki
72 + /// `mnw-server-conversion-plan`), where the question is what a described
73 + /// route does to everything else: the router is sync, so quasi-axum
74 + /// dispatches on `spawn_blocking`, and this server shares that pool with
75 + /// argon2 hashing, content exports and the file scanner. Turning the
76 + /// dashboard share up is how the described route is given enough
77 + /// concurrency to show whether it starves them.
78 + ///
79 + /// Panics on a mix that does not sum to 100, rather than silently
80 + /// renormalising: a measurement run under a mix nobody meant is worse than
81 + /// one that refused to start.
82 + pub(super) fn from_env() -> Self {
83 + let Ok(raw) = std::env::var("LOAD_MIX") else {
84 + return Self::default();
85 + };
86 + let mut mix = ScenarioMix {
87 + anonymous_browse: 0,
88 + buyer_flow: 0,
89 + creator_flow: 0,
90 + dashboard_session: 0,
91 + };
92 + for part in raw.split(',') {
93 + let (name, value) = part
94 + .trim()
95 + .split_once(':')
96 + .unwrap_or_else(|| panic!("LOAD_MIX entry {part:?} is not name:percent"));
97 + let value: u32 = value
98 + .trim()
99 + .parse()
100 + .unwrap_or_else(|_| panic!("LOAD_MIX entry {part:?} has a non-numeric percent"));
101 + match name.trim() {
102 + "anon" => mix.anonymous_browse = value,
103 + "buyer" => mix.buyer_flow = value,
104 + "creator" => mix.creator_flow = value,
105 + "dash" => mix.dashboard_session = value,
106 + other => panic!("LOAD_MIX names anon, buyer, creator, dash; got {other:?}"),
107 + }
108 + }
109 + let total =
110 + mix.anonymous_browse + mix.buyer_flow + mix.creator_flow + mix.dashboard_session;
111 + assert_eq!(total, 100, "LOAD_MIX must sum to 100, got {total}");
112 + mix
113 + }
114 + }
115 +
63 116 impl ScenarioMix {
64 117 /// Deterministically assign a scenario to a VU based on its index.
65 118 pub(super) fn assign_scenario(&self, vu_index: u32, total_vus: u32) -> ScenarioType {
@@ -292,8 +292,14 @@
292 292 .await
293 293 .expect("Failed to grant creator to seed user");
294 294
295 - // Re-login
296 - client.post_form("/logout", "").await;
295 + // Re-login on a FRESH client rather than logging this one out. The
296 + // signup left a live session whose SessionUser predates the grant
297 + // above, and every way of reusing it depends on the auth path noticing
298 + // the change: `authenticate` skips its touch query for
299 + // SESSION_TOUCH_CACHE_SECS (5s) and this dance runs well inside that.
300 + // A new cookie jar cannot carry a stale user.
301 + let mut client = TestClient::new(app.clone());
302 + client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
297 303 client.fetch_csrf_token().await;
298 304 let body = format!("login={username}&password=seedpass123");
299 305 let resp = client.post_form("/login", &body).await;
@@ -303,6 +309,14 @@
303 309 username, resp.status, resp.text
304 310 );
305 311
312 + // Re-fetch the token: logging in rotates the session, and the token
313 + // fetched before it is bound to the session that no longer exists. A
314 + // rejected CSRF check answers 403 Forbidden with the same generic
315 + // message an authorization failure uses, which is why this read for a
316 + // long time as "the seed user is not a creator" — the flags were right
317 + // the whole time.
318 + client.fetch_csrf_token().await;
319 +
306 320 // Create project
307 321 let body = format!(
308 322 "slug={}&title=Seed+Project+{}",