/home/noah/src/realizar/src/bench/load_testing.rs
Line | Count | Source |
1 | | //! Load testing framework for stress testing inference servers |
2 | | //! |
3 | | //! Extracted from bench/mod.rs (PMAT-802) to reduce module size. |
4 | | //! Contains: |
5 | | //! - LoadTestConfig for wrk2-style load testing configuration |
6 | | //! - LoadTestResult for test outcomes |
7 | | //! - LoadTestRunner for executing load tests |
8 | | |
9 | | #![allow(clippy::cast_precision_loss)] |
10 | | |
11 | | use serde::{Deserialize, Serialize}; |
12 | | |
13 | | |
14 | | // ============================================================================ |
15 | | // Load Testing (Section 14.1) |
16 | | // ============================================================================ |
17 | | |
18 | | /// Configuration for load testing |
19 | | /// |
20 | | /// Per spec ยง14: Implements wrk2-style load testing with configurable |
21 | | /// concurrency, duration, and target rates. |
22 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
23 | | pub struct LoadTestConfig { |
24 | | /// Number of concurrent connections/threads |
25 | | pub concurrency: usize, |
26 | | /// Test duration in seconds |
27 | | pub duration_secs: u64, |
28 | | /// Target requests per second (0 = unlimited) |
29 | | pub target_rps: f64, |
30 | | /// Request timeout in milliseconds |
31 | | pub timeout_ms: u64, |
32 | | /// Warm-up period in seconds |
33 | | pub warmup_secs: u64, |
34 | | /// Target latency threshold (p99) in milliseconds |
35 | | pub latency_threshold_ms: f64, |
36 | | } |
37 | | |
38 | | impl Default for LoadTestConfig { |
39 | 4 | fn default() -> Self { |
40 | 4 | Self { |
41 | 4 | concurrency: 10, |
42 | 4 | duration_secs: 60, |
43 | 4 | target_rps: 0.0, // Unlimited |
44 | 4 | timeout_ms: 5000, |
45 | 4 | warmup_secs: 5, |
46 | 4 | latency_threshold_ms: 500.0, // Per spec: <500ms p99 target |
47 | 4 | } |
48 | 4 | } |
49 | | } |
50 | | |
51 | | impl LoadTestConfig { |
52 | | /// Create config for stress testing |
53 | | #[must_use] |
54 | 1 | pub fn for_stress_test() -> Self { |
55 | 1 | Self { |
56 | 1 | concurrency: 100, |
57 | 1 | duration_secs: 300, |
58 | 1 | target_rps: 0.0, |
59 | 1 | timeout_ms: 10_000, |
60 | 1 | warmup_secs: 10, |
61 | 1 | latency_threshold_ms: 1000.0, |
62 | 1 | } |
63 | 1 | } |
64 | | |
65 | | /// Create config for latency-focused testing |
66 | | #[must_use] |
67 | 1 | pub fn for_latency_test() -> Self { |
68 | 1 | Self { |
69 | 1 | concurrency: 1, |
70 | 1 | duration_secs: 60, |
71 | 1 | target_rps: 10.0, // Fixed rate |
72 | 1 | timeout_ms: 2000, |
73 | 1 | warmup_secs: 5, |
74 | 1 | latency_threshold_ms: 200.0, |
75 | 1 | } |
76 | 1 | } |
77 | | |
78 | | /// Validate the configuration |
79 | | #[must_use] |
80 | 2 | pub fn is_valid(&self) -> bool { |
81 | 2 | self.concurrency > 0 |
82 | 1 | && self.duration_secs > 0 |
83 | 1 | && self.timeout_ms > 0 |
84 | 1 | && self.latency_threshold_ms > 0.0 |
85 | 2 | } |
86 | | } |
87 | | |
88 | | /// Results from a load test run |
89 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
90 | | pub struct LoadTestResult { |
91 | | /// Total requests made |
92 | | pub total_requests: usize, |
93 | | /// Successful requests |
94 | | pub successful_requests: usize, |
95 | | /// Failed requests |
96 | | pub failed_requests: usize, |
97 | | /// Requests per second (achieved) |
98 | | pub rps_achieved: f64, |
99 | | /// Latency percentiles in milliseconds |
100 | | pub latency_p50_ms: f64, |
101 | | /// Latency p95 in milliseconds |
102 | | pub latency_p95_ms: f64, |
103 | | /// Latency p99 in milliseconds |
104 | | pub latency_p99_ms: f64, |
105 | | /// Maximum latency in milliseconds |
106 | | pub latency_max_ms: f64, |
107 | | /// Total data transferred in bytes |
108 | | pub data_transferred_bytes: u64, |
109 | | /// Test duration in seconds |
110 | | pub duration_secs: f64, |
111 | | /// Error rate (0.0-1.0) |
112 | | pub error_rate: f64, |
113 | | /// Whether the test passed the latency threshold |
114 | | pub passed_latency_threshold: bool, |
115 | | } |
116 | | |
117 | | impl LoadTestResult { |
118 | | /// Check if the load test passed all thresholds |
119 | | #[must_use] |
120 | 3 | pub fn is_passing(&self) -> bool { |
121 | 3 | self.passed_latency_threshold && self.error_rate < 0.012 // <1% error rate |
122 | 3 | } |
123 | | |
124 | | /// Calculate throughput in MB/s |
125 | | #[must_use] |
126 | 1 | pub fn throughput_mbps(&self) -> f64 { |
127 | 1 | if self.duration_secs > 0.0 { |
128 | 1 | (self.data_transferred_bytes as f64 / 1_000_000.0) / self.duration_secs |
129 | | } else { |
130 | 0 | 0.0 |
131 | | } |
132 | 1 | } |
133 | | } |
134 | | |
135 | | /// Load test runner |
136 | | #[derive(Debug)] |
137 | | pub struct LoadTestRunner { |
138 | | config: LoadTestConfig, |
139 | | } |
140 | | |
141 | | impl LoadTestRunner { |
142 | | /// Create a new load test runner |
143 | | #[must_use] |
144 | 1 | pub fn new(config: LoadTestConfig) -> Self { |
145 | 1 | Self { config } |
146 | 1 | } |
147 | | |
148 | | /// Get the configuration |
149 | | #[must_use] |
150 | 0 | pub fn config(&self) -> &LoadTestConfig { |
151 | 0 | &self.config |
152 | 0 | } |
153 | | |
154 | | /// Simulate a load test run (for testing purposes) |
155 | | /// |
156 | | /// In production, this would make actual HTTP requests. |
157 | | #[must_use] |
158 | 1 | pub fn simulate_run(&self) -> LoadTestResult { |
159 | | // Simulate based on configuration |
160 | 1 | let total_requests = |
161 | 1 | (self.config.concurrency as f64 * self.config.duration_secs as f64 * 10.0) as usize; |
162 | 1 | let error_count = total_requests / 100; // 1% error rate |
163 | 1 | let successful = total_requests - error_count; |
164 | | |
165 | | // Simulate latencies based on concurrency |
166 | | // Higher concurrency = higher latencies |
167 | 1 | let base_latency = 20.0; // 20ms base |
168 | 1 | let concurrency_factor = (self.config.concurrency as f64).ln(); |
169 | | |
170 | 1 | let p50 = base_latency + concurrency_factor * 5.0; |
171 | 1 | let p95 = p50 * 2.5; |
172 | 1 | let p99 = p50 * 4.0; |
173 | 1 | let max = p99 * 2.0; |
174 | | |
175 | 1 | let duration = self.config.duration_secs as f64; |
176 | 1 | let rps = if duration > 0.0 { |
177 | 1 | total_requests as f64 / duration |
178 | | } else { |
179 | 0 | 0.0 |
180 | | }; |
181 | | |
182 | 1 | LoadTestResult { |
183 | 1 | total_requests, |
184 | 1 | successful_requests: successful, |
185 | 1 | failed_requests: error_count, |
186 | 1 | rps_achieved: rps, |
187 | 1 | latency_p50_ms: p50, |
188 | 1 | latency_p95_ms: p95, |
189 | 1 | latency_p99_ms: p99, |
190 | 1 | latency_max_ms: max, |
191 | 1 | data_transferred_bytes: (total_requests * 1024) as u64, // ~1KB per request |
192 | 1 | duration_secs: duration, |
193 | 1 | error_rate: error_count as f64 / total_requests as f64, |
194 | 1 | passed_latency_threshold: p99 < self.config.latency_threshold_ms, |
195 | 1 | } |
196 | 1 | } |
197 | | } |