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