//! Load test configuration and scenario distribution. use std::time::Duration; /// Top-level configuration for a load test run. pub(super) struct LoadConfig { /// Number of concurrent virtual users. pub virtual_users: u32, /// Total duration of the test. pub duration: Duration, /// Time to linearly ramp up all VUs. pub ramp_up: Duration, /// Pause between requests within a scenario loop. pub think_time: Duration, /// Max DB connections for the production-sized pool. pub db_max_connections: u32, /// DB connection acquire timeout. pub db_acquire_timeout: Duration, /// Scenario distribution across VUs. pub scenario_mix: ScenarioMix, /// How long the Multithreaded stub sleeps before answering. /// /// The variable the forum-membership measurement turns. Those two screens /// are the only described ones whose work is an outbound call, and the /// described version holds a blocking-pool thread across it. Sweeping this /// is how the run answers "at what upstream latency does occupancy start to /// cost something" instead of "was it fine on the afternoon we looked". pub mt_latency: Duration, /// How many membership rows the stub answers with. pub mt_memberships: usize, /// How often the blocking-pool dispatch probe samples. pub probe_interval: Duration, } impl LoadConfig { /// Build config from env vars, falling back to defaults. pub(super) fn from_env() -> Self { let virtual_users = env_or("LOAD_VUS", 20); let duration = Duration::from_secs(env_or("LOAD_DURATION_SECS", 30)); let ramp_up = Duration::from_secs(env_or("LOAD_RAMP_SECS", 5)); let think_time = Duration::from_millis(env_or("LOAD_THINK_MS", 50)); LoadConfig { virtual_users, duration, ramp_up, think_time, db_max_connections: 10, db_acquire_timeout: Duration::from_secs(3), scenario_mix: ScenarioMix::from_env(), mt_latency: Duration::from_millis(env_or("LOAD_MT_LATENCY_MS", 0)), mt_memberships: env_or("LOAD_MT_MEMBERSHIPS", 8), probe_interval: Duration::from_millis(env_or("LOAD_PROBE_MS", 50)), } } } /// Percentage-based scenario distribution. Must sum to 100. #[derive(Debug)] pub(super) struct ScenarioMix { pub anonymous_browse: u32, pub buyer_flow: u32, pub creator_flow: u32, pub dashboard_session: u32, } impl Default for ScenarioMix { fn default() -> Self { ScenarioMix { anonymous_browse: 60, buyer_flow: 20, creator_flow: 15, dashboard_session: 5, } } } impl ScenarioMix { /// Read the mix from the environment, falling back to the default shape. /// /// `LOAD_MIX=anon:20,buyer:10,creator:10,dash:60`. The default is what a /// normal day is thought to look like and is the right thing to measure the /// server against; it is the wrong thing to measure ONE ROUTE with, because /// 5% of 20 virtual users is one, and one user reaches no contention at all. /// /// Added for the S3 conversion measurement (wiki /// `mnw-server-conversion-plan`), where the question is what a described /// route does to everything else: the router is sync, so quasi-axum /// dispatches on `spawn_blocking`, and this server shares that pool with /// argon2 hashing, content exports and the file scanner. Turning the /// dashboard share up is how the described route is given enough /// concurrency to show whether it starves them. /// /// Panics on a mix that does not sum to 100, rather than silently /// renormalising: a measurement run under a mix nobody meant is worse than /// one that refused to start. pub(super) fn from_env() -> Self { let Ok(raw) = std::env::var("LOAD_MIX") else { return Self::default(); }; let mut mix = ScenarioMix { anonymous_browse: 0, buyer_flow: 0, creator_flow: 0, dashboard_session: 0, }; for part in raw.split(',') { let (name, value) = part .trim() .split_once(':') .unwrap_or_else(|| panic!("LOAD_MIX entry {part:?} is not name:percent")); let value: u32 = value .trim() .parse() .unwrap_or_else(|_| panic!("LOAD_MIX entry {part:?} has a non-numeric percent")); match name.trim() { "anon" => mix.anonymous_browse = value, "buyer" => mix.buyer_flow = value, "creator" => mix.creator_flow = value, "dash" => mix.dashboard_session = value, other => panic!("LOAD_MIX names anon, buyer, creator, dash; got {other:?}"), } } let total = mix.anonymous_browse + mix.buyer_flow + mix.creator_flow + mix.dashboard_session; assert_eq!(total, 100, "LOAD_MIX must sum to 100, got {total}"); mix } } impl ScenarioMix { /// Deterministically assign a scenario to a VU based on its index. pub(super) fn assign_scenario(&self, vu_index: u32, total_vus: u32) -> ScenarioType { // Map the VU index to a percentage position (0..100) let pct = (vu_index as u64 * 100 / total_vus as u64) as u32; if pct < self.anonymous_browse { ScenarioType::AnonymousBrowse } else if pct < self.anonymous_browse + self.buyer_flow { ScenarioType::BuyerFlow } else if pct < self.anonymous_browse + self.buyer_flow + self.creator_flow { ScenarioType::CreatorFlow } else { ScenarioType::DashboardSession } } } #[derive(Debug, Clone, Copy)] pub(super) enum ScenarioType { AnonymousBrowse, BuyerFlow, CreatorFlow, DashboardSession, } impl std::fmt::Display for ScenarioType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ScenarioType::AnonymousBrowse => write!(f, "anonymous_browse"), ScenarioType::BuyerFlow => write!(f, "buyer_flow"), ScenarioType::CreatorFlow => write!(f, "creator_flow"), ScenarioType::DashboardSession => write!(f, "dashboard_session"), } } } fn env_or(key: &str, default: T) -> T { std::env::var(key) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(default) }