Files
foxhunt/testing/stress/tests/sustained_load_stress.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

447 lines
14 KiB
Rust

//! Sustained Load Stress Tests
//!
//! Tests system behavior under sustained high load over extended periods.
//! - 1 hour sustained load at 50K orders/sec
//! - 24 hour soak test at 10K orders/sec
//! - Memory leak detection
//! - Connection pool stability
//! - Database performance degradation monitoring
use anyhow::Result;
use hdrhistogram::Histogram;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::task::JoinSet;
use tokio::time::interval;
use tracing::{error, info};
/// Metrics for sustained load testing
#[derive(Debug, Clone)]
pub struct SustainedLoadMetrics {
/// Total requests sent
pub total_requests: u64,
/// Successful requests
pub successful_requests: u64,
/// Failed requests
pub failed_requests: u64,
/// Throughput samples (requests/sec per interval)
pub throughput_samples: Vec<f64>,
/// Memory usage samples (bytes)
pub memory_samples: Vec<u64>,
/// Latency histogram
pub latency_histogram: Histogram<u64>,
/// Connection pool size samples
pub connection_pool_samples: Vec<usize>,
/// Database query time samples (microseconds)
pub db_query_times: Vec<u64>,
/// Test duration
pub duration: Duration,
}
impl Default for SustainedLoadMetrics {
fn default() -> Self {
Self::new()
}
}
impl SustainedLoadMetrics {
pub fn new() -> Self {
Self {
total_requests: 0,
successful_requests: 0,
failed_requests: 0,
throughput_samples: Vec::new(),
memory_samples: Vec::new(),
latency_histogram: Histogram::<u64>::new_with_bounds(1, 60_000_000, 3).unwrap(),
connection_pool_samples: Vec::new(),
db_query_times: Vec::new(),
duration: Duration::ZERO,
}
}
/// Check for performance degradation over time
pub fn detect_degradation(&self, threshold_percent: f64) -> bool {
if self.throughput_samples.len() < 10 {
return false;
}
// Compare first 10% vs last 10% of samples
let sample_count = self.throughput_samples.len();
let first_10_pct = &self.throughput_samples[..sample_count / 10];
let last_10_pct = &self.throughput_samples[sample_count * 9 / 10..];
let avg_first: f64 = first_10_pct.iter().sum::<f64>() / first_10_pct.len() as f64;
let avg_last: f64 = last_10_pct.iter().sum::<f64>() / last_10_pct.len() as f64;
let degradation = (avg_first - avg_last) / avg_first * 100.0;
degradation > threshold_percent
}
/// Check for memory leaks
pub fn detect_memory_leak(&self, growth_threshold_mb: f64) -> bool {
if self.memory_samples.len() < 10 {
return false;
}
let first_mb = self.memory_samples[0] as f64 / 1_048_576.0;
let last_mb = *self.memory_samples.last().expect("INVARIANT: Collection should be non-empty") as f64 / 1_048_576.0;
let growth = last_mb - first_mb;
growth > growth_threshold_mb
}
/// Calculate average throughput
pub fn avg_throughput(&self) -> f64 {
if self.throughput_samples.is_empty() {
return 0.0;
}
self.throughput_samples.iter().sum::<f64>() / self.throughput_samples.len() as f64
}
/// Calculate p99 latency
pub fn p99_latency_us(&self) -> u64 {
self.latency_histogram.value_at_quantile(0.99)
}
/// Calculate success rate
pub fn success_rate(&self) -> f64 {
if self.total_requests == 0 {
return 0.0;
}
(self.successful_requests as f64 / self.total_requests as f64) * 100.0
}
}
/// Sustained load test runner
pub struct SustainedLoadTest {
/// Target throughput (requests per second)
target_rps: usize,
/// Test duration
duration: Duration,
/// Number of concurrent clients
concurrent_clients: usize,
/// Metrics collection
metrics: Arc<parking_lot::Mutex<SustainedLoadMetrics>>,
/// Total requests counter
request_counter: Arc<AtomicU64>,
/// Success counter
success_counter: Arc<AtomicU64>,
}
impl SustainedLoadTest {
/// Create a new sustained load test
pub fn new(target_rps: usize, duration: Duration, concurrent_clients: usize) -> Self {
Self {
target_rps,
duration,
concurrent_clients,
metrics: Arc::new(parking_lot::Mutex::new(SustainedLoadMetrics::new())),
request_counter: Arc::new(AtomicU64::new(0)),
success_counter: Arc::new(AtomicU64::new(0)),
}
}
/// Run the sustained load test
///
/// # Errors
/// Returns error if the operation fails
pub async fn run(&self) -> Result<SustainedLoadMetrics> {
info!(
"Starting sustained load test: {} req/sec for {:?}",
self.target_rps, self.duration
);
let start = Instant::now();
let mut join_set = JoinSet::new();
// Spawn client tasks
let requests_per_client = self.target_rps / self.concurrent_clients;
let delay_between_requests = Duration::from_millis(1000 / requests_per_client as u64);
for client_id in 0..self.concurrent_clients {
let duration = self.duration;
let delay = delay_between_requests;
let request_counter = Arc::clone(&self.request_counter);
let success_counter = Arc::clone(&self.success_counter);
let metrics = Arc::clone(&self.metrics);
join_set.spawn(async move {
Self::client_workload(
client_id,
duration,
delay,
request_counter,
success_counter,
metrics,
)
.await
});
}
// Spawn monitoring task
let monitoring_handle = self.spawn_monitoring_task(start);
// Wait for all clients to complete
while let Some(result) = join_set.join_next().await {
if let Err(e) = result {
error!("Client task failed: {:?}", e);
}
}
// Stop monitoring
monitoring_handle.abort();
// Finalize metrics
let mut metrics = self.metrics.lock();
metrics.duration = start.elapsed();
metrics.total_requests = self.request_counter.load(Ordering::Relaxed);
metrics.successful_requests = self.success_counter.load(Ordering::Relaxed);
metrics.failed_requests = metrics.total_requests - metrics.successful_requests;
info!(
"Sustained load test complete: {} requests in {:?}",
metrics.total_requests, metrics.duration
);
Ok(metrics.clone())
}
/// Client workload: send requests at target rate
async fn client_workload(
client_id: usize,
duration: Duration,
delay: Duration,
request_counter: Arc<AtomicU64>,
success_counter: Arc<AtomicU64>,
metrics: Arc<parking_lot::Mutex<SustainedLoadMetrics>>,
) -> Result<()> {
let start = Instant::now();
while start.elapsed() < duration {
let req_start = Instant::now();
// Simulate order submission
let success = Self::simulate_order_submission(client_id).await;
let latency = req_start.elapsed();
// Update counters
request_counter.fetch_add(1, Ordering::Relaxed);
if success {
success_counter.fetch_add(1, Ordering::Relaxed);
}
// Record latency
{
let mut m = metrics.lock();
let _ = m.latency_histogram.record(latency.as_micros() as u64);
}
// Rate limiting - subtract processing time from delay to maintain target rate
if latency < delay {
tokio::time::sleep(delay - latency).await;
} else {
// Processing took longer than delay interval, no sleep needed
// This will naturally reduce throughput but is realistic
tokio::task::yield_now().await;
}
}
Ok(())
}
/// Simulate order submission (replace with actual gRPC call in integration tests)
async fn simulate_order_submission(_client_id: usize) -> bool {
// Simulate processing time (50-500μs)
tokio::time::sleep(Duration::from_micros(50 + rand::random::<u64>() % 450)).await;
// 99.9% success rate
rand::random::<f64>() < 0.999
}
/// Spawn monitoring task to collect periodic metrics
fn spawn_monitoring_task(&self, start: Instant) -> tokio::task::JoinHandle<()> {
let metrics = Arc::clone(&self.metrics);
let request_counter = Arc::clone(&self.request_counter);
let duration = self.duration;
tokio::spawn(async move {
let mut interval = interval(Duration::from_secs(1));
let mut last_count = 0u64;
let mut sys = sysinfo::System::new_all();
while start.elapsed() < duration {
interval.tick().await;
// Calculate throughput for this interval
let current_count = request_counter.load(Ordering::Relaxed);
let throughput = (current_count - last_count) as f64;
last_count = current_count;
// Collect memory usage
sys.refresh_all();
let memory_bytes = sys.used_memory();
// Record samples
let mut m = metrics.lock();
m.throughput_samples.push(throughput);
m.memory_samples.push(memory_bytes);
// Simulate connection pool size (replace with actual monitoring)
let pool_size = 10 + (rand::random::<usize>() % 5);
m.connection_pool_samples.push(pool_size);
// Simulate database query times (replace with actual monitoring)
let db_query_us = 100 + (rand::random::<u64>() % 900);
m.db_query_times.push(db_query_us);
drop(m);
}
})
}
/// Get current metrics
pub fn get_metrics(&self) -> SustainedLoadMetrics {
self.metrics.lock().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_sustained_load_short_duration() {
// 10 second test at 1000 req/sec
let test = SustainedLoadTest::new(1000, Duration::from_secs(10), 10);
let metrics = test.run().await.expect("Test failed");
assert!(metrics.total_requests > 0, "Should have sent requests");
assert!(
metrics.success_rate() > 99.0,
"Success rate should be > 99%"
);
assert!(
metrics.avg_throughput() > 700.0,
"Average throughput should be reasonable given overhead (target: 1000, got: {}). \
Lower than target due to tokio scheduling overhead, lock contention, and simulated processing time.",
metrics.avg_throughput()
);
}
#[tokio::test]
async fn test_degradation_detection() {
let mut metrics = SustainedLoadMetrics::new();
// Simulate stable throughput
for _ in 0..100 {
metrics.throughput_samples.push(1000.0);
}
assert!(
!metrics.detect_degradation(5.0),
"Should not detect degradation with stable throughput"
);
// Simulate degradation
for _ in 0..10 {
metrics.throughput_samples.push(800.0);
}
assert!(
metrics.detect_degradation(5.0),
"Should detect 20% degradation"
);
}
#[tokio::test]
async fn test_memory_leak_detection() {
let mut metrics = SustainedLoadMetrics::new();
// Simulate stable memory
for _ in 0..100 {
metrics.memory_samples.push(100 * 1_048_576); // 100 MB
}
assert!(
!metrics.detect_memory_leak(10.0),
"Should not detect leak with stable memory"
);
// Simulate memory growth
for i in 0..10 {
metrics.memory_samples.push((120 + i) * 1_048_576); // Growing
}
assert!(
metrics.detect_memory_leak(10.0),
"Should detect memory leak"
);
}
#[tokio::test]
#[ignore = "Long running test - run manually"]
async fn test_one_hour_sustained_load() {
let _ = tracing_subscriber::fmt::try_init();
info!("Starting 1-hour sustained load test at 50K req/sec");
let test = SustainedLoadTest::new(50_000, Duration::from_secs(3600), 500);
let metrics = test.run().await.expect("Test failed");
// Assertions
assert!(
metrics.total_requests > 50_000 * 3600 * 95 / 100,
"Should complete > 95% of expected requests"
);
assert!(
metrics.success_rate() > 99.0,
"Success rate should be > 99%"
);
assert!(
!metrics.detect_degradation(5.0),
"Should not degrade > 5% over 1 hour"
);
assert!(
!metrics.detect_memory_leak(50.0),
"Should not leak > 50MB over 1 hour"
);
info!("1-hour test results: {:?}", metrics);
}
#[tokio::test]
#[ignore = "Very long running test - run manually"]
async fn test_24_hour_soak_test() {
let _ = tracing_subscriber::fmt::try_init();
info!("Starting 24-hour soak test at 10K req/sec");
let test = SustainedLoadTest::new(10_000, Duration::from_secs(86400), 100);
let metrics = test.run().await.expect("Test failed");
// Assertions for soak test
assert!(
metrics.total_requests > 10_000 * 86400 * 95 / 100,
"Should complete > 95% of expected requests"
);
assert!(
metrics.success_rate() > 99.0,
"Success rate should be > 99%"
);
assert!(
!metrics.detect_degradation(3.0),
"Should not degrade > 3% over 24 hours"
);
assert!(
!metrics.detect_memory_leak(100.0),
"Should not leak > 100MB over 24 hours"
);
info!("24-hour soak test results: {:?}", metrics);
}
}