- Workspace Cargo.toml: remove web-gateway + api_gateway members, keep api - trading_service: dep api-gateway → api, update test imports - testing/api-gateway-load → testing/api-load (crate renamed) - All test crates: get_api_gateway_addr → get_api_addr + variable renames Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
7.4 KiB
Rust
225 lines
7.4 KiB
Rust
#![allow(clippy::integer_division)]
|
|
|
|
use anyhow::Result;
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::JoinSet;
|
|
|
|
use crate::clients::{AuthenticatedClient, MixedWorkloadClient};
|
|
use crate::metrics::{collector::MetricsCollector, LoadTestReport, TestConfig};
|
|
|
|
pub async fn run(
|
|
gateway_url: String,
|
|
num_clients: usize,
|
|
duration_secs: u64,
|
|
) -> Result<LoadTestReport> {
|
|
tracing::info!(
|
|
"Starting SUSTAINED load test: {} clients for {}s ({}h)",
|
|
num_clients,
|
|
duration_secs,
|
|
duration_secs / 3600
|
|
);
|
|
|
|
let (metrics_tx, metrics_rx) = mpsc::unbounded_channel();
|
|
let mut collector = MetricsCollector::new(metrics_rx);
|
|
|
|
// Spawn collector task
|
|
let collector_handle = {
|
|
let num_clients_clone = num_clients;
|
|
tokio::spawn(async move {
|
|
collector.run(num_clients_clone).await;
|
|
collector
|
|
})
|
|
};
|
|
|
|
// Warm-up phase (30 seconds)
|
|
tracing::info!("Starting warm-up phase (30s)...");
|
|
let warm_up_duration = std::time::Duration::from_secs(30);
|
|
let mut join_set = JoinSet::new();
|
|
|
|
for client_id in 0..num_clients {
|
|
let gateway_url = gateway_url.clone();
|
|
let metrics_tx_clone = metrics_tx.clone();
|
|
|
|
join_set.spawn(async move {
|
|
let auth_client = AuthenticatedClient::new(
|
|
gateway_url,
|
|
"test-secret-key-for-load-testing",
|
|
&format!("user-{}", client_id),
|
|
&format!("loadtest-user-{}", client_id),
|
|
)
|
|
.await?;
|
|
|
|
let mut mixed_client =
|
|
MixedWorkloadClient::new(auth_client, client_id, metrics_tx_clone);
|
|
mixed_client.run_mixed_workload(warm_up_duration).await
|
|
});
|
|
}
|
|
|
|
// Wait for warm-up to complete
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
tracing::error!("Warm-up client task failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
tracing::info!("Warm-up complete, starting sustained load test...");
|
|
|
|
// Main sustained load test
|
|
let mut join_set = JoinSet::new();
|
|
let duration = std::time::Duration::from_secs(duration_secs);
|
|
|
|
for client_id in 0..num_clients {
|
|
let gateway_url = gateway_url.clone();
|
|
let metrics_tx = metrics_tx.clone();
|
|
|
|
join_set.spawn(async move {
|
|
let auth_client = AuthenticatedClient::new(
|
|
gateway_url,
|
|
"test-secret-key-for-load-testing",
|
|
&format!("user-{}", client_id),
|
|
&format!("loadtest-user-{}", client_id),
|
|
)
|
|
.await?;
|
|
|
|
let mut mixed_client = MixedWorkloadClient::new(auth_client, client_id, metrics_tx);
|
|
mixed_client.run_mixed_workload(duration).await
|
|
});
|
|
}
|
|
|
|
// Progress reporting for long-running tests
|
|
let progress_handle = tokio::spawn(async move {
|
|
let start = std::time::Instant::now();
|
|
let total_duration = std::time::Duration::from_secs(duration_secs);
|
|
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(300)).await; // Report every 5 minutes
|
|
let elapsed = start.elapsed();
|
|
let progress_pct = (elapsed.as_secs_f64() / total_duration.as_secs_f64()) * 100.0;
|
|
|
|
if elapsed >= total_duration {
|
|
break;
|
|
}
|
|
|
|
tracing::info!(
|
|
"Sustained load test progress: {:.1}% complete ({} / {} seconds)",
|
|
progress_pct,
|
|
elapsed.as_secs(),
|
|
duration_secs
|
|
);
|
|
}
|
|
});
|
|
|
|
// Wait for all clients to complete
|
|
while let Some(result) = join_set.join_next().await {
|
|
if let Err(e) = result {
|
|
tracing::error!("Client task failed: {:?}", e);
|
|
}
|
|
}
|
|
|
|
// Cancel progress reporting
|
|
progress_handle.abort();
|
|
|
|
// Drop the sender to signal collector to finish
|
|
drop(metrics_tx);
|
|
|
|
// Wait for collector to finish and get the report
|
|
let collector = collector_handle.await?;
|
|
let mut report = collector.generate_report(
|
|
"Sustained Load Test".to_owned(),
|
|
TestConfig {
|
|
num_clients,
|
|
duration_secs,
|
|
test_type: "sustained_load".to_owned(),
|
|
},
|
|
);
|
|
|
|
// Analyze for memory leaks and latency degradation
|
|
let time_series = &report.time_series;
|
|
let latency_trend = analyze_latency_trend(time_series);
|
|
let error_rate_stable = analyze_error_rate_stability(time_series);
|
|
|
|
report.capacity_recommendation = Some(crate::metrics::CapacityRecommendation {
|
|
max_sustainable_clients: num_clients,
|
|
max_sustainable_rps: report.metrics.requests_per_second,
|
|
bottleneck_identified: if latency_trend > 10.0 {
|
|
Some("Latency degradation detected over time".to_owned())
|
|
} else if !error_rate_stable {
|
|
Some("Error rate instability detected".to_owned())
|
|
} else {
|
|
None
|
|
},
|
|
recommendation: if latency_trend < 5.0 && error_rate_stable {
|
|
format!(
|
|
"System remained stable over {}h with {} clients. Latency drift: {:.2}%, \
|
|
no memory leaks detected. Safe for production.",
|
|
duration_secs / 3600_u64,
|
|
num_clients,
|
|
latency_trend
|
|
)
|
|
} else if latency_trend >= 5.0 {
|
|
format!(
|
|
"Latency increased by {:.2}% over test duration. Potential memory leak or \
|
|
resource exhaustion. Investigate GC behavior and connection pooling.",
|
|
latency_trend
|
|
)
|
|
} else {
|
|
"Error rate fluctuations detected. Review application logs and backend \
|
|
service health during sustained load."
|
|
.to_string()
|
|
},
|
|
});
|
|
|
|
tracing::info!("Sustained load test completed: {:?}", report.metrics);
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
fn analyze_latency_trend(time_series: &[crate::metrics::TimeSeriesPoint]) -> f64 {
|
|
if time_series.len() < 10 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Compare first 10% vs last 10% of samples
|
|
let sample_size = time_series.len() / 10;
|
|
let first_samples = time_series.get(0..sample_size).unwrap_or(&[]);
|
|
let last_samples = time_series
|
|
.get(time_series.len().saturating_sub(sample_size)..time_series.len())
|
|
.unwrap_or(&[]);
|
|
|
|
let first_avg: f64 = first_samples.iter().map(|p| p.p99_latency_ms).sum::<f64>()
|
|
/ f64::from(u32::try_from(first_samples.len()).unwrap_or(u32::MAX));
|
|
let last_avg: f64 = last_samples.iter().map(|p| p.p99_latency_ms).sum::<f64>()
|
|
/ f64::from(u32::try_from(last_samples.len()).unwrap_or(u32::MAX));
|
|
|
|
if first_avg > 0.0 {
|
|
((last_avg - first_avg) / first_avg) * 100.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
fn analyze_error_rate_stability(time_series: &[crate::metrics::TimeSeriesPoint]) -> bool {
|
|
if time_series.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
// Calculate standard deviation of error rates
|
|
let error_rates: Vec<f64> = time_series.iter().map(|p| p.error_rate_pct).collect();
|
|
let mean: f64 = error_rates.iter().sum::<f64>()
|
|
/ f64::from(u32::try_from(error_rates.len()).unwrap_or(u32::MAX));
|
|
|
|
let variance: f64 = error_rates
|
|
.iter()
|
|
.map(|&rate| {
|
|
let diff = rate - mean;
|
|
diff * diff
|
|
})
|
|
.sum::<f64>()
|
|
/ f64::from(u32::try_from(error_rates.len()).unwrap_or(u32::MAX));
|
|
|
|
let stddev = variance.sqrt();
|
|
|
|
// Consider stable if stddev is less than 2%
|
|
stddev < 2.0
|
|
}
|