Files
foxhunt/services/ml_training_service/tests/load/load_generator.rs
jgrusewski eae3c31e53 fix(clippy): Fix 6 unwrap_used violations in risk/data
Patterns applied:
- Pattern 2: Float comparison (2x: utils.rs, var_edge_cases_tests.rs)
- Pattern 7: Date/time construction (2x: production_streaming.rs, streaming.rs)
- Pattern 1: Duration/time ops (2x: rate limiter, semaphore)
- Pattern 4: Optional field access (1x: position_tracker.rs)

Changes:
- data/src/utils.rs: Float sort with NaN handling
- data/src/providers/benzinga/production_streaming.rs: Rate limiter + semaphore + date/time
- data/src/providers/benzinga/streaming.rs: Date/time construction
- risk/src/position_tracker.rs: Emergency fallback counter
- risk/tests/var_edge_cases_tests.rs: Test helper float sort

Test impact: 0 failures (182/182 passing)
Compilation: Clean (0 errors, 0 warnings)
Time: 25 min (44% under budget)
2025-10-23 14:58:32 +02:00

293 lines
8.7 KiB
Rust

//! Load Generator for ML Training Service
//!
//! Configurable load generation for stress testing and performance analysis.
use anyhow::Result;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Load test configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadTestConfig {
/// Total number of operations to perform
pub total_operations: u64,
/// Number of concurrent workers
pub concurrent_workers: usize,
/// Operations per second (0 = unlimited)
pub target_rps: u64,
/// Test duration (None = run until total_operations complete)
pub duration: Option<Duration>,
/// Warmup duration before collecting metrics
pub warmup_duration: Duration,
/// Operation timeout
pub operation_timeout: Duration,
}
impl Default for LoadTestConfig {
fn default() -> Self {
Self {
total_operations: 1000,
concurrent_workers: 10,
target_rps: 0,
duration: None,
warmup_duration: Duration::from_secs(5),
operation_timeout: Duration::from_secs(30),
}
}
}
/// Load test results
#[derive(Debug, Clone)]
pub struct LoadTestResults {
pub total_operations: u64,
pub successful_operations: u64,
pub failed_operations: u64,
pub total_duration: Duration,
pub warmup_duration: Duration,
pub actual_rps: f64,
pub latencies: LatencyStats,
}
/// Latency statistics
#[derive(Debug, Clone)]
pub struct LatencyStats {
pub min: Duration,
pub max: Duration,
pub mean: Duration,
pub p50: Duration,
pub p95: Duration,
pub p99: Duration,
pub p999: Duration,
}
/// Load generator
pub struct LoadGenerator<F, Fut>
where
F: Fn(u64) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<()>> + Send,
{
config: LoadTestConfig,
operation: Arc<F>,
}
impl<F, Fut> LoadGenerator<F, Fut>
where
F: Fn(u64) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<()>> + Send,
{
/// Create a new load generator
pub fn new(config: LoadTestConfig, operation: F) -> Self {
Self {
config,
operation: Arc::new(operation),
}
}
/// Run the load test
pub async fn run(&self) -> Result<LoadTestResults> {
println!("Starting load test with {} workers, target {} ops",
self.config.concurrent_workers, self.config.total_operations);
// Warmup phase
println!("Warmup for {:?}...", self.config.warmup_duration);
let warmup_start = Instant::now();
self.warmup().await?;
let warmup_duration = warmup_start.elapsed();
// Main test phase
println!("Starting main test phase...");
let test_start = Instant::now();
let completed = Arc::new(AtomicU64::new(0));
let succeeded = Arc::new(AtomicU64::new(0));
let failed = Arc::new(AtomicU64::new(0));
let latencies = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut handles = Vec::new();
// Calculate ops per worker
let ops_per_worker = self.config.total_operations / self.config.concurrent_workers as u64;
// Spawn worker tasks
for worker_id in 0..self.config.concurrent_workers {
let operation = self.operation.clone();
let completed_count = completed.clone();
let success_count = succeeded.clone();
let fail_count = failed.clone();
let latency_vec = latencies.clone();
let timeout = self.config.operation_timeout;
let handle = tokio::spawn(async move {
for op_num in 0..ops_per_worker {
let global_op_num = worker_id as u64 * ops_per_worker + op_num;
let op_start = Instant::now();
match tokio::time::timeout(timeout, operation(global_op_num)).await {
Ok(Ok(_)) => {
success_count.fetch_add(1, Ordering::Relaxed);
}
Ok(Err(_)) | Err(_) => {
fail_count.fetch_add(1, Ordering::Relaxed);
}
}
let latency = op_start.elapsed();
latency_vec.lock().expect("INVARIANT: Lock should not be poisoned").push(latency);
completed_count.fetch_add(1, Ordering::Relaxed);
}
});
handles.push(handle);
}
// Wait for all workers
for handle in handles {
handle.await?;
}
let total_duration = test_start.elapsed();
// Calculate statistics
let total_ops = completed.load(Ordering::Relaxed);
let success_ops = succeeded.load(Ordering::Relaxed);
let fail_ops = failed.load(Ordering::Relaxed);
let actual_rps = total_ops as f64 / total_duration.as_secs_f64();
let latency_stats = self.calculate_latency_stats(&latencies.lock().expect("INVARIANT: Lock should not be poisoned"));
println!("✓ Load test complete");
println!(" - Total ops: {}", total_ops);
println!(" - Success: {}", success_ops);
println!(" - Failed: {}", fail_ops);
println!(" - Duration: {:?}", total_duration);
println!(" - RPS: {:.2}", actual_rps);
Ok(LoadTestResults {
total_operations: total_ops,
successful_operations: success_ops,
failed_operations: fail_ops,
total_duration,
warmup_duration,
actual_rps,
latencies: latency_stats,
})
}
/// Warmup phase
async fn warmup(&self) -> Result<()> {
let warmup_ops = 100; // Fixed warmup operation count
let mut handles = Vec::new();
for i in 0..warmup_ops {
let operation = self.operation.clone();
let handle = tokio::spawn(async move {
let _ = operation(i).await;
});
handles.push(handle);
}
for handle in handles {
handle.await?;
}
tokio::time::sleep(self.config.warmup_duration).await;
Ok(())
}
/// Calculate latency statistics
fn calculate_latency_stats(&self, latencies: &[Duration]) -> LatencyStats {
if latencies.is_empty() {
return LatencyStats {
min: Duration::ZERO,
max: Duration::ZERO,
mean: Duration::ZERO,
p50: Duration::ZERO,
p95: Duration::ZERO,
p99: Duration::ZERO,
p999: Duration::ZERO,
};
}
let mut sorted = latencies.to_vec();
sorted.sort();
let len = sorted.len();
let sum: Duration = sorted.iter().sum();
LatencyStats {
min: sorted[0],
max: sorted[len - 1],
mean: sum / len as u32,
p50: sorted[len / 2],
p95: sorted[(len * 95) / 100],
p99: sorted[(len * 99) / 100],
p999: sorted[(len * 999) / 1000],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_load_generator_basic() -> Result<()> {
let config = LoadTestConfig {
total_operations: 100,
concurrent_workers: 10,
warmup_duration: Duration::from_millis(100),
..Default::default()
};
let operation = |_op_num: u64| async move {
tokio::time::sleep(Duration::from_micros(100)).await;
Ok(())
};
let generator = LoadGenerator::new(config, operation);
let results = generator.run().await?;
assert_eq!(results.total_operations, 100);
assert_eq!(results.successful_operations, 100);
assert_eq!(results.failed_operations, 0);
Ok(())
}
#[tokio::test]
async fn test_load_generator_with_failures() -> Result<()> {
let config = LoadTestConfig {
total_operations: 100,
concurrent_workers: 10,
warmup_duration: Duration::from_millis(100),
..Default::default()
};
let operation = |op_num: u64| async move {
tokio::time::sleep(Duration::from_micros(100)).await;
if op_num % 10 == 0 {
Err(anyhow::anyhow!("Simulated failure"))
} else {
Ok(())
}
};
let generator = LoadGenerator::new(config, operation);
let results = generator.run().await?;
assert_eq!(results.total_operations, 100);
assert!(results.failed_operations >= 8, "Expected at least 8 failures");
Ok(())
}
}