Skip to main content

max / makenotwork

6.4 KB · 174 lines History Blame Raw
1 //! Load test configuration and scenario distribution.
2
3 use std::time::Duration;
4
5 /// Top-level configuration for a load test run.
6 pub(super) struct LoadConfig {
7 /// Number of concurrent virtual users.
8 pub virtual_users: u32,
9 /// Total duration of the test.
10 pub duration: Duration,
11 /// Time to linearly ramp up all VUs.
12 pub ramp_up: Duration,
13 /// Pause between requests within a scenario loop.
14 pub think_time: Duration,
15 /// Max DB connections for the production-sized pool.
16 pub db_max_connections: u32,
17 /// DB connection acquire timeout.
18 pub db_acquire_timeout: Duration,
19 /// Scenario distribution across VUs.
20 pub scenario_mix: ScenarioMix,
21 /// How long the Multithreaded stub sleeps before answering.
22 ///
23 /// The variable the forum-membership measurement turns. Those two screens
24 /// are the only described ones whose work is an outbound call, and the
25 /// described version holds a blocking-pool thread across it. Sweeping this
26 /// is how the run answers "at what upstream latency does occupancy start to
27 /// cost something" instead of "was it fine on the afternoon we looked".
28 pub mt_latency: Duration,
29 /// How many membership rows the stub answers with.
30 pub mt_memberships: usize,
31 /// How often the blocking-pool dispatch probe samples.
32 pub probe_interval: Duration,
33 }
34
35 impl LoadConfig {
36 /// Build config from env vars, falling back to defaults.
37 pub(super) fn from_env() -> Self {
38 let virtual_users = env_or("LOAD_VUS", 20);
39 let duration = Duration::from_secs(env_or("LOAD_DURATION_SECS", 30));
40 let ramp_up = Duration::from_secs(env_or("LOAD_RAMP_SECS", 5));
41 let think_time = Duration::from_millis(env_or("LOAD_THINK_MS", 50));
42
43 LoadConfig {
44 virtual_users,
45 duration,
46 ramp_up,
47 think_time,
48 db_max_connections: 10,
49 db_acquire_timeout: Duration::from_secs(3),
50 scenario_mix: ScenarioMix::from_env(),
51 mt_latency: Duration::from_millis(env_or("LOAD_MT_LATENCY_MS", 0)),
52 mt_memberships: env_or("LOAD_MT_MEMBERSHIPS", 8),
53 probe_interval: Duration::from_millis(env_or("LOAD_PROBE_MS", 50)),
54 }
55 }
56 }
57
58 /// Percentage-based scenario distribution. Must sum to 100.
59 #[derive(Debug)]
60 pub(super) struct ScenarioMix {
61 pub anonymous_browse: u32,
62 pub buyer_flow: u32,
63 pub creator_flow: u32,
64 pub dashboard_session: u32,
65 }
66
67 impl Default for ScenarioMix {
68 fn default() -> Self {
69 ScenarioMix {
70 anonymous_browse: 60,
71 buyer_flow: 20,
72 creator_flow: 15,
73 dashboard_session: 5,
74 }
75 }
76 }
77
78 impl ScenarioMix {
79 /// Read the mix from the environment, falling back to the default shape.
80 ///
81 /// `LOAD_MIX=anon:20,buyer:10,creator:10,dash:60`. The default is what a
82 /// normal day is thought to look like and is the right thing to measure the
83 /// server against; it is the wrong thing to measure ONE ROUTE with, because
84 /// 5% of 20 virtual users is one, and one user reaches no contention at all.
85 ///
86 /// Added for the S3 conversion measurement (wiki
87 /// `mnw-server-conversion-plan`), where the question is what a described
88 /// route does to everything else: the router is sync, so quasi-axum
89 /// dispatches on `spawn_blocking`, and this server shares that pool with
90 /// argon2 hashing, content exports and the file scanner. Turning the
91 /// dashboard share up is how the described route is given enough
92 /// concurrency to show whether it starves them.
93 ///
94 /// Panics on a mix that does not sum to 100, rather than silently
95 /// renormalising: a measurement run under a mix nobody meant is worse than
96 /// one that refused to start.
97 pub(super) fn from_env() -> Self {
98 let Ok(raw) = std::env::var("LOAD_MIX") else {
99 return Self::default();
100 };
101 let mut mix = ScenarioMix {
102 anonymous_browse: 0,
103 buyer_flow: 0,
104 creator_flow: 0,
105 dashboard_session: 0,
106 };
107 for part in raw.split(',') {
108 let (name, value) = part
109 .trim()
110 .split_once(':')
111 .unwrap_or_else(|| panic!("LOAD_MIX entry {part:?} is not name:percent"));
112 let value: u32 = value
113 .trim()
114 .parse()
115 .unwrap_or_else(|_| panic!("LOAD_MIX entry {part:?} has a non-numeric percent"));
116 match name.trim() {
117 "anon" => mix.anonymous_browse = value,
118 "buyer" => mix.buyer_flow = value,
119 "creator" => mix.creator_flow = value,
120 "dash" => mix.dashboard_session = value,
121 other => panic!("LOAD_MIX names anon, buyer, creator, dash; got {other:?}"),
122 }
123 }
124 let total =
125 mix.anonymous_browse + mix.buyer_flow + mix.creator_flow + mix.dashboard_session;
126 assert_eq!(total, 100, "LOAD_MIX must sum to 100, got {total}");
127 mix
128 }
129 }
130
131 impl ScenarioMix {
132 /// Deterministically assign a scenario to a VU based on its index.
133 pub(super) fn assign_scenario(&self, vu_index: u32, total_vus: u32) -> ScenarioType {
134 // Map the VU index to a percentage position (0..100)
135 let pct = (vu_index as u64 * 100 / total_vus as u64) as u32;
136
137 if pct < self.anonymous_browse {
138 ScenarioType::AnonymousBrowse
139 } else if pct < self.anonymous_browse + self.buyer_flow {
140 ScenarioType::BuyerFlow
141 } else if pct < self.anonymous_browse + self.buyer_flow + self.creator_flow {
142 ScenarioType::CreatorFlow
143 } else {
144 ScenarioType::DashboardSession
145 }
146 }
147 }
148
149 #[derive(Debug, Clone, Copy)]
150 pub(super) enum ScenarioType {
151 AnonymousBrowse,
152 BuyerFlow,
153 CreatorFlow,
154 DashboardSession,
155 }
156
157 impl std::fmt::Display for ScenarioType {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 ScenarioType::AnonymousBrowse => write!(f, "anonymous_browse"),
161 ScenarioType::BuyerFlow => write!(f, "buyer_flow"),
162 ScenarioType::CreatorFlow => write!(f, "creator_flow"),
163 ScenarioType::DashboardSession => write!(f, "dashboard_session"),
164 }
165 }
166 }
167
168 fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
169 std::env::var(key)
170 .ok()
171 .and_then(|v| v.parse().ok())
172 .unwrap_or(default)
173 }
174