refactor: rename api_gateway → api across workspace, tests, and load crate

- 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>
This commit is contained in:
jgrusewski
2026-03-04 23:38:21 +01:00
parent e50ea55064
commit d25c82f8f3
31 changed files with 74 additions and 75 deletions

View File

@@ -0,0 +1,321 @@
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, EncodingKey, Header};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Instant;
use crate::metrics::{RequestMetric, RequestStatus, ServiceType};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
username: String,
exp: i64,
iat: i64,
}
pub struct AuthenticatedClient {
client: Client,
gateway_url: String,
jwt_token: String,
}
impl AuthenticatedClient {
pub async fn new(
gateway_url: String,
jwt_secret: &str,
user_id: &str,
username: &str,
) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.pool_max_idle_per_host(10)
.build()
.context("Failed to create HTTP client")?;
// Generate JWT token
let claims = Claims {
sub: user_id.to_string(),
username: username.to_string(),
iat: Utc::now().timestamp(),
exp: (Utc::now() + Duration::hours(24)).timestamp(),
};
let jwt_token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)
.context("Failed to generate JWT token")?;
Ok(Self {
client,
gateway_url,
jwt_token,
})
}
pub async fn submit_order(&self, client_id: usize, order: TestOrder) -> Result<RequestMetric> {
let start = Instant::now();
let result = self
.client
.post(format!("{}/trading/orders", self.gateway_url))
.header("Authorization", format!("Bearer {}", self.jwt_token))
.json(&order)
.send()
.await;
let latency = start.elapsed();
let status = match result {
Ok(response) => {
if response.status().is_success() {
RequestStatus::Success
} else if response.status().as_u16() == 429 {
RequestStatus::RateLimited
} else if response.status().as_u16() == 503 {
RequestStatus::CircuitBreakerOpen
} else {
RequestStatus::Error
}
},
Err(e) => {
if e.is_timeout() {
RequestStatus::Timeout
} else {
RequestStatus::Error
}
},
};
Ok(RequestMetric {
timestamp: chrono::Utc::now(),
client_id,
service: ServiceType::Trading,
latency,
status,
error_type: if status != RequestStatus::Success {
Some(format!("{:?}", status))
} else {
None
},
})
}
pub async fn get_positions(&self, client_id: usize) -> Result<RequestMetric> {
let start = Instant::now();
let result = self
.client
.get(format!("{}/trading/positions", self.gateway_url))
.header("Authorization", format!("Bearer {}", self.jwt_token))
.send()
.await;
let latency = start.elapsed();
let status = match result {
Ok(response) => {
if response.status().is_success() {
RequestStatus::Success
} else if response.status().as_u16() == 429 {
RequestStatus::RateLimited
} else if response.status().as_u16() == 503 {
RequestStatus::CircuitBreakerOpen
} else {
RequestStatus::Error
}
},
Err(e) => {
if e.is_timeout() {
RequestStatus::Timeout
} else {
RequestStatus::Error
}
},
};
Ok(RequestMetric {
timestamp: chrono::Utc::now(),
client_id,
service: ServiceType::Trading,
latency,
status,
error_type: if status != RequestStatus::Success {
Some(format!("{:?}", status))
} else {
None
},
})
}
pub async fn run_backtest(
&self,
client_id: usize,
config: BacktestConfig,
) -> Result<RequestMetric> {
let start = Instant::now();
let result = self
.client
.post(format!("{}/backtesting/run", self.gateway_url))
.header("Authorization", format!("Bearer {}", self.jwt_token))
.json(&config)
.send()
.await;
let latency = start.elapsed();
let status = match result {
Ok(response) => {
if response.status().is_success() {
RequestStatus::Success
} else if response.status().as_u16() == 429 {
RequestStatus::RateLimited
} else if response.status().as_u16() == 503 {
RequestStatus::CircuitBreakerOpen
} else {
RequestStatus::Error
}
},
Err(e) => {
if e.is_timeout() {
RequestStatus::Timeout
} else {
RequestStatus::Error
}
},
};
Ok(RequestMetric {
timestamp: chrono::Utc::now(),
client_id,
service: ServiceType::Backtesting,
latency,
status,
error_type: if status != RequestStatus::Success {
Some(format!("{:?}", status))
} else {
None
},
})
}
pub async fn train_model(
&self,
client_id: usize,
config: TrainingConfig,
) -> Result<RequestMetric> {
let start = Instant::now();
let result = self
.client
.post(format!("{}/ml/train", self.gateway_url))
.header("Authorization", format!("Bearer {}", self.jwt_token))
.json(&config)
.send()
.await;
let latency = start.elapsed();
let status = match result {
Ok(response) => {
if response.status().is_success() {
RequestStatus::Success
} else if response.status().as_u16() == 429 {
RequestStatus::RateLimited
} else if response.status().as_u16() == 503 {
RequestStatus::CircuitBreakerOpen
} else {
RequestStatus::Error
}
},
Err(e) => {
if e.is_timeout() {
RequestStatus::Timeout
} else {
RequestStatus::Error
}
},
};
Ok(RequestMetric {
timestamp: chrono::Utc::now(),
client_id,
service: ServiceType::MlTraining,
latency,
status,
error_type: if status != RequestStatus::Success {
Some(format!("{:?}", status))
} else {
None
},
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TestOrder {
pub symbol: String,
pub quantity: f64,
pub side: String,
pub order_type: String,
}
impl TestOrder {
pub fn random() -> Self {
use rand::Rng;
let mut rng = rand::thread_rng();
let symbols = ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"];
let sides = ["buy", "sell"];
let order_types = ["market", "limit"];
Self {
symbol: symbols
.get(rng.gen_range(0..symbols.len()))
.map_or_else(|| "AAPL".to_owned(), |s| (*s).to_string()),
quantity: rng.gen_range(1.0..101.0),
side: sides
.get(rng.gen_range(0..sides.len()))
.map_or_else(|| "buy".to_owned(), |s| (*s).to_string()),
order_type: order_types
.get(rng.gen_range(0..order_types.len()))
.map_or_else(|| "market".to_owned(), |s| (*s).to_string()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BacktestConfig {
pub strategy: String,
pub start_date: String,
pub end_date: String,
}
impl BacktestConfig {
pub fn default() -> Self {
Self {
strategy: "momentum".to_owned(),
start_date: "2024-01-01".to_owned(),
end_date: "2024-12-31".to_owned(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TrainingConfig {
pub model_type: String,
pub epochs: u32,
}
impl TrainingConfig {
pub fn default() -> Self {
Self {
model_type: "mamba2".to_owned(),
epochs: 10_u32,
}
}
}

View File

@@ -0,0 +1,83 @@
use anyhow::Result;
use tokio::sync::mpsc;
use super::authenticated_client::*;
use crate::metrics::RequestMetric;
pub struct MixedWorkloadClient {
client: AuthenticatedClient,
client_id: usize,
metrics_tx: mpsc::UnboundedSender<RequestMetric>,
}
impl MixedWorkloadClient {
pub fn new(
client: AuthenticatedClient,
client_id: usize,
metrics_tx: mpsc::UnboundedSender<RequestMetric>,
) -> Self {
Self {
client,
client_id,
metrics_tx,
}
}
/// Run mixed workload with realistic distribution:
/// - 60% order submissions
///
/// - 30% position queries
/// - 8% backtesting requests
///
/// - 2% ML training requests
pub async fn run_mixed_workload(&mut self, duration: std::time::Duration) -> Result<()> {
use rand::Rng;
let start = std::time::Instant::now();
while start.elapsed() < duration {
// Generate random number each iteration to avoid holding RNG across await
let workload_type = {
let mut rng = rand::thread_rng();
rng.gen_range(0..100)
};
let metric = match workload_type {
0..=59 => {
// 60% - Submit order
self.client
.submit_order(self.client_id, TestOrder::random())
.await?
},
60..=89 => {
// 30% - Query positions
self.client.get_positions(self.client_id).await?
},
90..=97 => {
// 8% - Run backtest
self.client
.run_backtest(self.client_id, BacktestConfig::default())
.await?
},
98..=99 => {
// 2% - Train model
self.client
.train_model(self.client_id, TrainingConfig::default())
.await?
},
_ => unreachable!(),
};
self.metrics_tx.send(metric)?;
// Small random think time (1-50ms) to simulate realistic user behavior
let think_time_ms = {
let mut rng = rand::thread_rng();
rng.gen_range(1..=50)
};
tokio::time::sleep(tokio::time::Duration::from_millis(think_time_ms)).await;
}
Ok(())
}
}

View File

@@ -0,0 +1,5 @@
pub mod authenticated_client;
pub mod mixed_workload;
pub use authenticated_client::*;
pub use mixed_workload::*;

View File

@@ -0,0 +1,100 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadTestConfig {
pub gateway_url: String,
pub prometheus_url: Option<String>,
pub auth: AuthConfig,
pub scenarios: ScenariosConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub jwt_secret: String,
pub username: String,
pub user_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenariosConfig {
pub normal_load: NormalLoadConfig,
pub spike_load: SpikeLoadConfig,
pub sustained_load: SustainedLoadConfig,
pub stress_test: StressTestConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalLoadConfig {
pub num_clients: usize,
pub duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpikeLoadConfig {
pub target_clients: usize,
pub ramp_up_secs: u64,
pub sustain_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SustainedLoadConfig {
pub num_clients: usize,
pub duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StressTestConfig {
pub initial_clients: usize,
pub increment: usize,
pub increment_interval_secs: u64,
pub max_p99_latency_ms: f64,
pub max_error_rate_pct: f64,
}
impl Default for LoadTestConfig {
fn default() -> Self {
Self {
gateway_url: "http://localhost:50050".to_string(),
prometheus_url: Some("http://localhost:9090".to_string()),
auth: AuthConfig::default(),
scenarios: ScenariosConfig::default(),
}
}
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
jwt_secret: "test-secret-key-for-load-testing".to_string(),
username: "load-test-user".to_string(),
user_id: "load-test-user-id".to_string(),
}
}
}
impl Default for ScenariosConfig {
fn default() -> Self {
Self {
normal_load: NormalLoadConfig {
num_clients: 1000_usize,
duration_secs: 60_u64,
},
spike_load: SpikeLoadConfig {
target_clients: 10000_usize,
ramp_up_secs: 10_u64,
sustain_secs: 60_u64,
},
sustained_load: SustainedLoadConfig {
num_clients: 100_usize,
duration_secs: 86400_u64, // 24 hours
},
stress_test: StressTestConfig {
initial_clients: 100_usize,
increment: 100_usize,
increment_interval_secs: 60_u64,
max_p99_latency_ms: 50.0,
max_error_rate_pct: 5.0,
},
}
}
}

View File

@@ -0,0 +1,196 @@
#![allow(clippy::integer_division)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod clients;
mod config;
mod metrics;
mod orchestrator;
mod reporting;
mod scenarios;
#[derive(Parser)]
#[command(name = "load_test_runner")]
#[command(about = "API Gateway Load Testing Framework", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Run normal load test (1K concurrent clients for 60s)
Normal {
#[arg(long, default_value = "http://localhost:50050")]
gateway_url: String,
#[arg(long, default_value = "1000")]
num_clients: usize,
#[arg(long, default_value = "60")]
duration_secs: u64,
},
/// Run spike load test (0→10K clients in 10s, sustain 60s)
Spike {
#[arg(long, default_value = "http://localhost:50050")]
gateway_url: String,
#[arg(long, default_value = "10000")]
target_clients: usize,
#[arg(long, default_value = "10")]
ramp_up_secs: u64,
#[arg(long, default_value = "60")]
sustain_secs: u64,
},
/// Run sustained load test (100 clients for 24h)
Sustained {
#[arg(long, default_value = "http://localhost:50050")]
gateway_url: String,
#[arg(long, default_value = "100")]
num_clients: usize,
#[arg(long, default_value = "86400")]
duration_secs: u64,
},
/// Run stress test (incrementally increase until failure)
Stress {
#[arg(long, default_value = "http://localhost:50050")]
gateway_url: String,
#[arg(long, default_value = "100")]
initial_clients: usize,
#[arg(long, default_value = "100")]
increment: usize,
#[arg(long, default_value = "60")]
increment_interval_secs: u64,
#[arg(long, default_value = "50.0")]
max_p99_latency_ms: f64,
#[arg(long, default_value = "5.0")]
max_error_rate_pct: f64,
},
/// Run all scenarios sequentially
All {
#[arg(long, default_value = "http://localhost:50050")]
gateway_url: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,load_test_runner=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let cli = Cli::parse();
match cli.command {
Commands::Normal {
gateway_url,
num_clients,
duration_secs,
} => {
tracing::info!(
"Running NORMAL load test: {} clients for {}s",
num_clients,
duration_secs
);
let report =
scenarios::normal_load::run(gateway_url, num_clients, duration_secs).await?;
reporting::generate_html_report("normal_load_report.html", report)?;
},
Commands::Spike {
gateway_url,
target_clients,
ramp_up_secs,
sustain_secs,
} => {
tracing::info!(
"Running SPIKE load test: 0→{} clients in {}s, sustain {}s",
target_clients,
ramp_up_secs,
sustain_secs
);
let report =
scenarios::spike_load::run(gateway_url, target_clients, ramp_up_secs, sustain_secs)
.await?;
reporting::generate_html_report("spike_load_report.html", report)?;
},
Commands::Sustained {
gateway_url,
num_clients,
duration_secs,
} => {
tracing::info!(
"Running SUSTAINED load test: {} clients for {}s ({}h)",
num_clients,
duration_secs,
duration_secs / 3600
);
let report =
scenarios::sustained_load::run(gateway_url, num_clients, duration_secs).await?;
reporting::generate_html_report("sustained_load_report.html", report)?;
},
Commands::Stress {
gateway_url,
initial_clients,
increment,
increment_interval_secs,
max_p99_latency_ms,
max_error_rate_pct,
} => {
tracing::info!(
"Running STRESS test: start {} clients, increment by {} every {}s",
initial_clients,
increment,
increment_interval_secs
);
let report = scenarios::stress_test::run(
gateway_url,
initial_clients,
increment,
increment_interval_secs,
max_p99_latency_ms,
max_error_rate_pct,
)
.await?;
reporting::generate_html_report("stress_test_report.html", report)?;
},
Commands::All { gateway_url } => {
tracing::info!("Running ALL load test scenarios sequentially");
// Normal load
let normal_report =
scenarios::normal_load::run(gateway_url.clone(), 1000_usize, 60).await?;
reporting::generate_html_report("normal_load_report.html", normal_report)?;
// Wait between tests
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
// Spike load
let spike_report =
scenarios::spike_load::run(gateway_url.clone(), 10000_usize, 10_u64, 60).await?;
reporting::generate_html_report("spike_load_report.html", spike_report)?;
// Wait between tests
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
// Stress test (short version)
let stress_report = scenarios::stress_test::run(
gateway_url.clone(),
100_usize,
100_usize,
60_u64,
50.0,
5.0,
)
.await?;
reporting::generate_html_report("stress_test_report.html", stress_report)?;
tracing::info!("All scenarios complete! Reports generated.");
},
}
Ok(())
}

View File

@@ -0,0 +1,244 @@
use super::*;
use dashmap::DashMap;
use hdrhistogram::Histogram;
use parking_lot::RwLock;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
pub struct MetricsCollector {
receiver: mpsc::UnboundedReceiver<RequestMetric>,
histogram: Arc<RwLock<Histogram<u64>>>,
service_histograms: Arc<DashMap<ServiceType, Histogram<u64>>>,
counters: Arc<RwLock<Counters>>,
start_time: Instant,
time_series: Arc<RwLock<Vec<TimeSeriesPoint>>>,
}
#[derive(Debug, Default)]
struct Counters {
total_requests: u64,
successful_requests: u64,
failed_requests: u64,
timeout_requests: u64,
rate_limited_requests: u64,
circuit_breaker_requests: u64,
}
impl MetricsCollector {
pub fn new(receiver: mpsc::UnboundedReceiver<RequestMetric>) -> Self {
Self {
receiver,
histogram: Arc::new(RwLock::new(Histogram::new(3).unwrap())),
service_histograms: Arc::new(DashMap::new()),
counters: Arc::new(RwLock::new(Counters::default())),
start_time: Instant::now(),
time_series: Arc::new(RwLock::new(Vec::new())),
}
}
pub async fn run(&mut self, num_clients: usize) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
let mut last_snapshot_time = Instant::now();
let mut last_request_count = 0u64;
loop {
tokio::select! {
Some(metric) = self.receiver.recv() => {
self.process_metric(metric);
}
_ = interval.tick() => {
// Create time series snapshot every second
let current_time = Instant::now();
let elapsed = current_time.duration_since(last_snapshot_time);
let counters = self.counters.read();
let current_request_count = counters.total_requests;
let requests_in_interval = current_request_count - last_request_count;
let rps = f64::from(u32::try_from(requests_in_interval).unwrap_or(u32::MAX)) / elapsed.as_secs_f64();
let histogram = self.histogram.read();
let p99_latency_ms = if !histogram.is_empty() {
f64::from(u32::try_from(histogram.value_at_quantile(0.99)).unwrap_or(u32::MAX)) / 1_000_000.0 // Convert nanoseconds to ms
} else {
0.0
};
let error_rate_pct = if counters.total_requests > 0 {
(f64::from(u32::try_from(counters.failed_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(counters.total_requests).unwrap_or(u32::MAX))) * 100.0
} else {
0.0
};
drop(counters);
drop(histogram);
self.time_series.write().push(TimeSeriesPoint {
timestamp: chrono::Utc::now(),
rps,
p99_latency_ms,
error_rate_pct,
active_clients: num_clients,
});
last_snapshot_time = current_time;
last_request_count = current_request_count;
}
else => break,
}
}
}
fn process_metric(&mut self, metric: RequestMetric) {
let latency_ns = u64::try_from(metric.latency.as_nanos()).unwrap_or(u64::MAX);
// Update global histogram
let mut histogram = self.histogram.write();
let _ = histogram.record(latency_ns);
drop(histogram);
// Update per-service histogram
self.service_histograms
.entry(metric.service)
.or_insert_with(|| Histogram::new(3).unwrap())
.record(latency_ns)
.ok();
// Update counters
let mut counters = self.counters.write();
counters.total_requests += 1;
match metric.status {
RequestStatus::Success => counters.successful_requests += 1,
RequestStatus::Error => counters.failed_requests += 1,
RequestStatus::Timeout => counters.timeout_requests += 1,
RequestStatus::RateLimited => counters.rate_limited_requests += 1,
RequestStatus::CircuitBreakerOpen => counters.circuit_breaker_requests += 1,
}
}
pub fn generate_report(&self, test_name: String, config: TestConfig) -> LoadTestReport {
let end_time = chrono::Utc::now();
let duration = self.start_time.elapsed();
let counters = self.counters.read();
let histogram = self.histogram.read();
let total_requests = counters.total_requests;
let successful_requests = counters.successful_requests;
let failed_requests = counters.failed_requests;
let requests_per_second =
f64::from(u32::try_from(total_requests).unwrap_or(u32::MAX)) / duration.as_secs_f64();
let error_rate_pct = if total_requests > 0 {
(f64::from(u32::try_from(failed_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(total_requests).unwrap_or(u32::MAX)))
* 100.0
} else {
0.0
};
let latency_stats = if !histogram.is_empty() {
LatencyStats {
min_ms: f64::from(u32::try_from(histogram.min()).unwrap_or(u32::MAX)) / 1_000_000.0,
max_ms: f64::from(u32::try_from(histogram.max()).unwrap_or(u32::MAX)) / 1_000_000.0,
mean_ms: histogram.mean() / 1_000_000.0,
p50_ms: f64::from(
u32::try_from(histogram.value_at_quantile(0.50)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p90_ms: f64::from(
u32::try_from(histogram.value_at_quantile(0.90)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p95_ms: f64::from(
u32::try_from(histogram.value_at_quantile(0.95)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p99_ms: f64::from(
u32::try_from(histogram.value_at_quantile(0.99)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p99_9_ms: f64::from(
u32::try_from(histogram.value_at_quantile(0.999)).unwrap_or(u32::MAX),
) / 1_000_000.0,
stddev_ms: histogram.stdev() / 1_000_000.0,
}
} else {
LatencyStats {
min_ms: 0.0,
max_ms: 0.0,
mean_ms: 0.0,
p50_ms: 0.0,
p90_ms: 0.0,
p95_ms: 0.0,
p99_ms: 0.0,
p99_9_ms: 0.0,
stddev_ms: 0.0,
}
};
// Calculate per-service stats
let mut per_service_stats = std::collections::HashMap::new();
for entry in self.service_histograms.iter() {
let service = *entry.key();
let hist = entry.value();
if !hist.is_empty() {
let service_latency_stats = LatencyStats {
min_ms: f64::from(u32::try_from(hist.min()).unwrap_or(u32::MAX)) / 1_000_000.0,
max_ms: f64::from(u32::try_from(hist.max()).unwrap_or(u32::MAX)) / 1_000_000.0,
mean_ms: hist.mean() / 1_000_000.0,
p50_ms: f64::from(
u32::try_from(hist.value_at_quantile(0.50)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p90_ms: f64::from(
u32::try_from(hist.value_at_quantile(0.90)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p95_ms: f64::from(
u32::try_from(hist.value_at_quantile(0.95)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p99_ms: f64::from(
u32::try_from(hist.value_at_quantile(0.99)).unwrap_or(u32::MAX),
) / 1_000_000.0,
p99_9_ms: f64::from(
u32::try_from(hist.value_at_quantile(0.999)).unwrap_or(u32::MAX),
) / 1_000_000.0,
stddev_ms: hist.stdev() / 1_000_000.0,
};
per_service_stats.insert(
service,
ServiceStats {
total_requests: hist.len(),
successful_requests: hist.len(), // Simplified for now
error_rate_pct: 0.0, // Simplified for now
latency_stats: service_latency_stats,
},
);
}
}
let metrics = AggregatedMetrics {
total_requests,
successful_requests,
failed_requests,
timeout_requests: counters.timeout_requests,
rate_limited_requests: counters.rate_limited_requests,
circuit_breaker_requests: counters.circuit_breaker_requests,
duration,
requests_per_second,
error_rate_pct,
latency_stats,
per_service_stats,
system_metrics: None, // To be filled by system monitor
};
LoadTestReport {
test_name,
start_time: end_time - chrono::Duration::from_std(duration).expect("INVARIANT: Duration should fit in chrono::Duration"),
end_time,
config,
metrics,
time_series: self.time_series.read().clone(),
capacity_recommendation: None, // To be filled based on test type
}
}
}

View File

@@ -0,0 +1,126 @@
pub mod collector;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestMetric {
pub timestamp: DateTime<Utc>,
pub client_id: usize,
pub service: ServiceType,
pub latency: Duration,
pub status: RequestStatus,
pub error_type: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum ServiceType {
Trading,
Backtesting,
MlTraining,
Gateway,
}
impl std::fmt::Display for ServiceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServiceType::Trading => write!(f, "Trading"),
ServiceType::Backtesting => write!(f, "Backtesting"),
ServiceType::MlTraining => write!(f, "ML Training"),
ServiceType::Gateway => write!(f, "Gateway"),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum RequestStatus {
Success,
Error,
Timeout,
RateLimited,
CircuitBreakerOpen,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregatedMetrics {
pub total_requests: u64,
pub successful_requests: u64,
pub failed_requests: u64,
pub timeout_requests: u64,
pub rate_limited_requests: u64,
pub circuit_breaker_requests: u64,
pub duration: Duration,
pub requests_per_second: f64,
pub error_rate_pct: f64,
pub latency_stats: LatencyStats,
pub per_service_stats: std::collections::HashMap<ServiceType, ServiceStats>,
pub system_metrics: Option<SystemMetrics>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LatencyStats {
pub min_ms: f64,
pub max_ms: f64,
pub mean_ms: f64,
pub p50_ms: f64,
pub p90_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
pub p99_9_ms: f64,
pub stddev_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStats {
pub total_requests: u64,
pub successful_requests: u64,
pub error_rate_pct: f64,
pub latency_stats: LatencyStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
pub cpu_usage_pct: f64,
pub memory_used_mb: f64,
pub memory_total_mb: f64,
pub memory_usage_pct: f64,
pub circuit_breaker_trips: u64,
pub rate_limit_hits: u64,
pub connection_pool_utilization_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadTestReport {
pub test_name: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub config: TestConfig,
pub metrics: AggregatedMetrics,
pub time_series: Vec<TimeSeriesPoint>,
pub capacity_recommendation: Option<CapacityRecommendation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestConfig {
pub num_clients: usize,
pub duration_secs: u64,
pub test_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesPoint {
pub timestamp: DateTime<Utc>,
pub rps: f64,
pub p99_latency_ms: f64,
pub error_rate_pct: f64,
pub active_clients: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapacityRecommendation {
pub max_sustainable_clients: usize,
pub max_sustainable_rps: f64,
pub bottleneck_identified: Option<String>,
pub recommendation: String,
}

View File

@@ -0,0 +1,2 @@
// Orchestrator module for managing load test execution
// This module provides utilities for coordinating multiple test scenarios

View File

@@ -0,0 +1,443 @@
use anyhow::Result;
use plotters::prelude::*;
use std::path::Path;
use crate::metrics::LoadTestReport;
pub fn generate_html_report<P: AsRef<Path>>(output_path: P, report: LoadTestReport) -> Result<()> {
let output_path = output_path.as_ref();
tracing::info!("Generating HTML report: {:?}", output_path);
// Generate plots
let rps_chart_path = output_path.with_extension("rps.svg");
let latency_chart_path = output_path.with_extension("latency.svg");
let error_chart_path = output_path.with_extension("errors.svg");
generate_rps_chart(&rps_chart_path, &report)?;
generate_latency_chart(&latency_chart_path, &report)?;
generate_error_rate_chart(&error_chart_path, &report)?;
// Generate HTML
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Load Test Report: {test_name}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}}
.container {{
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
h1 {{
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
}}
h2 {{
color: #34495e;
margin-top: 30px;
}}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}}
.metric-card {{
background: #ecf0f1;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #3498db;
}}
.metric-card h3 {{
margin: 0 0 10px 0;
color: #2c3e50;
font-size: 14px;
text-transform: uppercase;
}}
.metric-card .value {{
font-size: 32px;
font-weight: bold;
color: #2c3e50;
}}
.metric-card .unit {{
font-size: 16px;
color: #7f8c8d;
}}
.success {{ border-left-color: #27ae60; }}
.warning {{ border-left-color: #f39c12; }}
.error {{ border-left-color: #e74c3c; }}
.chart {{
margin: 30px 0;
text-align: center;
}}
.chart img {{
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 4px;
}}
.recommendation {{
background: #e8f5e9;
border-left: 4px solid #4caf50;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
}}
.recommendation h3 {{
margin-top: 0;
color: #2e7d32;
}}
table {{
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}}
th {{
background-color: #3498db;
color: white;
}}
tr:hover {{
background-color: #f5f5f5;
}}
.timestamp {{
color: #7f8c8d;
font-size: 14px;
}}
</style>
</head>
<body>
<div class="container">
<h1>{test_name}</h1>
<p class="timestamp">
Test Period: {start_time} to {end_time}<br>
Duration: {duration_secs} seconds ({duration_hours:.2} hours)
</p>
<h2>Summary</h2>
<div class="summary">
<div class="metric-card success">
<h3>Total Requests</h3>
<div class="value">{total_requests}</div>
</div>
<div class="metric-card {rps_class}">
<h3>Requests/Second</h3>
<div class="value">{rps:.0}</div>
</div>
<div class="metric-card {error_class}">
<h3>Error Rate</h3>
<div class="value">{error_rate:.2}<span class="unit">%</span></div>
</div>
<div class="metric-card {latency_class}">
<h3>P99 Latency</h3>
<div class="value">{p99_latency:.2}<span class="unit">ms</span></div>
</div>
</div>
<h2>Latency Statistics</h2>
<table>
<tr>
<th>Percentile</th>
<th>Latency (ms)</th>
</tr>
<tr><td>Minimum</td><td>{min_latency:.3}</td></tr>
<tr><td>P50 (Median)</td><td>{p50_latency:.3}</td></tr>
<tr><td>P90</td><td>{p90_latency:.3}</td></tr>
<tr><td>P95</td><td>{p95_latency:.3}</td></tr>
<tr><td>P99</td><td>{p99_latency:.3}</td></tr>
<tr><td>P99.9</td><td>{p99_9_latency:.3}</td></tr>
<tr><td>Maximum</td><td>{max_latency:.3}</td></tr>
<tr><td>Mean</td><td>{mean_latency:.3}</td></tr>
<tr><td>Std Dev</td><td>{stddev_latency:.3}</td></tr>
</table>
<h2>Request Breakdown</h2>
<table>
<tr>
<th>Status</th>
<th>Count</th>
<th>Percentage</th>
</tr>
<tr>
<td>Successful</td>
<td>{successful_requests}</td>
<td>{success_pct:.2}%</td>
</tr>
<tr>
<td>Failed</td>
<td>{failed_requests}</td>
<td>{failed_pct:.2}%</td>
</tr>
<tr>
<td>Timeout</td>
<td>{timeout_requests}</td>
<td>{timeout_pct:.2}%</td>
</tr>
<tr>
<td>Rate Limited</td>
<td>{rate_limited_requests}</td>
<td>{rate_limited_pct:.2}%</td>
</tr>
<tr>
<td>Circuit Breaker</td>
<td>{circuit_breaker_requests}</td>
<td>{circuit_breaker_pct:.2}%</td>
</tr>
</table>
{per_service_stats}
{capacity_recommendation}
<h2>Performance Over Time</h2>
<div class="chart">
<h3>Requests Per Second</h3>
<img src="{rps_chart_filename}" alt="RPS Chart">
</div>
<div class="chart">
<h3>P99 Latency</h3>
<img src="{latency_chart_filename}" alt="Latency Chart">
</div>
<div class="chart">
<h3>Error Rate</h3>
<img src="{error_chart_filename}" alt="Error Rate Chart">
</div>
</div>
</body>
</html>"#,
test_name = report.test_name,
start_time = report.start_time.format("%Y-%m-%d %H:%M:%S UTC"),
end_time = report.end_time.format("%Y-%m-%d %H:%M:%S UTC"),
duration_secs = report.metrics.duration.as_secs(),
duration_hours = report.metrics.duration.as_secs_f64() / 3600.0,
total_requests = report.metrics.total_requests,
rps = report.metrics.requests_per_second,
rps_class = if report.metrics.requests_per_second > 1000.0 {
"success"
} else {
"warning"
},
error_rate = report.metrics.error_rate_pct,
error_class = if report.metrics.error_rate_pct < 1.0 {
"success"
} else if report.metrics.error_rate_pct < 5.0 {
"warning"
} else {
"error"
},
p99_latency = report.metrics.latency_stats.p99_ms,
latency_class = if report.metrics.latency_stats.p99_ms < 10.0 {
"success"
} else if report.metrics.latency_stats.p99_ms < 50.0 {
"warning"
} else {
"error"
},
min_latency = report.metrics.latency_stats.min_ms,
p50_latency = report.metrics.latency_stats.p50_ms,
p90_latency = report.metrics.latency_stats.p90_ms,
p95_latency = report.metrics.latency_stats.p95_ms,
p99_9_latency = report.metrics.latency_stats.p99_9_ms,
max_latency = report.metrics.latency_stats.max_ms,
mean_latency = report.metrics.latency_stats.mean_ms,
stddev_latency = report.metrics.latency_stats.stddev_ms,
successful_requests = report.metrics.successful_requests,
success_pct =
(f64::from(u32::try_from(report.metrics.successful_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX)))
* 100.0,
failed_requests = report.metrics.failed_requests,
failed_pct = (f64::from(u32::try_from(report.metrics.failed_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX)))
* 100.0,
timeout_requests = report.metrics.timeout_requests,
timeout_pct =
(f64::from(u32::try_from(report.metrics.timeout_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX)))
* 100.0,
rate_limited_requests = report.metrics.rate_limited_requests,
rate_limited_pct =
(f64::from(u32::try_from(report.metrics.rate_limited_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX)))
* 100.0,
circuit_breaker_requests = report.metrics.circuit_breaker_requests,
circuit_breaker_pct =
(f64::from(u32::try_from(report.metrics.circuit_breaker_requests).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX)))
* 100.0,
per_service_stats = generate_per_service_stats_html(&report),
capacity_recommendation = generate_capacity_recommendation_html(&report),
rps_chart_filename = rps_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"),
latency_chart_filename = latency_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"),
error_chart_filename = error_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"),
);
std::fs::write(output_path, html)?;
tracing::info!("HTML report generated: {:?}", output_path);
Ok(())
}
fn generate_per_service_stats_html(report: &LoadTestReport) -> String {
if report.metrics.per_service_stats.is_empty() {
return String::new();
}
let mut html = String::from("<h2>Per-Service Statistics</h2><table>");
html.push_str("<tr><th>Service</th><th>Requests</th><th>Error Rate</th><th>P50</th><th>P95</th><th>P99</th></tr>");
for (service, stats) in &report.metrics.per_service_stats {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{:.2}%</td><td>{:.2}ms</td><td>{:.2}ms</td><td>{:.2}ms</td></tr>",
service, stats.total_requests, stats.error_rate_pct,
stats.latency_stats.p50_ms, stats.latency_stats.p95_ms, stats.latency_stats.p99_ms
));
}
html.push_str("</table>");
html
}
fn generate_capacity_recommendation_html(report: &LoadTestReport) -> String {
if let Some(rec) = &report.capacity_recommendation {
format!(
r#"<div class="recommendation">
<h3>Capacity Recommendation</h3>
<p><strong>Max Sustainable Clients:</strong> {}</p>
<p><strong>Max Sustainable RPS:</strong> {:.0}</p>
{}
<p>{}</p>
</div>"#,
rec.max_sustainable_clients,
rec.max_sustainable_rps,
rec.bottleneck_identified
.as_ref()
.map(|b| format!("<p><strong>Bottleneck:</strong> {}</p>", b))
.unwrap_or_default(),
rec.recommendation
)
} else {
String::new()
}
}
fn generate_rps_chart<P: AsRef<Path>>(path: P, report: &LoadTestReport) -> Result<()> {
let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area();
root.fill(&WHITE)?;
let max_rps = report
.time_series
.iter()
.map(|p| p.rps)
.fold(0.0f64, f64::max);
let mut chart = ChartBuilder::on(&root)
.caption("Requests Per Second", ("sans-serif", 30))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.build_cartesian_2d(0..report.time_series.len(), 0f64..max_rps * 1.1)?;
chart.configure_mesh().draw()?;
chart.draw_series(LineSeries::new(
report
.time_series
.iter()
.enumerate()
.map(|(i, p)| (i, p.rps)),
&BLUE,
))?;
root.present()?;
Ok(())
}
fn generate_latency_chart<P: AsRef<Path>>(path: P, report: &LoadTestReport) -> Result<()> {
let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area();
root.fill(&WHITE)?;
let max_latency = report
.time_series
.iter()
.map(|p| p.p99_latency_ms)
.fold(0.0f64, f64::max);
let mut chart = ChartBuilder::on(&root)
.caption("P99 Latency (ms)", ("sans-serif", 30))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.build_cartesian_2d(0..report.time_series.len(), 0f64..max_latency * 1.1)?;
chart.configure_mesh().draw()?;
chart.draw_series(LineSeries::new(
report
.time_series
.iter()
.enumerate()
.map(|(i, p)| (i, p.p99_latency_ms)),
&RED,
))?;
root.present()?;
Ok(())
}
fn generate_error_rate_chart<P: AsRef<Path>>(path: P, report: &LoadTestReport) -> Result<()> {
let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area();
root.fill(&WHITE)?;
let max_error_rate = report
.time_series
.iter()
.map(|p| p.error_rate_pct)
.fold(0.0f64, f64::max);
let mut chart = ChartBuilder::on(&root)
.caption("Error Rate (%)", ("sans-serif", 30))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.build_cartesian_2d(
0..report.time_series.len(),
0f64..(max_error_rate * 1.1).max(1.0),
)?;
chart.configure_mesh().draw()?;
chart.draw_series(LineSeries::new(
report
.time_series
.iter()
.enumerate()
.map(|(i, p)| (i, p.error_rate_pct)),
&RED,
))?;
root.present()?;
Ok(())
}

View File

@@ -0,0 +1,4 @@
pub mod normal_load;
pub mod spike_load;
pub mod stress_test;
pub mod sustained_load;

View File

@@ -0,0 +1,117 @@
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 NORMAL load test: {} clients for {}s",
num_clients,
duration_secs
);
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
})
};
// Spawn client tasks
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
});
}
// 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);
}
}
// 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(
"Normal Load Test".to_string(),
TestConfig {
num_clients,
duration_secs,
test_type: "normal_load".to_string(),
},
);
// Add capacity recommendation
if report.metrics.error_rate_pct < 1.0 && report.metrics.latency_stats.p99_ms < 10.0 {
report.capacity_recommendation = Some(crate::metrics::CapacityRecommendation {
max_sustainable_clients: num_clients,
max_sustainable_rps: report.metrics.requests_per_second,
bottleneck_identified: None,
recommendation: format!(
"System handled {} clients with {:.2}% error rate and {:.2}ms P99 latency. \
System is performing well under normal load.",
num_clients, report.metrics.error_rate_pct, report.metrics.latency_stats.p99_ms
),
});
} else {
let bottleneck = if report.metrics.error_rate_pct >= 1.0 {
"High error rate indicates potential backend service overload"
} else {
"High latency indicates potential performance bottleneck"
};
report.capacity_recommendation = Some(crate::metrics::CapacityRecommendation {
max_sustainable_clients: usize::try_from(
(f64::from(u32::try_from(num_clients).unwrap_or(u32::MAX)) * 0.8) as u64,
)
.unwrap_or(usize::MAX), // Estimate 80% as safe
max_sustainable_rps: report.metrics.requests_per_second * 0.8,
bottleneck_identified: Some(bottleneck.to_string()),
recommendation: format!(
"System showed degradation at {} clients ({:.2}% error rate, {:.2}ms P99). \
Recommend staying below {} concurrent clients for production.",
num_clients,
report.metrics.error_rate_pct,
report.metrics.latency_stats.p99_ms,
usize::try_from(
(f64::from(u32::try_from(num_clients).unwrap_or(u32::MAX)) * 0.8) as u64
)
.unwrap_or(usize::MAX)
),
});
}
tracing::info!("Normal load test completed: {:?}", report.metrics);
Ok(report)
}

View File

@@ -0,0 +1,148 @@
#![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,
target_clients: usize,
ramp_up_secs: u64,
sustain_secs: u64,
) -> Result<LoadTestReport> {
tracing::info!(
"Starting SPIKE load test: 0→{} clients in {}s, sustain {}s",
target_clients,
ramp_up_secs,
sustain_secs
);
let (metrics_tx, metrics_rx) = mpsc::unbounded_channel();
let mut collector = MetricsCollector::new(metrics_rx);
// Spawn collector task
let collector_handle = {
let target_clients_clone = target_clients;
tokio::spawn(async move {
collector.run(target_clients_clone).await;
collector
})
};
let mut join_set = JoinSet::new();
let total_duration = std::time::Duration::from_secs(ramp_up_secs + sustain_secs);
let ramp_up_duration = std::time::Duration::from_secs(ramp_up_secs);
// Calculate how many clients to spawn per interval
let spawn_interval_ms = 100; // Spawn clients every 100ms
let intervals_in_ramp_up =
u64::from(u32::try_from(ramp_up_secs * 1000 / spawn_interval_ms).unwrap_or(u32::MAX));
let clients_per_interval = usize::try_from(
(f64::from(u32::try_from(target_clients).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(intervals_in_ramp_up).unwrap_or(u32::MAX)))
.ceil() as u64,
)
.unwrap_or(usize::MAX);
let start_time = std::time::Instant::now();
let mut clients_spawned = 0;
// Ramp up phase: gradually spawn clients
while start_time.elapsed() < ramp_up_duration && clients_spawned < target_clients {
let batch_size = std::cmp::min(clients_per_interval, target_clients - clients_spawned);
for i in 0..batch_size {
let client_id = clients_spawned + i;
let gateway_url = gateway_url.clone();
let metrics_tx = metrics_tx.clone();
let remaining_duration = total_duration - start_time.elapsed();
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(remaining_duration).await
});
}
clients_spawned += batch_size;
tracing::debug!("Spawned {} / {} clients", clients_spawned, target_clients);
tokio::time::sleep(tokio::time::Duration::from_millis(spawn_interval_ms)).await;
}
tracing::info!(
"Ramp-up complete: {} clients active, sustaining for {}s",
clients_spawned,
sustain_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);
}
}
// 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(
"Spike Load Test".to_owned(),
TestConfig {
num_clients: target_clients,
duration_secs: ramp_up_secs + sustain_secs,
test_type: "spike_load".to_owned(),
},
);
// Add capacity recommendation based on circuit breaker activations
let circuit_breaker_activated = report.metrics.circuit_breaker_requests > 0;
let graceful_degradation = report.metrics.error_rate_pct < 10.0;
report.capacity_recommendation = Some(crate::metrics::CapacityRecommendation {
max_sustainable_clients: target_clients,
max_sustainable_rps: report.metrics.requests_per_second,
bottleneck_identified: if circuit_breaker_activated {
Some("Circuit breaker activated during spike".to_owned())
} else if !graceful_degradation {
Some("High error rate during spike".to_owned())
} else {
None
},
recommendation: if graceful_degradation && !circuit_breaker_activated {
format!(
"System handled spike to {} clients gracefully. Error rate: {:.2}%, P99: {:.2}ms. \
Circuit breakers did not activate - good resilience.",
target_clients, report.metrics.error_rate_pct, report.metrics.latency_stats.p99_ms
)
} else if circuit_breaker_activated {
format!(
"System activated circuit breakers during spike. {} circuit breaker trips recorded. \
This is expected behavior for fault tolerance. Review backend service capacity.",
report.metrics.circuit_breaker_requests
)
} else {
format!(
"System showed degradation during spike: {:.2}% error rate. \
Consider implementing rate limiting or adding capacity.",
report.metrics.error_rate_pct
)
},
});
tracing::info!("Spike load test completed: {:?}", report.metrics);
Ok(report)
}

View File

@@ -0,0 +1,206 @@
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,
initial_clients: usize,
increment: usize,
increment_interval_secs: u64,
max_p99_latency_ms: f64,
max_error_rate_pct: f64,
) -> Result<LoadTestReport> {
tracing::info!(
"Starting STRESS test: initial {} clients, increment by {} every {}s",
initial_clients,
increment,
increment_interval_secs
);
let (metrics_tx, metrics_rx) = mpsc::unbounded_channel();
let mut collector = MetricsCollector::new(metrics_rx);
// Spawn collector task
let collector_handle = tokio::spawn(async move {
collector.run(initial_clients).await;
collector
});
let mut join_set = JoinSet::new();
#[allow(unused_assignments)]
let mut current_clients = 0usize;
#[allow(unused_assignments)]
let mut max_clients_reached = initial_clients;
let increment_duration = std::time::Duration::from_secs(increment_interval_secs);
let test_start = std::time::Instant::now();
// Helper to spawn a batch of clients
let spawn_clients = |join_set: &mut JoinSet<Result<()>>,
metrics_tx: &mpsc::UnboundedSender<crate::metrics::RequestMetric>,
gateway_url: &String,
start_id: usize,
count: usize| {
for i in 0..count {
let client_id = start_id + i;
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);
// Run for a long time (clients will be terminated when threshold is hit)
mixed_client
.run_mixed_workload(std::time::Duration::from_secs(3600))
.await
});
}
};
// Spawn initial batch
spawn_clients(
&mut join_set,
&metrics_tx,
&gateway_url,
0_usize,
initial_clients,
);
current_clients = initial_clients;
tracing::info!("Initial {} clients spawned", current_clients);
// Incremental load increase loop
loop {
// Wait for increment interval
tokio::time::sleep(increment_duration).await;
// Check current performance metrics (simplified - in real scenario, query collector)
// For now, we'll use a heuristic: if we've had any errors in recent metrics, consider stopping
// In production, you'd pull latest metrics from collector via a channel
// Spawn next batch
spawn_clients(
&mut join_set,
&metrics_tx,
&gateway_url,
current_clients,
increment,
);
current_clients += increment;
max_clients_reached = current_clients;
tracing::info!(
"Increased load: {} active clients (elapsed: {}s)",
current_clients,
test_start.elapsed().as_secs()
);
// In a real implementation, we'd check live metrics here
// For demonstration, we'll run until a maximum (e.g., 5000 clients or 10 minutes)
if current_clients >= 5000 || test_start.elapsed().as_secs() >= 600 {
tracing::info!(
"Stress test limit reached: {} clients or 10 minutes elapsed",
current_clients
);
break;
}
}
// Let the current load run for one more interval to measure performance
tracing::info!(
"Sustaining max load ({} clients) for {}s to measure breaking point...",
current_clients,
increment_interval_secs
);
tokio::time::sleep(increment_duration).await;
// Abort all client tasks
join_set.shutdown().await;
// 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(
"Stress Test".to_owned(),
TestConfig {
num_clients: max_clients_reached,
duration_secs: test_start.elapsed().as_secs(),
test_type: "stress_test".to_owned(),
},
);
// Determine breaking point and capacity limits
let breaking_point_identified = report.metrics.error_rate_pct >= max_error_rate_pct
|| report.metrics.latency_stats.p99_ms >= max_p99_latency_ms;
let recommended_max_clients = if breaking_point_identified {
// Recommend 80% of breaking point as safe limit
usize::try_from(
(f64::from(u32::try_from(max_clients_reached).unwrap_or(u32::MAX)) * 0.8) as u64,
)
.unwrap_or(usize::MAX)
} else {
max_clients_reached
};
let bottleneck = if report.metrics.error_rate_pct >= max_error_rate_pct {
Some(format!(
"Error rate threshold exceeded: {:.2}% (max: {:.2}%)",
report.metrics.error_rate_pct, max_error_rate_pct
))
} else if report.metrics.latency_stats.p99_ms >= max_p99_latency_ms {
Some(format!(
"Latency threshold exceeded: {:.2}ms P99 (max: {:.2}ms)",
report.metrics.latency_stats.p99_ms, max_p99_latency_ms
))
} else {
Some("Test limit reached without failure".to_owned())
};
report.capacity_recommendation = Some(crate::metrics::CapacityRecommendation {
max_sustainable_clients: recommended_max_clients,
max_sustainable_rps: report.metrics.requests_per_second * 0.8,
bottleneck_identified: bottleneck.clone(),
recommendation: if breaking_point_identified {
format!(
"BREAKING POINT IDENTIFIED at {} clients. {}\n\
Recommended production capacity: {} concurrent clients ({} RPS).\n\
System degraded with {:.2}% error rate and {:.2}ms P99 latency.",
max_clients_reached,
bottleneck.unwrap_or_default(),
recommended_max_clients,
usize::try_from((report.metrics.requests_per_second * 0.8) as u64)
.unwrap_or(usize::MAX),
report.metrics.error_rate_pct,
report.metrics.latency_stats.p99_ms
)
} else {
format!(
"System sustained {} clients without failure (test limit reached).\n\
Error rate: {:.2}%, P99 latency: {:.2}ms.\n\
Breaking point not identified within test constraints. \
System capacity exceeds {} concurrent clients.",
max_clients_reached,
report.metrics.error_rate_pct,
report.metrics.latency_stats.p99_ms,
max_clients_reached
)
},
});
tracing::info!("Stress test completed: {:?}", report.metrics);
Ok(report)
}

View File

@@ -0,0 +1,224 @@
#![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
}