Skip to main content

max / makenotwork

14.3 KB · 394 lines History Blame Raw
1 //! Load test orchestrator: sets up the shared app, seeds data, spawns virtual
2 //! users, and prints the final report.
3
4 use axum::Router;
5 use sqlx::PgPool;
6 use sqlx::postgres::PgPoolOptions;
7 use std::sync::Arc;
8 use std::time::{Duration, Instant};
9 use tower_sessions::cookie::SameSite;
10 use tower_sessions::cookie::time::Duration as CookieDuration;
11 use tower_sessions::{Expiry, SessionManagerLayer};
12 use tower_sessions_sqlx_store::PostgresStore;
13
14 use docengine::DocLoader;
15 use makenotwork::config::{
16 BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig,
17 };
18 use makenotwork::email::{EmailClient, EmailConfig};
19 use makenotwork::{AppState, AppStateParts, AppStorage, build_app};
20
21 use crate::harness::client::TestClient;
22 use crate::harness::db::TestDb;
23
24 use super::blocking_probe::BlockingProbe;
25 use super::config::{LoadConfig, ScenarioType};
26 use super::metrics::MetricsCollector;
27 use super::mt_stub::MtStub;
28 use super::scenarios::{self, SeedData};
29
30 /// Run the full load test.
31 pub(super) async fn run(config: LoadConfig) {
32 // 1. Database setup: TestDb for creation/migration/cleanup
33 let test_db = TestDb::new().await;
34
35 // A stand-in Multithreaded, up before the app so its address can be
36 // configured. Always started, not only when latency is asked for: the two
37 // forum screens refuse or empty out when `mt_base_url` is unset, and a run
38 // that measures a refusal reads as a very fast route.
39 let mt = MtStub::start(config.mt_latency, config.mt_memberships).await;
40
41 // Production-sized pool against the same test database
42 let pool = PgPoolOptions::new()
43 .max_connections(config.db_max_connections)
44 .acquire_timeout(config.db_acquire_timeout)
45 .connect(test_db.url())
46 .await
47 .expect("Failed to create load test pool");
48
49 // 2. Session store
50 let session_store = PostgresStore::new(pool.clone());
51 session_store
52 .migrate()
53 .await
54 .expect("Failed to migrate session store");
55
56 let session_layer = SessionManagerLayer::new(session_store)
57 .with_secure(false)
58 .with_same_site(SameSite::Lax)
59 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
60
61 // 3. App
62 let app_config = Config {
63 host: "127.0.0.1".parse().unwrap(),
64 port: 0,
65 database_url: String::new(),
66 host_url: std::sync::Arc::from("http://localhost:3000"),
67 signing_secret: "load-test-signing-secret".to_string(),
68 storage: None,
69 synckit_storage: None,
70 public_storage: None,
71 stripe: None,
72 admin_user_id: None,
73 synckit_jwt_secret: None,
74 scan: None,
75 cdn_base_url: "https://cdn.localhost".to_string(),
76 user_pages_host: std::sync::Arc::from("u.localhost"),
77 access_gate: makenotwork::config::AccessGate::Open,
78 sso: None,
79 // The load runner drives thousands of requests from one IP, so the
80 // production limiter would measure the limiter rather than the server.
81 rate_limits: makenotwork::constants::RateLimits::relaxed(),
82 // Read from the environment rather than defaulted off, because the
83 // conversion's one open cost is measured by running this twice: once
84 // with every screen on Askama and once with QUASI_SCREENS naming the
85 // described one. A default here would silently measure the same side
86 // twice. See wiki `mnw-server-conversion-plan`, S3.
87 quasi_screens: makenotwork::config::QuasiScreens::parse(
88 &std::env::var("QUASI_SCREENS").unwrap_or_default(),
89 ),
90 build: BuildConfig {
91 trigger_token: None,
92 host_linux: None,
93 host_darwin: None,
94 git_repos_path: None,
95 git_ssh_host: None,
96 },
97 email_webhooks: EmailWebhookConfig {
98 webhook_token: None,
99 broadcast_webhook_token: None,
100 inbound_webhook_token: None,
101 enforce_sender_auth: true,
102 },
103 creator_pricing: CreatorTierPricing {
104 fan_plus_price_id: None,
105 tier_prices: std::collections::HashMap::new(),
106 tier_annual_prices: std::collections::HashMap::new(),
107 tier_founder_prices: std::collections::HashMap::new(),
108 tier_founder_annual_prices: std::collections::HashMap::new(),
109 founder_window_open: false,
110 },
111 integrations: IntegrationsConfig {
112 // Both halves, or the forum screens are not exercised. This was
113 // hardcoded `None`, which is why the two Multithreaded-backed
114 // described screens had no load measurement at all: the described
115 // library tab answered an empty list without making the call, and
116 // the described settings section 404'd, so either would have
117 // reported as the fastest route in the run.
118 mt_base_url: Some(mt.base_url()),
119 wam_url: None,
120 internal_shared_secret: Some("load-test-mt-secret".to_string()),
121 cli_service_token: None,
122 alerts_ingest_token: None,
123 },
124 };
125
126 let email = EmailClient::new(
127 EmailConfig {
128 postmark_token: None,
129 from_address: "loadtest@makenot.work".to_string(),
130 from_name: "LoadTest".to_string(),
131 },
132 Some(pool.clone()),
133 );
134
135 let rp_origin = url::Url::parse(&app_config.host_url).expect("test HOST_URL");
136 let rp_id = rp_origin
137 .host_str()
138 .expect("test HOST_URL host")
139 .to_string();
140 let webauthn = Arc::new(
141 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
142 .expect("WebauthnBuilder")
143 .rp_name("LoadTest")
144 .build()
145 .expect("Webauthn"),
146 );
147
148 // Route through the shared `AppState::build` constructor (same as main.rs
149 // and the integration harness), derived in-memory state has one source.
150 let state = AppState::build(AppStateParts {
151 db: pool.clone(),
152 config: app_config,
153 storage: AppStorage {
154 s3: None,
155 synckit_s3: None,
156 public_s3: None,
157 },
158 stripe: None,
159 email,
160 docs: Arc::new(DocLoader::load(
161 std::path::Path::new("."),
162 &docengine::DocLoaderConfig {
163 sections: vec![],
164 link_prefix: "/docs".to_string(),
165 unpublished_pattern: None,
166 examples_path: None,
167 pre_process: None,
168 },
169 )),
170 tier_prices: {
171 // Install the process-global TierPrices so CreatorTier accessors
172 // work under the load harness (they read the global). Idempotent.
173 makenotwork::tier_prices::TierPrices::install_test_default();
174 makenotwork::tier_prices::TierPrices::global().clone()
175 },
176 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
177 fee_calculator: makenotwork::fee_calculator::FeeCalculator::load(
178 "docs/business/assumptions.toml",
179 ),
180 scanner: None,
181 webauthn,
182 syntax: None,
183 mt_client: None,
184 wam: None,
185 domain_cache: Arc::new(dashmap::DashMap::new()),
186 metrics_handle: None,
187 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
188 bg: makenotwork::background::spawn_pool_detached(),
189 });
190
191 let app = build_app(&state, session_layer);
192
193 // 4. Seed data
194 println!("Seeding test data...");
195 let seed = Arc::new(seed_data(&app, &pool).await);
196 println!(
197 " Seeded {} creators, {} projects, {} items",
198 seed.usernames.len(),
199 seed.project_slugs.len(),
200 seed.item_ids.len()
201 );
202
203 // 5. Spawn VUs
204 let metrics = MetricsCollector::new();
205 // Started after seeding, which is itself heavy enough to show as a stall and
206 // is not part of what the run is measuring.
207 let probe = BlockingProbe::start(config.probe_interval);
208 let ramp_delay = if config.virtual_users > 1 {
209 config.ramp_up / config.virtual_users
210 } else {
211 Duration::ZERO
212 };
213
214 let test_duration = config.duration;
215 let test_start = Instant::now();
216 let mut handles = Vec::new();
217
218 println!("Spawning {} virtual users...", config.virtual_users);
219
220 for vu in 0..config.virtual_users {
221 // Stagger VU start times
222 if vu > 0 {
223 tokio::time::sleep(ramp_delay).await;
224 }
225
226 let scenario = config
227 .scenario_mix
228 .assign_scenario(vu, config.virtual_users);
229 let ip = format!("10.0.{}.{}", vu / 256, vu % 256);
230 let deadline = test_start + test_duration;
231 let think_time = config.think_time;
232 let m = metrics.clone();
233 let a = app.clone();
234 let s = Arc::clone(&seed);
235 let p = pool.clone();
236
237 let handle = tokio::spawn(async move {
238 match scenario {
239 ScenarioType::AnonymousBrowse => {
240 scenarios::anonymous_browse(a, ip, deadline, think_time, m, &s).await;
241 }
242 ScenarioType::BuyerFlow => {
243 scenarios::buyer_flow(a, ip, deadline, think_time, m, &s).await;
244 }
245 ScenarioType::CreatorFlow => {
246 scenarios::creator_flow(a, ip, deadline, think_time, m, p).await;
247 }
248 ScenarioType::DashboardSession => {
249 scenarios::dashboard_session(a, ip, deadline, think_time, m, p).await;
250 }
251 }
252 });
253
254 handles.push((vu, scenario, handle));
255 }
256
257 // 6. Join all
258 println!("Running for {test_duration:?}...\n");
259 for (vu, scenario, handle) in handles {
260 if let Err(e) = handle.await {
261 eprintln!("VU {vu} ({scenario}) panicked: {e:?}");
262 }
263 }
264
265 // 7. Report
266 metrics.report().print();
267 probe.finish().print();
268
269 // 8. Cleanup (TestDb dropped here)
270 drop(mt);
271 drop(pool);
272 drop(test_db);
273 }
274
275 /// Seed the database with creators, projects, and items via HTTP endpoints.
276 /// Returns shared seed data for all VU scenarios.
277 async fn seed_data(app: &Router, pool: &PgPool) -> SeedData {
278 let mut usernames = Vec::new();
279 let mut project_slugs = Vec::new();
280 let mut item_ids = Vec::new();
281
282 for i in 0..5 {
283 let username = format!("seed_creator_{i}");
284 let slug = format!("seed-project-{i}");
285
286 let mut client = TestClient::new(app.clone());
287 // Unique IP per seed client to avoid rate limiting
288 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
289
290 // Sign up
291 client.fetch_csrf_token().await;
292 let body =
293 format!("username={username}&email={username}%40seed.local&password=seedpass123");
294 let resp = client.post_form("/join/step/account", &body).await;
295 assert_eq!(
296 resp.status, 200,
297 "Seed signup failed for {}: {} {}",
298 username, resp.status, resp.text
299 );
300
301 // Grant creator via SQL
302 let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
303 .bind(&username)
304 .fetch_one(pool)
305 .await
306 .expect("Seed user not found");
307
308 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
309 .bind(user_id)
310 .execute(pool)
311 .await
312 .expect("Failed to grant creator to seed user");
313
314 // Re-login on a FRESH client rather than logging this one out. The
315 // signup left a live session whose SessionUser predates the grant
316 // above, and every way of reusing it depends on the auth path noticing
317 // the change: `authenticate` skips its touch query for
318 // SESSION_TOUCH_CACHE_SECS (5s) and this dance runs well inside that.
319 // A new cookie jar cannot carry a stale user.
320 let mut client = TestClient::new(app.clone());
321 client.set_forwarded_ip(&format!("192.168.{}.{}", i / 256, i % 256 + 1));
322 client.fetch_csrf_token().await;
323 let body = format!("login={username}&password=seedpass123");
324 let resp = client.post_form("/login", &body).await;
325 assert_eq!(
326 resp.status, 303,
327 "Seed login failed for {}: {} {}",
328 username, resp.status, resp.text
329 );
330
331 // Re-fetch the token: logging in rotates the session, and the token
332 // fetched before it is bound to the session that no longer exists. A
333 // rejected CSRF check answers 403 Forbidden with the same generic
334 // message an authorization failure uses, which is why this read for a
335 // long time as "the seed user is not a creator" — the flags were right
336 // the whole time.
337 client.fetch_csrf_token().await;
338
339 // Create project
340 let body = format!(
341 "slug={}&title=Seed+Project+{}",
342 urlencoding::encode(&slug),
343 i
344 );
345 let resp = client.post_form("/api/projects", &body).await;
346 assert_eq!(
347 resp.status, 200,
348 "Seed create project failed: {} {}",
349 resp.status, resp.text
350 );
351 let project: serde_json::Value = resp.json();
352 let project_id = project["id"].as_str().expect("project should have id");
353
354 // Make project public
355 client
356 .put_json(
357 &format!("/api/projects/{project_id}"),
358 r#"{"is_public": true}"#,
359 )
360 .await;
361
362 // Create 3 items per project
363 for j in 0..3 {
364 let item_body = format!("title=Seed+Item+{i}+{j}&price_cents=0&item_type=digital");
365 let resp = client
366 .post_form(&format!("/api/projects/{project_id}/items"), &item_body)
367 .await;
368 assert_eq!(
369 resp.status, 200,
370 "Seed create item failed: {} {}",
371 resp.status, resp.text
372 );
373 let item: serde_json::Value = resp.json();
374 let item_id = item["id"].as_str().expect("item should have id");
375
376 // Publish item
377 client
378 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
379 .await;
380
381 item_ids.push(item_id.to_string());
382 }
383
384 usernames.push(username);
385 project_slugs.push(slug);
386 }
387
388 SeedData {
389 usernames,
390 project_slugs,
391 item_ids,
392 }
393 }
394