Eliminate the entire mixed_precision runtime indirection layer: - Delete crates/ml-core/src/mixed_precision.rs (training_dtype, ensure_training_dtype, align_dim_for_tensor_cores) - Inline ~100 call sites across 130 files to constants: training_dtype(&device) → candle_core::DType::BF16 ensure_training_dtype(x) → x.to_dtype(candle_core::DType::BF16) align_dim_for_tensor_cores(x, &device) → (x + 7) & !7 - Remove re-exports from ml-dqn, ml-supervised, ml lib.rs - Clean config/toml/json/shell references No CPU/Metal training path exists — BF16 is the only dtype. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
//! Failure Recovery Integration Tests
|
|
//!
|
|
//! Tests error handling, reconnection, and partial batch completion
|
|
//!
|
|
//! **Test Coverage:**
|
|
//! - Job failure → Batch status update
|
|
//! - Database connection loss → Reconnect
|
|
//! - File not found → Graceful error
|
|
//! - GPU OOM → Fallback to CPU
|
|
//! - Partial batch completion
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use ml_training_service::{
|
|
database::DatabaseManager,
|
|
orchestrator::{JobStatus, TrainingOrchestrator},
|
|
storage::{ModelStorageManager, StorageConfig},
|
|
};
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
/// Create test orchestrator
|
|
async fn setup_test_orchestrator() -> (Arc<TrainingOrchestrator>, TempDir) {
|
|
let ml_config = config::MLConfig::default();
|
|
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
let db_config = config::database::DatabaseConfig {
|
|
url: database_url.clone(),
|
|
max_connections: 5,
|
|
min_connections: 1,
|
|
connect_timeout: Duration::from_secs(10),
|
|
query_timeout: Duration::from_secs(30),
|
|
enable_query_logging: false,
|
|
application_name: Some("failure_recovery_test".to_string()),
|
|
pool: config::PoolConfig {
|
|
min_connections: 1,
|
|
max_connections: 5,
|
|
acquire_timeout_secs: 5,
|
|
max_lifetime_secs: 3600,
|
|
idle_timeout_secs: 300,
|
|
test_before_acquire: true,
|
|
database_url: database_url.clone(),
|
|
health_check_enabled: true,
|
|
health_check_interval_secs: 60,
|
|
},
|
|
transaction: config::TransactionConfig::default(),
|
|
};
|
|
|
|
let database = Arc::new(
|
|
DatabaseManager::new(&db_config)
|
|
.await
|
|
.expect("Failed to create database"),
|
|
);
|
|
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
let storage_config = StorageConfig {
|
|
storage_type: "local".to_string(),
|
|
local_base_path: Some(temp_dir.path().to_path_buf()),
|
|
enable_compression: false,
|
|
};
|
|
let storage = Arc::new(
|
|
ModelStorageManager::new(storage_config)
|
|
.await
|
|
.expect("Failed to create storage"),
|
|
);
|
|
|
|
let orchestrator = Arc::new(
|
|
TrainingOrchestrator::new(ml_config, database, storage)
|
|
.await
|
|
.expect("Failed to create orchestrator"),
|
|
);
|
|
|
|
(orchestrator, temp_dir)
|
|
}
|
|
|
|
/// Create test config
|
|
fn create_test_config() -> ml::training_pipeline::ProductionTrainingConfig {
|
|
use ml::training_pipeline::{
|
|
FinancialValidationConfig, ModelArchitectureConfig, PerformanceConfig,
|
|
ProductionTrainingConfig, TrainingHyperparameters,
|
|
};
|
|
|
|
ProductionTrainingConfig {
|
|
model_config: ModelArchitectureConfig {
|
|
input_dim: 225,
|
|
output_dim: 1,
|
|
hidden_dims: vec![64, 32],
|
|
dropout_rate: 0.1,
|
|
activation: "relu".to_string(),
|
|
batch_norm: false,
|
|
residual_connections: false,
|
|
},
|
|
training_params: TrainingHyperparameters {
|
|
learning_rate: 0.001,
|
|
batch_size: 32,
|
|
max_epochs: 3,
|
|
patience: 2,
|
|
validation_split: 0.2,
|
|
l2_regularization: 0.0001,
|
|
lr_decay_factor: 0.1,
|
|
lr_decay_patience: 2,
|
|
},
|
|
safety_config: ml::safety::MLSafetyConfig::default(),
|
|
gradient_config: ml::safety::GradientSafetyConfig::default(),
|
|
financial_config: FinancialValidationConfig {
|
|
max_prediction_multiple: 2.0,
|
|
min_prediction_confidence: 0.6,
|
|
validate_position_sizing: true,
|
|
max_position_fraction: 0.25,
|
|
min_sharpe_threshold: 0.5,
|
|
},
|
|
performance_config: PerformanceConfig {
|
|
device_preference: "cpu".to_string(),
|
|
num_workers: 2,
|
|
max_memory_bytes: 2 * 1024 * 1024 * 1024,
|
|
gradient_accumulation_steps: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 1: Job failure → Batch status update
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_job_failure_updates_batch() {
|
|
let (orchestrator, _temp_dir) = setup_test_orchestrator().await;
|
|
|
|
// Submit job
|
|
let config = create_test_config();
|
|
let mut tags = std::collections::HashMap::new();
|
|
tags.insert("failure_test".to_string(), "batch_update".to_string());
|
|
|
|
let job_id = orchestrator
|
|
.submit_job(
|
|
"DQN".to_string(),
|
|
config,
|
|
"Failure test job".to_string(),
|
|
tags,
|
|
)
|
|
.await
|
|
.expect("Failed to submit job");
|
|
|
|
println!("✅ Submitted job: {}", job_id);
|
|
|
|
// Simulate failure by stopping with error
|
|
let stopped = orchestrator
|
|
.stop_job(job_id, "Simulated failure".to_string())
|
|
.await
|
|
.expect("Failed to stop job");
|
|
|
|
assert!(stopped);
|
|
println!("✅ Job stopped (simulating failure)");
|
|
|
|
// Verify job is in Stopped state
|
|
let job = orchestrator.get_job(job_id).await.expect("Failed to get job");
|
|
assert_eq!(job.status, JobStatus::Stopped);
|
|
assert!(job.error_message.is_some());
|
|
println!("✅ Job failure recorded: {:?}", job.error_message);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Database connection loss → Reconnect
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_database_reconnection() {
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
let db_config = config::database::DatabaseConfig {
|
|
url: database_url.clone(),
|
|
max_connections: 5,
|
|
min_connections: 1,
|
|
connect_timeout: Duration::from_secs(10),
|
|
query_timeout: Duration::from_secs(30),
|
|
enable_query_logging: false,
|
|
application_name: Some("reconnect_test".to_string()),
|
|
pool: config::PoolConfig {
|
|
min_connections: 1,
|
|
max_connections: 5,
|
|
acquire_timeout_secs: 5,
|
|
max_lifetime_secs: 3600,
|
|
idle_timeout_secs: 300,
|
|
test_before_acquire: true,
|
|
database_url: database_url.clone(),
|
|
health_check_enabled: true,
|
|
health_check_interval_secs: 60,
|
|
},
|
|
transaction: config::TransactionConfig::default(),
|
|
};
|
|
|
|
// Create first connection
|
|
let database1 = DatabaseManager::new(&db_config)
|
|
.await
|
|
.expect("Failed to create database");
|
|
|
|
println!("✅ Initial database connection established");
|
|
|
|
// Test query
|
|
let result = database1.health_check().await;
|
|
assert!(result.is_ok());
|
|
println!("✅ Health check passed");
|
|
|
|
// Simulate reconnection by creating new manager
|
|
let database2 = DatabaseManager::new(&db_config)
|
|
.await
|
|
.expect("Failed to reconnect database");
|
|
|
|
println!("✅ Database reconnection successful");
|
|
|
|
// Verify new connection works
|
|
let result2 = database2.health_check().await;
|
|
assert!(result2.is_ok());
|
|
println!("✅ Reconnected health check passed");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: File not found → Graceful error
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_file_not_found_error() {
|
|
let (orchestrator, _temp_dir) = setup_test_orchestrator().await;
|
|
|
|
// Try to query non-existent job
|
|
let fake_id = Uuid::new_v4();
|
|
let result = orchestrator.get_job(fake_id).await;
|
|
|
|
assert!(result.is_err());
|
|
println!("✅ Non-existent job returns error");
|
|
|
|
let error = result.unwrap_err();
|
|
println!("✅ Error message: {}", error);
|
|
|
|
// Verify error contains job ID
|
|
let error_str = error.to_string();
|
|
assert!(error_str.contains(&fake_id.to_string()));
|
|
println!("✅ Error contains job ID");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: GPU OOM → Fallback to CPU
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_gpu_oom_cpu_fallback() {
|
|
// Test GPU fallback configuration
|
|
let mut config = create_test_config();
|
|
|
|
// Start with GPU preference
|
|
config.performance_config.device_preference = "cuda".to_string();
|
|
println!("✅ Initial config: device={}", config.performance_config.device_preference);
|
|
|
|
// Simulate GPU OOM by switching to CPU
|
|
config.performance_config.device_preference = "cpu".to_string();
|
|
config.performance_config.max_memory_bytes = 1024 * 1024 * 1024; // 1GB
|
|
|
|
println!("✅ Fallback config: device={}", config.performance_config.device_preference);
|
|
println!("✅ Reduced memory: {} GB", config.performance_config.max_memory_bytes / (1024 * 1024 * 1024));
|
|
|
|
// Verify CPU config is valid
|
|
assert_eq!(config.performance_config.device_preference, "cpu");
|
|
println!("✅ CPU fallback validated");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Partial batch completion
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_partial_batch_completion() {
|
|
let (orchestrator, _temp_dir) = setup_test_orchestrator().await;
|
|
|
|
// Submit batch of 3 jobs
|
|
let config = create_test_config();
|
|
let mut job_ids = Vec::new();
|
|
|
|
for i in 0..3 {
|
|
let mut tags = std::collections::HashMap::new();
|
|
tags.insert("batch".to_string(), "partial_test".to_string());
|
|
tags.insert("index".to_string(), i.to_string());
|
|
|
|
let job_id = orchestrator
|
|
.submit_job(
|
|
"DQN".to_string(),
|
|
config.clone(),
|
|
format!("Partial batch job #{}", i),
|
|
tags,
|
|
)
|
|
.await
|
|
.expect("Failed to submit job");
|
|
|
|
job_ids.push(job_id);
|
|
}
|
|
|
|
println!("✅ Submitted batch of 3 jobs");
|
|
|
|
// Simulate partial completion: stop first 2 jobs
|
|
for (i, job_id) in job_ids.iter().take(2).enumerate() {
|
|
let stopped = orchestrator
|
|
.stop_job(*job_id, format!("Completed job #{}", i))
|
|
.await
|
|
.expect("Failed to stop job");
|
|
|
|
assert!(stopped);
|
|
}
|
|
|
|
println!("✅ Stopped 2/3 jobs (partial completion)");
|
|
|
|
// Verify batch state
|
|
let all_jobs = orchestrator
|
|
.list_jobs(None, None, None, None)
|
|
.await
|
|
.expect("Failed to list jobs");
|
|
|
|
let batch_jobs: Vec<_> = all_jobs
|
|
.iter()
|
|
.filter(|job| {
|
|
job.tags
|
|
.get("batch")
|
|
.map(|v| v == "partial_test")
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(batch_jobs.len(), 3);
|
|
|
|
let stopped_count = batch_jobs
|
|
.iter()
|
|
.filter(|job| job.status == JobStatus::Stopped)
|
|
.count();
|
|
|
|
let pending_count = batch_jobs
|
|
.iter()
|
|
.filter(|job| job.status == JobStatus::Pending)
|
|
.count();
|
|
|
|
assert_eq!(stopped_count, 2);
|
|
assert_eq!(pending_count, 1);
|
|
|
|
println!("✅ Partial completion validated:");
|
|
println!(" - Stopped: {}", stopped_count);
|
|
println!(" - Pending: {}", pending_count);
|
|
|
|
// Verify partial progress
|
|
let progress_percentage = (stopped_count as f32 / batch_jobs.len() as f32) * 100.0;
|
|
println!("✅ Batch progress: {:.1}%", progress_percentage);
|
|
assert!((progress_percentage - 66.67).abs() < 0.1);
|
|
}
|