Skip to main content

armature_testing/
load.rs

1//! Load Testing Utilities
2//!
3//! Provides performance testing and load generation capabilities.
4
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use thiserror::Error;
8use tokio::sync::Mutex;
9
10/// Load testing errors
11#[derive(Debug, Error)]
12pub enum LoadTestError {
13    #[error("Test failed: {0}")]
14    TestFailed(String),
15
16    #[error("Timeout: {0}")]
17    Timeout(String),
18}
19
20/// Load test statistics
21#[derive(Debug, Clone)]
22pub struct LoadTestStats {
23    /// Total number of requests
24    pub total_requests: u64,
25
26    /// Successful requests
27    pub successful: u64,
28
29    /// Failed requests
30    pub failed: u64,
31
32    /// Total duration
33    pub duration: Duration,
34
35    /// Min response time
36    pub min_response_time: Duration,
37
38    /// Max response time
39    pub max_response_time: Duration,
40
41    /// Average response time
42    pub avg_response_time: Duration,
43
44    /// Median response time (p50)
45    pub median_response_time: Duration,
46
47    /// 95th percentile response time
48    pub p95_response_time: Duration,
49
50    /// 99th percentile response time
51    pub p99_response_time: Duration,
52
53    /// Requests per second
54    pub rps: f64,
55}
56
57impl LoadTestStats {
58    /// Calculate statistics from response times
59    pub fn from_response_times(
60        response_times: &[Duration],
61        failed: u64,
62        total_duration: Duration,
63    ) -> Self {
64        let mut sorted = response_times.to_vec();
65        sorted.sort();
66
67        let total = response_times.len() as u64;
68        let sum: Duration = response_times.iter().sum();
69
70        let min = sorted.first().copied().unwrap_or_default();
71        let max = sorted.last().copied().unwrap_or_default();
72        let avg = if !sorted.is_empty() {
73            sum / sorted.len() as u32
74        } else {
75            Duration::default()
76        };
77
78        let median = if !sorted.is_empty() {
79            sorted[sorted.len() / 2]
80        } else {
81            Duration::default()
82        };
83
84        let p95_idx = (sorted.len() as f64 * 0.95) as usize;
85        let p95 = sorted.get(p95_idx).copied().unwrap_or(max);
86
87        let p99_idx = (sorted.len() as f64 * 0.99) as usize;
88        let p99 = sorted.get(p99_idx).copied().unwrap_or(max);
89
90        let rps = if total_duration.as_secs_f64() > 0.0 {
91            total as f64 / total_duration.as_secs_f64()
92        } else {
93            0.0
94        };
95
96        Self {
97            total_requests: total + failed,
98            successful: total,
99            failed,
100            duration: total_duration,
101            min_response_time: min,
102            max_response_time: max,
103            avg_response_time: avg,
104            median_response_time: median,
105            p95_response_time: p95,
106            p99_response_time: p99,
107            rps,
108        }
109    }
110
111    /// Print statistics
112    pub fn print(&self) {
113        println!("\n========== Load Test Results ==========");
114        println!("Total Requests:     {}", self.total_requests);
115        println!("Successful:         {}", self.successful);
116        println!("Failed:             {}", self.failed);
117        println!("Duration:           {:.2}s", self.duration.as_secs_f64());
118        println!("Requests/sec:       {:.2}", self.rps);
119        println!("\nResponse Times:");
120        println!(
121            "  Min:              {:.2}ms",
122            self.min_response_time.as_millis()
123        );
124        println!(
125            "  Avg:              {:.2}ms",
126            self.avg_response_time.as_millis()
127        );
128        println!(
129            "  Median (p50):     {:.2}ms",
130            self.median_response_time.as_millis()
131        );
132        println!(
133            "  p95:              {:.2}ms",
134            self.p95_response_time.as_millis()
135        );
136        println!(
137            "  p99:              {:.2}ms",
138            self.p99_response_time.as_millis()
139        );
140        println!(
141            "  Max:              {:.2}ms",
142            self.max_response_time.as_millis()
143        );
144        println!("=======================================\n");
145    }
146}
147
148/// Load test configuration
149#[derive(Debug, Clone)]
150pub struct LoadTestConfig {
151    /// Number of concurrent requests
152    pub concurrency: usize,
153
154    /// Total number of requests
155    pub total_requests: u64,
156
157    /// Duration of test (alternative to total_requests)
158    pub duration: Option<Duration>,
159
160    /// Requests per second limit
161    pub rate_limit: Option<f64>,
162
163    /// Timeout per request
164    pub timeout: Duration,
165}
166
167impl LoadTestConfig {
168    /// Create new load test config
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use armature_testing::load::LoadTestConfig;
174    ///
175    /// let config = LoadTestConfig::new(10, 1000);
176    /// ```
177    pub fn new(concurrency: usize, total_requests: u64) -> Self {
178        Self {
179            concurrency,
180            total_requests,
181            duration: None,
182            rate_limit: None,
183            timeout: Duration::from_secs(30),
184        }
185    }
186
187    /// Set test duration instead of request count
188    pub fn with_duration(mut self, duration: Duration) -> Self {
189        self.duration = Some(duration);
190        self
191    }
192
193    /// Set rate limit (requests per second)
194    pub fn with_rate_limit(mut self, rps: f64) -> Self {
195        self.rate_limit = Some(rps);
196        self
197    }
198
199    /// Set timeout per request
200    pub fn with_timeout(mut self, timeout: Duration) -> Self {
201        self.timeout = timeout;
202        self
203    }
204}
205
206impl Default for LoadTestConfig {
207    fn default() -> Self {
208        Self::new(10, 100)
209    }
210}
211
212/// Load test runner
213pub struct LoadTestRunner<F, Fut>
214where
215    F: Fn() -> Fut + Send + Sync + Clone + 'static,
216    Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
217{
218    config: LoadTestConfig,
219    test_fn: Arc<F>,
220}
221
222impl<F, Fut> LoadTestRunner<F, Fut>
223where
224    F: Fn() -> Fut + Send + Sync + Clone + 'static,
225    Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
226{
227    /// Create new load test runner
228    ///
229    /// # Examples
230    ///
231    /// ```rust,ignore
232    /// use armature_testing::load::*;
233    ///
234    /// let config = LoadTestConfig::new(10, 100);
235    /// let runner = LoadTestRunner::new(config, || async {
236    ///     // Your test code here
237    ///     Ok(())
238    /// });
239    /// ```
240    pub fn new(config: LoadTestConfig, test_fn: F) -> Self {
241        Self {
242            config,
243            test_fn: Arc::new(test_fn),
244        }
245    }
246
247    /// Run load test
248    pub async fn run(&self) -> Result<LoadTestStats, LoadTestError> {
249        let start_time = Instant::now();
250        let response_times = Arc::new(Mutex::new(Vec::new()));
251        let failed_count = Arc::new(Mutex::new(0u64));
252
253        let mut handles = vec![];
254
255        let concurrency = self.config.concurrency as u64;
256
257        // Requests-per-second pacing, applied per worker: to make the
258        // *aggregate* rate across all `concurrency` workers converge on
259        // `rate_limit`, each worker must space its own requests
260        // `concurrency / rate_limit` seconds apart.
261        let min_interval = self
262            .config
263            .rate_limit
264            .filter(|rps| *rps > 0.0)
265            .map(|rps| Duration::from_secs_f64(concurrency.max(1) as f64 / rps));
266
267        for worker_idx in 0..self.config.concurrency {
268            let test_fn = self.test_fn.clone();
269            let response_times = response_times.clone();
270            let failed_count = failed_count.clone();
271            let timeout = self.config.timeout;
272            let duration = self.config.duration;
273
274            // Distribute `total_requests` across workers exactly: give the
275            // first `total_requests % concurrency` workers one extra
276            // request. This same formula also guards `concurrency >
277            // total_requests` for free — e.g. `new(10, 5)` yields base = 0,
278            // remainder = 5, so workers 0..5 run 1 request each and workers
279            // 5..10 run 0, for an exact total of 5 (not the previous 0 from
280            // `5 / 10 == 0` applied uniformly to every worker).
281            let requests_for_this_worker = if duration.is_some() {
282                None
283            } else {
284                let base = self.config.total_requests / concurrency.max(1);
285                let remainder = self.config.total_requests % concurrency.max(1);
286                Some(base + u64::from((worker_idx as u64) < remainder))
287            };
288
289            let handle = tokio::spawn(async move {
290                let worker_start = Instant::now();
291                let mut request_count = 0u64;
292
293                // Whether the worker should stop *before* starting another
294                // request, given how many it has completed so far. Shared
295                // between the top-of-loop break check and the post-request
296                // pacing check below, so both agree on what counts as "the
297                // last request".
298                let should_stop = |request_count: u64| -> bool {
299                    if let Some(duration) = duration {
300                        worker_start.elapsed() >= duration
301                    } else if let Some(max_requests) = requests_for_this_worker {
302                        request_count >= max_requests
303                    } else {
304                        false
305                    }
306                };
307
308                loop {
309                    // Check if we should stop
310                    if should_stop(request_count) {
311                        break;
312                    }
313
314                    // Execute test function
315                    let req_start = Instant::now();
316                    let result = tokio::time::timeout(timeout, test_fn()).await;
317
318                    match result {
319                        Ok(Ok(())) => {
320                            let elapsed = req_start.elapsed();
321                            response_times.lock().await.push(elapsed);
322                        }
323                        _ => {
324                            *failed_count.lock().await += 1;
325                        }
326                    }
327
328                    request_count += 1;
329
330                    // Pace to the configured rate limit, if any — but skip
331                    // the sleep after what will be this worker's last
332                    // request. N requests need only N-1 gaps *between* them;
333                    // sleeping after the final one just adds a trailing
334                    // delay that inflates duration-based runs by one
335                    // interval per worker for no benefit (nothing follows
336                    // it to be paced against).
337                    if !should_stop(request_count)
338                        && let Some(interval) = min_interval
339                    {
340                        let elapsed = req_start.elapsed();
341                        if elapsed < interval {
342                            tokio::time::sleep(interval - elapsed).await;
343                        }
344                    }
345                }
346            });
347
348            handles.push(handle);
349        }
350
351        // Wait for all workers to complete
352        for handle in handles {
353            let _ = handle.await;
354        }
355
356        let total_duration = start_time.elapsed();
357        let response_times = response_times.lock().await;
358        let failed = *failed_count.lock().await;
359
360        Ok(LoadTestStats::from_response_times(
361            &response_times,
362            failed,
363            total_duration,
364        ))
365    }
366}
367
368/// Stress test runner (gradually increases load)
369pub struct StressTestRunner<F, Fut>
370where
371    F: Fn() -> Fut + Send + Sync + Clone + 'static,
372    Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
373{
374    initial_concurrency: usize,
375    max_concurrency: usize,
376    step_size: usize,
377    step_duration: Duration,
378    test_fn: Arc<F>,
379}
380
381impl<F, Fut> StressTestRunner<F, Fut>
382where
383    F: Fn() -> Fut + Send + Sync + Clone + 'static,
384    Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
385{
386    /// Create new stress test runner
387    ///
388    /// # Examples
389    ///
390    /// ```rust,ignore
391    /// use armature_testing::load::*;
392    /// use std::time::Duration;
393    ///
394    /// let runner = StressTestRunner::new(1, 100, 10, Duration::from_secs(10), || async {
395    ///     // Your test code
396    ///     Ok(())
397    /// });
398    /// ```
399    pub fn new(
400        initial_concurrency: usize,
401        max_concurrency: usize,
402        step_size: usize,
403        step_duration: Duration,
404        test_fn: F,
405    ) -> Self {
406        Self {
407            initial_concurrency,
408            max_concurrency,
409            step_size,
410            step_duration,
411            test_fn: Arc::new(test_fn),
412        }
413    }
414
415    /// Run stress test
416    pub async fn run(&self) -> Result<Vec<(usize, LoadTestStats)>, LoadTestError> {
417        let mut results = vec![];
418        let mut concurrency = self.initial_concurrency;
419
420        println!("\n========== Stress Test Starting ==========");
421        println!("Initial Concurrency: {}", self.initial_concurrency);
422        println!("Max Concurrency:     {}", self.max_concurrency);
423        println!("Step Size:           {}", self.step_size);
424        println!(
425            "Step Duration:       {:.0}s",
426            self.step_duration.as_secs_f64()
427        );
428        println!("==========================================\n");
429
430        while concurrency <= self.max_concurrency {
431            println!("Testing with {} concurrent requests...", concurrency);
432
433            let config =
434                LoadTestConfig::new(concurrency, u64::MAX).with_duration(self.step_duration);
435
436            let runner = LoadTestRunner::new(config, self.test_fn.as_ref().clone());
437            let stats = runner.run().await?;
438
439            println!(
440                "  RPS: {:.2}, Avg: {:.2}ms, p95: {:.2}ms",
441                stats.rps,
442                stats.avg_response_time.as_millis(),
443                stats.p95_response_time.as_millis()
444            );
445
446            results.push((concurrency, stats));
447            concurrency += self.step_size;
448        }
449
450        println!("\n========== Stress Test Complete ==========\n");
451
452        Ok(results)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_load_test_config() {
462        let config = LoadTestConfig::new(10, 100)
463            .with_rate_limit(50.0)
464            .with_timeout(Duration::from_secs(5));
465
466        assert_eq!(config.concurrency, 10);
467        assert_eq!(config.total_requests, 100);
468        assert_eq!(config.rate_limit, Some(50.0));
469    }
470
471    #[test]
472    fn test_load_test_stats() {
473        let response_times = vec![
474            Duration::from_millis(100),
475            Duration::from_millis(200),
476            Duration::from_millis(150),
477            Duration::from_millis(300),
478            Duration::from_millis(250),
479        ];
480
481        let stats = LoadTestStats::from_response_times(&response_times, 0, Duration::from_secs(1));
482
483        assert_eq!(stats.total_requests, 5);
484        assert_eq!(stats.successful, 5);
485        assert_eq!(stats.failed, 0);
486        assert_eq!(stats.min_response_time, Duration::from_millis(100));
487        assert_eq!(stats.max_response_time, Duration::from_millis(300));
488    }
489
490    #[tokio::test]
491    async fn test_load_test_runner() {
492        let config = LoadTestConfig::new(2, 10);
493
494        let runner = LoadTestRunner::new(config, || async {
495            tokio::time::sleep(Duration::from_millis(10)).await;
496            Ok(())
497        });
498
499        let stats = runner.run().await.unwrap();
500        assert_eq!(stats.total_requests, 10);
501        assert_eq!(stats.successful, 10);
502    }
503
504    #[tokio::test]
505    async fn run_executes_exactly_total_requests_when_not_evenly_divisible() {
506        // 100 / 3 == 33 with integer division; the old code ran
507        // 33 * 3 == 99, dropping one request. The remainder must be
508        // distributed across workers so all 100 actually run.
509        use std::sync::atomic::{AtomicU64, Ordering};
510        static COUNTER: AtomicU64 = AtomicU64::new(0);
511        COUNTER.store(0, Ordering::SeqCst);
512
513        let config = LoadTestConfig::new(3, 100);
514        let runner = LoadTestRunner::new(config, || async {
515            COUNTER.fetch_add(1, Ordering::SeqCst);
516            Ok(())
517        });
518
519        let stats = runner.run().await.unwrap();
520        assert_eq!(COUNTER.load(Ordering::SeqCst), 100);
521        assert_eq!(stats.total_requests, 100);
522    }
523
524    #[tokio::test]
525    async fn run_executes_exactly_total_requests_when_concurrency_exceeds_total() {
526        // concurrency(5) > total_requests(2): old integer-division code
527        // computed 2 / 5 == 0 requests per worker, so nothing ran at all.
528        use std::sync::atomic::{AtomicU64, Ordering};
529        static COUNTER: AtomicU64 = AtomicU64::new(0);
530        COUNTER.store(0, Ordering::SeqCst);
531
532        let config = LoadTestConfig::new(5, 2);
533        let runner = LoadTestRunner::new(config, || async {
534            COUNTER.fetch_add(1, Ordering::SeqCst);
535            Ok(())
536        });
537
538        let stats = runner.run().await.unwrap();
539        assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
540        assert_eq!(stats.total_requests, 2);
541    }
542
543    #[tokio::test]
544    async fn run_honors_rate_limit_by_pacing_requests() {
545        // 1 worker, rate-limited to 20 req/s => ~50ms between requests.
546        // 4 requests means 3 inter-request gaps (no trailing sleep after the
547        // last request), so the run should take at least ~150ms. An
548        // unthrottled run of near-instant requests would finish in well
549        // under a millisecond, and the old N-sleeps-for-N-requests bug would
550        // take ~200ms (4 gaps, including a pointless trailing one), so a
551        // window of [140ms, 195ms) reliably distinguishes "3 gaps honored"
552        // from both "rate_limit ignored" and "4 sleeps applied" without
553        // being tight enough to flake under CI scheduling jitter: 140ms
554        // gives ~10ms of slack under the 150ms target, and 195ms stays
555        // under the ~200ms the bug would produce.
556        let config = LoadTestConfig::new(1, 4).with_rate_limit(20.0);
557        let runner = LoadTestRunner::new(config, || async { Ok(()) });
558
559        let start = Instant::now();
560        let stats = runner.run().await.unwrap();
561        let elapsed = start.elapsed();
562
563        assert_eq!(stats.total_requests, 4);
564        assert!(
565            elapsed >= Duration::from_millis(140) && elapsed < Duration::from_millis(195),
566            "expected pacing to take ~150ms for 3 gaps at 50ms (not ~200ms for 4 sleeps), took {elapsed:?}"
567        );
568    }
569}