Files
foxhunt/testing/integration/chaos/nightly_chaos_runner.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

805 lines
26 KiB
Rust

//! Nightly Chaos Job Automation
//!
//! Automated scheduling and execution of chaos engineering tests
//! for continuous validation of system resilience.
use anyhow::{Context, Result};
use chrono::{Datelike, DateTime, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::fs;
use tokio::sync::{broadcast, RwLock};
use tokio::time::{interval, sleep_until, timeout, Instant};
use tracing::{error, info, warn};
use uuid::Uuid;
use super::chaos_framework::ChaosResult;
use super::ml_training_chaos::{MLChaosConfig, MLChaosResult, MLTrainingChaosTests};
/// Nightly chaos job configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NightlyChaosConfig {
pub enabled: bool,
pub schedule_time: NaiveTime, // Time to run chaos tests (e.g., 2:00 AM)
pub timezone: String, // Timezone for scheduling (e.g., "UTC", "America/New_York")
pub max_duration_hours: u8, // Maximum time chaos tests can run
pub notification_webhook: Option<String>, // Slack/Teams webhook for alerts
pub report_storage_path: PathBuf,
pub ml_chaos_config: MLChaosConfig,
pub exclude_weekends: bool,
pub retry_on_failure: bool,
pub max_retries: u8,
}
impl Default for NightlyChaosConfig {
fn default() -> Self {
Self {
enabled: true,
schedule_time: NaiveTime::from_hms_opt(2, 0, 0).unwrap(), // 2:00 AM
timezone: "UTC".to_string(),
max_duration_hours: 4,
notification_webhook: None,
report_storage_path: PathBuf::from("/tmp/chaos_reports"),
ml_chaos_config: MLChaosConfig {
ml_service_endpoint: "http://localhost:8080".to_string(),
checkpoint_base_path: PathBuf::from("/tmp/ml_checkpoints"),
model_types: vec![
super::ml_training_chaos::ModelType::TLOB,
super::ml_training_chaos::ModelType::DQN,
super::ml_training_chaos::ModelType::MAMBA2,
],
training_timeout_secs: 300,
max_recovery_time_ms: 100, // HFT requirement
gpu_memory_threshold_mb: 8192,
},
exclude_weekends: true,
retry_on_failure: true,
max_retries: 2,
}
}
}
/// Chaos job execution status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChaosJobStatus {
Scheduled,
Running,
Completed,
Failed,
Cancelled,
Retrying { attempt: u8 },
}
/// Chaos job execution record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChaosJobExecution {
pub id: Uuid,
pub scheduled_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub status: ChaosJobStatus,
pub chaos_results: Vec<ChaosResult>,
pub ml_chaos_results: Vec<MLChaosResult>,
pub total_experiments: usize,
pub successful_experiments: usize,
pub failed_experiments: usize,
pub report_path: Option<PathBuf>,
pub error_messages: Vec<String>,
pub performance_summary: Option<PerformanceSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceSummary {
pub total_recovery_time_ms: u64,
pub average_recovery_time_ms: f64,
pub max_recovery_time_ms: u64,
pub sla_violations: usize, // Recovery times > max_recovery_time_ms
pub performance_regressions: usize,
pub checkpoint_failures: usize,
}
/// Nightly chaos job scheduler and runner
pub struct NightlyChaosRunner {
config: Arc<RwLock<NightlyChaosConfig>>,
job_history: Arc<RwLock<Vec<ChaosJobExecution>>>,
_event_sender: broadcast::Sender<ChaosJobEvent>,
is_running: Arc<RwLock<bool>>,
}
#[derive(Debug, Clone)]
pub enum ChaosJobEvent {
JobScheduled {
id: Uuid,
scheduled_time: DateTime<Utc>,
},
JobStarted {
id: Uuid,
},
JobCompleted {
id: Uuid,
summary: PerformanceSummary,
},
JobFailed {
id: Uuid,
error: String,
},
JobRetrying {
id: Uuid,
attempt: u8,
},
AlertTriggered {
message: String,
severity: AlertSeverity,
},
}
#[derive(Debug, Clone)]
pub enum AlertSeverity {
Info,
Warning,
Critical,
}
impl NightlyChaosRunner {
pub fn new(config: NightlyChaosConfig) -> Self {
let (_event_sender, _) = broadcast::channel(1000);
Self {
config: Arc::new(RwLock::new(config)),
job_history: Arc::new(RwLock::new(Vec::new())),
_event_sender,
is_running: Arc::new(RwLock::new(false)),
}
}
/// Start the nightly chaos job scheduler
pub async fn start(&self) -> Result<()> {
{
let mut running = self.is_running.write().await;
if *running {
return Err(anyhow::anyhow!("Chaos runner is already running"));
}
*running = true;
}
info!("Starting nightly chaos job scheduler");
let config = self.config.read().await.clone();
if !config.enabled {
warn!("Nightly chaos jobs are disabled in configuration");
return Ok(());
}
// Create report storage directory
fs::create_dir_all(&config.report_storage_path)
.await
.context("Failed to create report storage directory")?;
// Start scheduling loop
let scheduler_handle = {
let runner = self.clone();
tokio::spawn(async move {
runner.scheduling_loop().await;
})
};
// Wait for the scheduler (it runs indefinitely)
let _ = scheduler_handle.await;
Ok(())
}
/// Stop the chaos job scheduler
pub async fn stop(&self) {
let mut running = self.is_running.write().await;
*running = false;
info!("Stopped nightly chaos job scheduler");
}
/// Main scheduling loop
async fn scheduling_loop(&self) {
let mut check_interval = interval(Duration::from_secs(60)); // Check every minute
loop {
check_interval.tick().await;
// Check if we should stop
{
let running = self.is_running.read().await;
if !*running {
break;
}
}
let config = self.config.read().await.clone();
if !config.enabled {
continue;
}
// Check if it's time to run chaos tests
if self.should_run_chaos_tests(&config).await {
match self.schedule_chaos_job().await {
Ok(job_id) => {
info!("Scheduled chaos job: {}", job_id);
// Execute the job
let runner = self.clone();
tokio::spawn(async move {
if let Err(e) = runner.execute_chaos_job(job_id).await {
error!("Chaos job execution failed: {}", e);
}
});
}
Err(e) => {
error!("Failed to schedule chaos job: {}", e);
}
}
// Wait 24 hours before next check to avoid duplicate runs
sleep_until(Instant::now() + Duration::from_secs(24 * 60 * 60)).await;
}
}
}
/// Check if chaos tests should run now
async fn should_run_chaos_tests(&self, config: &NightlyChaosConfig) -> bool {
let now = Utc::now();
// Skip weekends if configured
if config.exclude_weekends {
let weekday = now.weekday();
if weekday == chrono::Weekday::Sat || weekday == chrono::Weekday::Sun {
return false;
}
}
// Check if it's the scheduled time (within 1 minute window)
let current_time = now.time();
let schedule_time = config.schedule_time;
let diff = if current_time >= schedule_time {
current_time - schedule_time
} else {
// Handle day boundary
chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap() - schedule_time
+ chrono::Duration::seconds(60)
+ (current_time - chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
};
// Run if within 1 minute of scheduled time
diff <= Duration::minutes(1)
}
/// Schedule a new chaos job
async fn schedule_chaos_job(&self) -> Result<Uuid> {
let job_id = Uuid::new_v4();
let now = Utc::now();
let job = ChaosJobExecution {
id: job_id,
scheduled_at: now,
started_at: None,
completed_at: None,
status: ChaosJobStatus::Scheduled,
chaos_results: Vec::new(),
ml_chaos_results: Vec::new(),
total_experiments: 0,
successful_experiments: 0,
failed_experiments: 0,
report_path: None,
error_messages: Vec::new(),
performance_summary: None,
};
// Add to job history
{
let mut history = self.job_history.write().await;
history.push(job.clone());
}
// Send scheduling event
let _ = self._event_sender.send(ChaosJobEvent::JobScheduled {
id: job_id,
scheduled_time: now,
});
Ok(job_id)
}
/// Execute a chaos job
async fn execute_chaos_job(&self, job_id: Uuid) -> Result<()> {
info!("Executing chaos job: {}", job_id);
// Update job status
self.update_job_status(job_id, ChaosJobStatus::Running)
.await?;
self.update_job_start_time(job_id, Some(Utc::now())).await?;
// Send start event
let _ = self
._event_sender
.send(ChaosJobEvent::JobStarted { id: job_id });
let config = self.config.read().await.clone();
let mut attempt = 1;
loop {
match self.run_chaos_experiments(job_id, &config).await {
Ok(summary) => {
// Job succeeded
self.update_job_status(job_id, ChaosJobStatus::Completed)
.await?;
self.update_job_completion_time(job_id, Some(Utc::now()))
.await?;
self.update_job_performance_summary(job_id, Some(summary.clone()))
.await?;
// Generate and save report
if let Err(e) = self.generate_and_save_report(job_id).await {
error!("Failed to generate chaos job report: {}", e);
}
// Send completion event
let _ = self._event_sender.send(ChaosJobEvent::JobCompleted {
id: job_id,
summary: summary.clone(),
});
// Send alerts if needed
self.check_and_send_alerts(job_id, &summary).await;
info!("Chaos job completed successfully: {}", job_id);
break;
}
Err(e) => {
error!("Chaos job failed (attempt {}): {}", attempt, e);
if config.retry_on_failure && attempt <= config.max_retries {
// Retry the job
self.update_job_status(job_id, ChaosJobStatus::Retrying { attempt })
.await?;
self.add_job_error(job_id, format!("Attempt {} failed: {}", attempt, e))
.await?;
let _ = self._event_sender.send(ChaosJobEvent::JobRetrying {
id: job_id,
attempt,
});
attempt += 1;
sleep_until(Instant::now() + Duration::from_secs(300)).await; // Wait 5 minutes before retry
continue;
} else {
// Job failed permanently
self.update_job_status(job_id, ChaosJobStatus::Failed)
.await?;
self.update_job_completion_time(job_id, Some(Utc::now()))
.await?;
self.add_job_error(job_id, e.to_string()).await?;
let _ = self._event_sender.send(ChaosJobEvent::JobFailed {
id: job_id,
error: e.to_string(),
});
error!("Chaos job failed permanently: {}", job_id);
break;
}
}
}
}
Ok(())
}
/// Run all chaos experiments
async fn run_chaos_experiments(
&self,
job_id: Uuid,
config: &NightlyChaosConfig,
) -> Result<PerformanceSummary> {
info!("Running chaos experiments for job: {}", job_id);
// Initialize ML chaos tests
let ml_chaos_tests = MLTrainingChaosTests::new(config.ml_chaos_config.clone());
// Run ML-specific chaos experiments
let ml_results = timeout(
Duration::from_secs(config.max_duration_hours as u64 * 3600),
ml_chaos_tests.run_ml_chaos_suite(),
)
.await
.context("ML chaos tests timed out")?
.context("ML chaos tests failed")?;
// Update job with ML results
self.update_job_ml_results(job_id, ml_results.clone())
.await?;
// Calculate performance summary
let summary = self.calculate_performance_summary(&[], &ml_results);
Ok(summary)
}
/// Calculate performance summary from results
fn calculate_performance_summary(
&self,
chaos_results: &[ChaosResult],
ml_results: &[MLChaosResult],
) -> PerformanceSummary {
let mut total_recovery_time_ms = 0u64;
let mut recovery_times = Vec::new();
let mut sla_violations = 0;
let mut performance_regressions = 0;
let mut checkpoint_failures = 0;
// Process general chaos results
for result in chaos_results {
if let Some(recovery_time) = result.recovery_time_ms {
total_recovery_time_ms += recovery_time;
recovery_times.push(recovery_time);
// Check for SLA violations (assuming 100ms max recovery time for HFT)
if recovery_time > 100 {
sla_violations += 1;
}
}
if result.performance_regression.is_some() {
performance_regressions += 1;
}
if !result.checkpoint_integrity {
checkpoint_failures += 1;
}
}
// Process ML-specific results
for ml_result in ml_results {
if !ml_result.training_loss_continuity {
checkpoint_failures += 1;
}
if ml_result.performance_regression.is_some() {
performance_regressions += 1;
}
}
let average_recovery_time_ms = if !recovery_times.is_empty() {
total_recovery_time_ms as f64 / recovery_times.len() as f64
} else {
0.0
};
let max_recovery_time_ms = recovery_times.into_iter().max().unwrap_or(0);
PerformanceSummary {
total_recovery_time_ms,
average_recovery_time_ms,
max_recovery_time_ms,
sla_violations,
performance_regressions,
checkpoint_failures,
}
}
/// Generate and save chaos job report
async fn generate_and_save_report(&self, job_id: Uuid) -> Result<()> {
let job = {
let history = self.job_history.read().await;
history
.iter()
.find(|j| j.id == job_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Job not found: {}", job_id))?
};
let config = self.config.read().await.clone();
// Generate ML chaos report
let ml_chaos_tests = MLTrainingChaosTests::new(config.ml_chaos_config.clone());
let ml_report = ml_chaos_tests
.generate_chaos_report(&job.ml_chaos_results)
.await?;
// Generate comprehensive report
let mut full_report = String::new();
full_report.push_str("# Nightly Chaos Engineering Report\n\n");
full_report.push_str(&format!("**Job ID:** {}\n", job.id));
full_report.push_str(&format!("**Scheduled:** {}\n", job.scheduled_at));
full_report.push_str(&format!(
"**Started:** {}\n",
job.started_at.map_or("N/A".to_string(), |t| t.to_string())
));
full_report.push_str(&format!(
"**Completed:** {}\n",
job.completed_at
.map_or("N/A".to_string(), |t| t.to_string())
));
full_report.push_str(&format!("**Status:** {:?}\n\n", job.status));
// Performance summary
if let Some(ref summary) = job.performance_summary {
full_report.push_str("## Performance Summary\n");
full_report.push_str(&format!(
"- **Average Recovery Time:** {:.1}ms\n",
summary.average_recovery_time_ms
));
full_report.push_str(&format!(
"- **Max Recovery Time:** {}ms\n",
summary.max_recovery_time_ms
));
full_report.push_str(&format!(
"- **SLA Violations:** {}\n",
summary.sla_violations
));
full_report.push_str(&format!(
"- **Performance Regressions:** {}\n",
summary.performance_regressions
));
full_report.push_str(&format!(
"- **Checkpoint Failures:** {}\n\n",
summary.checkpoint_failures
));
}
// Add ML-specific report
full_report.push_str(&ml_report);
// Add error messages if any
if !job.error_messages.is_empty() {
full_report.push_str("\n## Error Messages\n");
for error in &job.error_messages {
full_report.push_str(&format!("- {}\n", error));
}
}
// Save report to file
let report_filename = format!(
"chaos_report_{}_{}.md",
job_id,
job.scheduled_at.format("%Y%m%d_%H%M%S")
);
let report_path = config.report_storage_path.join(report_filename);
fs::write(&report_path, full_report)
.await
.context("Failed to save chaos report")?;
// Update job with report path
self.update_job_report_path(job_id, Some(report_path))
.await?;
Ok(())
}
/// Check for alerts and send notifications
async fn check_and_send_alerts(&self, job_id: Uuid, summary: &PerformanceSummary) {
let config = self.config.read().await.clone();
// Check for critical alerts
if summary.sla_violations > 0 {
let message = format!(
"🚨 CRITICAL: {} SLA violations detected in chaos job {}. Max recovery time: {}ms",
summary.sla_violations, job_id, summary.max_recovery_time_ms
);
let _ = self._event_sender.send(ChaosJobEvent::AlertTriggered {
message: message.clone(),
severity: AlertSeverity::Critical,
});
if let Some(ref webhook) = config.notification_webhook {
self.send_webhook_notification(webhook, &message, AlertSeverity::Critical)
.await;
}
}
// Check for warning alerts
if summary.checkpoint_failures > 0 {
let message = format!(
"⚠️ WARNING: {} checkpoint failures detected in chaos job {}",
summary.checkpoint_failures, job_id
);
let _ = self._event_sender.send(ChaosJobEvent::AlertTriggered {
message: message.clone(),
severity: AlertSeverity::Warning,
});
if let Some(ref webhook) = config.notification_webhook {
self.send_webhook_notification(webhook, &message, AlertSeverity::Warning)
.await;
}
}
// Send success notification
if summary.sla_violations == 0 && summary.checkpoint_failures == 0 {
let message = format!(
"✅ Chaos job {} completed successfully. Avg recovery time: {:.1}ms",
job_id, summary.average_recovery_time_ms
);
if let Some(ref webhook) = config.notification_webhook {
self.send_webhook_notification(webhook, &message, AlertSeverity::Info)
.await;
}
}
}
/// Send webhook notification
async fn send_webhook_notification(
&self,
webhook_url: &str,
message: &str,
severity: AlertSeverity,
) {
// TODO: Implement actual webhook sending (Slack, Teams, etc.)
info!(
"Sending {} alert: {}",
match severity {
AlertSeverity::Info => "INFO",
AlertSeverity::Warning => "WARNING",
AlertSeverity::Critical => "CRITICAL",
},
message
);
}
/// Subscribe to chaos job events
pub fn subscribe_events(&self) -> broadcast::Receiver<ChaosJobEvent> {
self._event_sender.subscribe()
}
/// Get job history
pub async fn get_job_history(&self) -> Vec<ChaosJobExecution> {
self.job_history.read().await.clone()
}
/// Update configuration
pub async fn update_config(&self, new_config: NightlyChaosConfig) {
let mut config = self.config.write().await;
*config = new_config;
info!("Updated nightly chaos configuration");
}
// Helper methods for updating job state
async fn update_job_status(&self, job_id: Uuid, status: ChaosJobStatus) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.status = status;
}
Ok(())
}
async fn update_job_start_time(
&self,
job_id: Uuid,
start_time: Option<DateTime<Utc>>,
) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.started_at = start_time;
}
Ok(())
}
async fn update_job_completion_time(
&self,
job_id: Uuid,
completion_time: Option<DateTime<Utc>>,
) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.completed_at = completion_time;
}
Ok(())
}
async fn update_job_ml_results(
&self,
job_id: Uuid,
ml_results: Vec<MLChaosResult>,
) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.ml_chaos_results = ml_results;
job.total_experiments = job.chaos_results.len() + job.ml_chaos_results.len();
job.successful_experiments = job
.ml_chaos_results
.iter()
.filter(|r| r.training_loss_continuity)
.count();
job.failed_experiments = job.total_experiments - job.successful_experiments;
}
Ok(())
}
async fn update_job_performance_summary(
&self,
job_id: Uuid,
summary: Option<PerformanceSummary>,
) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.performance_summary = summary;
}
Ok(())
}
async fn update_job_report_path(
&self,
job_id: Uuid,
report_path: Option<PathBuf>,
) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.report_path = report_path;
}
Ok(())
}
async fn add_job_error(&self, job_id: Uuid, error: String) -> Result<()> {
let mut history = self.job_history.write().await;
if let Some(job) = history.iter_mut().find(|j| j.id == job_id) {
job.error_messages.push(error);
}
Ok(())
}
}
impl Clone for NightlyChaosRunner {
fn clone(&self) -> Self {
Self {
config: Arc::clone(&self.config),
job_history: Arc::clone(&self.job_history),
_event_sender: self._event_sender.clone(),
is_running: Arc::clone(&self.is_running),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_nightly_chaos_runner() {
let config = NightlyChaosConfig::default();
let runner = NightlyChaosRunner::new(config);
// Test event subscription
let mut event_receiver = runner.subscribe_events();
assert!(event_receiver.try_recv().is_err()); // No events yet
// Test job history
let history = runner.get_job_history().await;
assert!(history.is_empty());
}
#[test]
fn test_performance_summary() {
let ml_results = vec![MLChaosResult {
experiment_id: Uuid::new_v4(),
model_type: super::ml_training_chaos::ModelType::TLOB,
training_job_id: Some("test_job".to_string()),
checkpoint_before_failure: None,
checkpoint_after_recovery: None,
model_accuracy_before: None,
model_accuracy_after: None,
training_loss_continuity: true,
gpu_memory_recovery: None,
performance_regression: None,
}];
let config = NightlyChaosConfig::default();
let runner = NightlyChaosRunner::new(config);
let summary = runner.calculate_performance_summary(&[], &ml_results);
assert_eq!(summary.checkpoint_failures, 0);
}
}