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>
583 lines
18 KiB
Rust
583 lines
18 KiB
Rust
//! Burst Load Stress Tests
|
|
//!
|
|
//! Tests system behavior under sudden load spikes and gradual ramps.
|
|
//! - Spike testing: 0 → 100K orders/sec in 1 second
|
|
//! - Gradual ramp: 0 → 50K over 10 minutes
|
|
//! - Sustained plateau: 50K for 1 hour
|
|
//! - Gradual ramp down: 50K → 0 over 10 minutes
|
|
|
|
use anyhow::Result;
|
|
use hdrhistogram::Histogram;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::Barrier;
|
|
use tokio::task::JoinSet;
|
|
use tracing::{error, info};
|
|
|
|
/// Load profile for burst testing
|
|
#[derive(Debug, Clone)]
|
|
pub enum LoadProfile {
|
|
/// Immediate spike to target RPS
|
|
Spike {
|
|
target_rps: usize,
|
|
duration: Duration,
|
|
},
|
|
/// Gradual ramp up to target RPS
|
|
RampUp {
|
|
target_rps: usize,
|
|
ramp_duration: Duration,
|
|
},
|
|
/// Sustained load at target RPS
|
|
Plateau {
|
|
target_rps: usize,
|
|
duration: Duration,
|
|
},
|
|
/// Gradual ramp down from target RPS to zero
|
|
RampDown {
|
|
start_rps: usize,
|
|
ramp_duration: Duration,
|
|
},
|
|
}
|
|
|
|
/// Burst load test metrics
|
|
#[derive(Debug, Clone)]
|
|
pub struct BurstLoadMetrics {
|
|
/// Total requests sent
|
|
pub total_requests: u64,
|
|
/// Successful requests
|
|
pub successful_requests: u64,
|
|
/// Failed requests
|
|
pub failed_requests: u64,
|
|
/// Peak throughput achieved (req/sec)
|
|
pub peak_throughput: f64,
|
|
/// Throughput samples over time
|
|
pub throughput_samples: Vec<(Duration, f64)>,
|
|
/// Latency histogram
|
|
pub latency_histogram: Histogram<u64>,
|
|
/// Test duration
|
|
pub duration: Duration,
|
|
/// Load profile description
|
|
pub profile_name: String,
|
|
}
|
|
|
|
impl BurstLoadMetrics {
|
|
pub fn new(profile_name: String) -> Self {
|
|
Self {
|
|
total_requests: 0,
|
|
successful_requests: 0,
|
|
failed_requests: 0,
|
|
peak_throughput: 0.0,
|
|
throughput_samples: Vec::new(),
|
|
latency_histogram: Histogram::<u64>::new_with_bounds(1, 60_000_000, 3).unwrap(),
|
|
duration: Duration::ZERO,
|
|
profile_name,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Get p99 latency
|
|
pub fn p99_latency_us(&self) -> u64 {
|
|
self.latency_histogram.value_at_quantile(0.99)
|
|
}
|
|
|
|
/// Get average throughput
|
|
pub fn avg_throughput(&self) -> f64 {
|
|
if self.throughput_samples.is_empty() {
|
|
return 0.0;
|
|
}
|
|
self.throughput_samples
|
|
.iter()
|
|
.map(|(_, tps)| tps)
|
|
.sum::<f64>()
|
|
/ self.throughput_samples.len() as f64
|
|
}
|
|
}
|
|
|
|
/// Burst load test runner
|
|
pub struct BurstLoadTest {
|
|
/// Load profile to execute
|
|
profile: LoadProfile,
|
|
/// Maximum concurrent clients
|
|
max_clients: usize,
|
|
/// Metrics collection
|
|
metrics: Arc<parking_lot::Mutex<BurstLoadMetrics>>,
|
|
/// Request counter
|
|
request_counter: Arc<AtomicU64>,
|
|
/// Success counter
|
|
success_counter: Arc<AtomicU64>,
|
|
}
|
|
|
|
impl BurstLoadTest {
|
|
/// Create new burst load test
|
|
pub fn new(profile: LoadProfile, max_clients: usize) -> Self {
|
|
let profile_name = match &profile {
|
|
LoadProfile::Spike { target_rps, .. } => {
|
|
format!("Spike to {} req/sec", target_rps)
|
|
},
|
|
LoadProfile::RampUp { target_rps, .. } => {
|
|
format!("Ramp up to {} req/sec", target_rps)
|
|
},
|
|
LoadProfile::Plateau { target_rps, .. } => {
|
|
format!("Plateau at {} req/sec", target_rps)
|
|
},
|
|
LoadProfile::RampDown { start_rps, .. } => {
|
|
format!("Ramp down from {} req/sec", start_rps)
|
|
},
|
|
};
|
|
|
|
Self {
|
|
profile,
|
|
max_clients,
|
|
metrics: Arc::new(parking_lot::Mutex::new(BurstLoadMetrics::new(profile_name))),
|
|
request_counter: Arc::new(AtomicU64::new(0)),
|
|
success_counter: Arc::new(AtomicU64::new(0)),
|
|
}
|
|
}
|
|
|
|
/// Run the burst load test
|
|
///
|
|
/// # Errors
|
|
/// Returns error if the operation fails
|
|
pub async fn run(&self) -> Result<BurstLoadMetrics> {
|
|
let start = Instant::now();
|
|
|
|
match &self.profile {
|
|
LoadProfile::Spike {
|
|
target_rps,
|
|
duration,
|
|
} => self.run_spike(*target_rps, *duration).await?,
|
|
LoadProfile::RampUp {
|
|
target_rps,
|
|
ramp_duration,
|
|
} => self.run_ramp_up(*target_rps, *ramp_duration).await?,
|
|
LoadProfile::Plateau {
|
|
target_rps,
|
|
duration,
|
|
} => self.run_plateau(*target_rps, *duration).await?,
|
|
LoadProfile::RampDown {
|
|
start_rps,
|
|
ramp_duration,
|
|
} => self.run_ramp_down(*start_rps, *ramp_duration).await?,
|
|
}
|
|
|
|
// 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;
|
|
|
|
Ok(metrics.clone())
|
|
}
|
|
|
|
/// Run spike test: immediate load spike
|
|
async fn run_spike(&self, target_rps: usize, duration: Duration) -> Result<()> {
|
|
info!("Running spike test: 0 → {} req/sec", target_rps);
|
|
|
|
let barrier = Arc::new(Barrier::new(self.max_clients));
|
|
let mut join_set = JoinSet::new();
|
|
|
|
// Spawn monitoring
|
|
let monitoring_handle = self.spawn_monitoring_task();
|
|
|
|
// Spawn all clients simultaneously
|
|
let requests_per_client = target_rps / self.max_clients;
|
|
let delay = if requests_per_client > 0 {
|
|
Duration::from_millis(1000 / (requests_per_client as u64))
|
|
} else {
|
|
Duration::from_millis(1000)
|
|
};
|
|
|
|
for client_id in 0..self.max_clients {
|
|
let barrier = Arc::clone(&barrier);
|
|
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 {
|
|
// Wait for all clients to be ready
|
|
barrier.wait().await;
|
|
|
|
// Start sending requests
|
|
Self::client_workload(
|
|
client_id,
|
|
duration,
|
|
delay,
|
|
request_counter,
|
|
success_counter,
|
|
metrics,
|
|
)
|
|
.await
|
|
});
|
|
}
|
|
|
|
// Wait for completion
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
error!("Spike client failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
monitoring_handle.abort();
|
|
|
|
info!("Spike test complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Run ramp up test: gradual increase in load
|
|
async fn run_ramp_up(&self, target_rps: usize, ramp_duration: Duration) -> Result<()> {
|
|
info!(
|
|
"Running ramp up test: 0 → {} req/sec over {:?}",
|
|
target_rps, ramp_duration
|
|
);
|
|
|
|
let mut join_set = JoinSet::new();
|
|
let monitoring_handle = self.spawn_monitoring_task();
|
|
|
|
let ramp_steps: u64 = 100; // 100 steps in the ramp
|
|
let step_duration = ramp_duration.as_millis() as u64 / ramp_steps;
|
|
|
|
for step in 0..ramp_steps {
|
|
let current_rps = (target_rps * (step + 1) as usize) / ramp_steps as usize;
|
|
let clients_for_step = (self.max_clients * (step + 1) as usize) / ramp_steps as usize;
|
|
|
|
// Spawn additional clients for this step
|
|
// Calculate how many NEW clients to spawn for this step
|
|
let previous_clients = if step > 0 {
|
|
(self.max_clients * step as usize) / ramp_steps as usize
|
|
} else {
|
|
0
|
|
};
|
|
let new_clients = clients_for_step.saturating_sub(previous_clients);
|
|
for client_id in 0..new_clients {
|
|
let delay = if current_rps > 0 {
|
|
Duration::from_millis(1000 / (current_rps as u64))
|
|
} else {
|
|
Duration::from_millis(1000)
|
|
};
|
|
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::from_millis(step_duration),
|
|
delay,
|
|
request_counter,
|
|
success_counter,
|
|
metrics,
|
|
)
|
|
.await
|
|
});
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(step_duration)).await;
|
|
}
|
|
|
|
// Wait for completion
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
error!("Ramp up client failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
monitoring_handle.abort();
|
|
|
|
info!("Ramp up test complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Run plateau test: sustained load at target RPS
|
|
async fn run_plateau(&self, target_rps: usize, duration: Duration) -> Result<()> {
|
|
info!(
|
|
"Running plateau test: {} req/sec for {:?}",
|
|
target_rps, duration
|
|
);
|
|
|
|
let mut join_set = JoinSet::new();
|
|
let monitoring_handle = self.spawn_monitoring_task();
|
|
|
|
let requests_per_client = target_rps / self.max_clients;
|
|
let delay = if requests_per_client > 0 {
|
|
Duration::from_millis(1000 / (requests_per_client as u64))
|
|
} else {
|
|
Duration::from_millis(1000)
|
|
};
|
|
|
|
for client_id in 0..self.max_clients {
|
|
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
|
|
});
|
|
}
|
|
|
|
// Wait for completion
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
error!("Plateau client failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
monitoring_handle.abort();
|
|
|
|
info!("Plateau test complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Run ramp down test: gradual decrease in load
|
|
async fn run_ramp_down(&self, start_rps: usize, ramp_duration: Duration) -> Result<()> {
|
|
info!(
|
|
"Running ramp down test: {} req/sec → 0 over {:?}",
|
|
start_rps, ramp_duration
|
|
);
|
|
|
|
let mut join_set = JoinSet::new();
|
|
let monitoring_handle = self.spawn_monitoring_task();
|
|
|
|
let ramp_steps: u64 = 100;
|
|
let step_duration = ramp_duration.as_millis() as u64 / ramp_steps;
|
|
|
|
// Start with all clients active
|
|
for step in 0..ramp_steps {
|
|
let current_rps = start_rps * (ramp_steps - step) as usize / ramp_steps as usize;
|
|
if current_rps == 0 {
|
|
break;
|
|
}
|
|
|
|
let active_clients =
|
|
self.max_clients * (ramp_steps - step) as usize / ramp_steps as usize;
|
|
|
|
for client_id in 0..active_clients.max(1) / ramp_steps as usize {
|
|
let delay = if current_rps > 0 {
|
|
Duration::from_millis(1000 / (current_rps as u64))
|
|
} else {
|
|
Duration::from_millis(1000)
|
|
};
|
|
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::from_millis(step_duration),
|
|
delay,
|
|
request_counter,
|
|
success_counter,
|
|
metrics,
|
|
)
|
|
.await
|
|
});
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(step_duration)).await;
|
|
}
|
|
|
|
// Wait for completion
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
error!("Ramp down client failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
monitoring_handle.abort();
|
|
|
|
info!("Ramp down test complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Client workload
|
|
async fn client_workload(
|
|
client_id: usize,
|
|
duration: Duration,
|
|
delay: Duration,
|
|
request_counter: Arc<AtomicU64>,
|
|
success_counter: Arc<AtomicU64>,
|
|
metrics: Arc<parking_lot::Mutex<BurstLoadMetrics>>,
|
|
) -> Result<()> {
|
|
let start = Instant::now();
|
|
|
|
while start.elapsed() < duration {
|
|
let req_start = Instant::now();
|
|
|
|
// Simulate request
|
|
let success = Self::simulate_request(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);
|
|
}
|
|
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Simulate request (replace with actual gRPC call)
|
|
async fn simulate_request(_client_id: usize) -> bool {
|
|
tokio::time::sleep(Duration::from_micros(50 + rand::random::<u64>() % 450)).await;
|
|
rand::random::<f64>() < 0.999
|
|
}
|
|
|
|
/// Spawn monitoring task
|
|
fn spawn_monitoring_task(&self) -> tokio::task::JoinHandle<()> {
|
|
let metrics = Arc::clone(&self.metrics);
|
|
let request_counter = Arc::clone(&self.request_counter);
|
|
|
|
tokio::spawn(async move {
|
|
let start = Instant::now();
|
|
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
|
let mut last_count = 0u64;
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
|
|
let current_count = request_counter.load(Ordering::Relaxed);
|
|
let throughput = (current_count - last_count) as f64;
|
|
last_count = current_count;
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
{
|
|
let mut m = metrics.lock();
|
|
m.throughput_samples.push((elapsed, throughput));
|
|
if throughput > m.peak_throughput {
|
|
m.peak_throughput = throughput;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Get current metrics
|
|
pub fn get_metrics(&self) -> BurstLoadMetrics {
|
|
self.metrics.lock().clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_spike_load() {
|
|
let profile = LoadProfile::Spike {
|
|
target_rps: 10_000,
|
|
duration: Duration::from_secs(5),
|
|
};
|
|
|
|
let test = BurstLoadTest::new(profile, 100);
|
|
let metrics = test.run().await.expect("Test failed");
|
|
|
|
assert!(metrics.total_requests > 0);
|
|
assert!(metrics.success_rate() > 99.0);
|
|
// Peak throughput should be reasonable for simulated workload
|
|
assert!(
|
|
metrics.peak_throughput > 5000.0,
|
|
"Peak throughput should be > 5000/sec (got: {})",
|
|
metrics.peak_throughput
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ramp_up() {
|
|
let profile = LoadProfile::RampUp {
|
|
target_rps: 5_000,
|
|
ramp_duration: Duration::from_secs(10),
|
|
};
|
|
|
|
let test = BurstLoadTest::new(profile, 50);
|
|
let metrics = test.run().await.expect("Test failed");
|
|
|
|
assert!(metrics.total_requests > 0);
|
|
assert!(metrics.success_rate() > 99.0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Long running - run manually"]
|
|
async fn test_full_burst_scenario() {
|
|
let _ = tracing_subscriber::fmt::try_init();
|
|
|
|
info!("Running full burst scenario");
|
|
|
|
// Phase 1: Spike to 100K req/sec for 1 second
|
|
let spike = BurstLoadTest::new(
|
|
LoadProfile::Spike {
|
|
target_rps: 100_000,
|
|
duration: Duration::from_secs(1),
|
|
},
|
|
1000,
|
|
);
|
|
let spike_metrics = spike.run().await.expect("Spike failed");
|
|
info!("Spike phase: {:?}", spike_metrics);
|
|
|
|
// Phase 2: Ramp up 0 → 50K over 10 minutes
|
|
let ramp_up = BurstLoadTest::new(
|
|
LoadProfile::RampUp {
|
|
target_rps: 50_000,
|
|
ramp_duration: Duration::from_secs(600),
|
|
},
|
|
500,
|
|
);
|
|
let ramp_up_metrics = ramp_up.run().await.expect("Ramp up failed");
|
|
info!("Ramp up phase: {:?}", ramp_up_metrics);
|
|
|
|
// Phase 3: Plateau at 50K for 1 hour
|
|
let plateau = BurstLoadTest::new(
|
|
LoadProfile::Plateau {
|
|
target_rps: 50_000,
|
|
duration: Duration::from_secs(3600),
|
|
},
|
|
500,
|
|
);
|
|
let plateau_metrics = plateau.run().await.expect("Plateau failed");
|
|
info!("Plateau phase: {:?}", plateau_metrics);
|
|
|
|
// Phase 4: Ramp down 50K → 0 over 10 minutes
|
|
let ramp_down = BurstLoadTest::new(
|
|
LoadProfile::RampDown {
|
|
start_rps: 50_000,
|
|
ramp_duration: Duration::from_secs(600),
|
|
},
|
|
500,
|
|
);
|
|
let ramp_down_metrics = ramp_down.run().await.expect("Ramp down failed");
|
|
info!("Ramp down phase: {:?}", ramp_down_metrics);
|
|
|
|
// All phases should have high success rates
|
|
assert!(spike_metrics.success_rate() > 95.0);
|
|
assert!(ramp_up_metrics.success_rate() > 99.0);
|
|
assert!(plateau_metrics.success_rate() > 99.0);
|
|
assert!(ramp_down_metrics.success_rate() > 99.0);
|
|
}
|
|
}
|