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:
321
testing/api-load/src/clients/authenticated_client.rs
Normal file
321
testing/api-load/src/clients/authenticated_client.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
83
testing/api-load/src/clients/mixed_workload.rs
Normal file
83
testing/api-load/src/clients/mixed_workload.rs
Normal 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(())
|
||||
}
|
||||
|
||||
}
|
||||
5
testing/api-load/src/clients/mod.rs
Normal file
5
testing/api-load/src/clients/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod authenticated_client;
|
||||
pub mod mixed_workload;
|
||||
|
||||
pub use authenticated_client::*;
|
||||
pub use mixed_workload::*;
|
||||
100
testing/api-load/src/config.rs
Normal file
100
testing/api-load/src/config.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
196
testing/api-load/src/main.rs
Normal file
196
testing/api-load/src/main.rs
Normal 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(())
|
||||
}
|
||||
244
testing/api-load/src/metrics/collector.rs
Normal file
244
testing/api-load/src/metrics/collector.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
126
testing/api-load/src/metrics/mod.rs
Normal file
126
testing/api-load/src/metrics/mod.rs
Normal 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,
|
||||
}
|
||||
2
testing/api-load/src/orchestrator.rs
Normal file
2
testing/api-load/src/orchestrator.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Orchestrator module for managing load test execution
|
||||
// This module provides utilities for coordinating multiple test scenarios
|
||||
443
testing/api-load/src/reporting.rs
Normal file
443
testing/api-load/src/reporting.rs
Normal 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(())
|
||||
}
|
||||
4
testing/api-load/src/scenarios/mod.rs
Normal file
4
testing/api-load/src/scenarios/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod normal_load;
|
||||
pub mod spike_load;
|
||||
pub mod stress_test;
|
||||
pub mod sustained_load;
|
||||
117
testing/api-load/src/scenarios/normal_load.rs
Normal file
117
testing/api-load/src/scenarios/normal_load.rs
Normal 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)
|
||||
}
|
||||
148
testing/api-load/src/scenarios/spike_load.rs
Normal file
148
testing/api-load/src/scenarios/spike_load.rs
Normal 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)
|
||||
}
|
||||
206
testing/api-load/src/scenarios/stress_test.rs
Normal file
206
testing/api-load/src/scenarios/stress_test.rs
Normal 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)
|
||||
}
|
||||
224
testing/api-load/src/scenarios/sustained_load.rs
Normal file
224
testing/api-load/src/scenarios/sustained_load.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user