//! ML Training Service Model Lifecycle Integration Tests //! //! Comprehensive tests covering: //! - Training job submission and validation //! - Job lifecycle management (start, stop, pause) //! - Model configuration validation //! - Hyperparameter validation //! - Training status updates //! - Resource management //! - Concurrent training jobs //! - Error handling use anyhow::Result; use config::{DatabaseConfig, MLConfig}; use ml_training_service::database::DatabaseManager; use ml_training_service::orchestrator::TrainingOrchestrator; use ml_training_service::service::{ proto::{ ml_training_service_server::MlTrainingService, DataSource, DqnParams, GetTrainingJobDetailsRequest, Hyperparameters, ListAvailableModelsRequest, ListTrainingJobsRequest, MambaParams, StartTrainingRequest, StopTrainingRequest, TlobParams, TrainingStatus, }, MLTrainingServiceImpl, }; use ml_training_service::storage::{ModelStorageManager, StorageConfig}; use std::collections::HashMap; use std::sync::Arc; use tonic::Request; /// Setup test ML training service async fn setup_ml_training_service() -> Result { let config = MLConfig::default(); // Create test database config with proper field names let db_config = DatabaseConfig { url: "postgres://test:test@localhost/test_ml_training".to_string(), max_connections: 5, min_connections: 1, connect_timeout: std::time::Duration::from_secs(30), query_timeout: std::time::Duration::from_secs(30), enable_query_logging: false, application_name: Some("ml_training_test".to_string()), pool: config::PoolConfig::default(), transaction: config::TransactionConfig::default(), }; // Create database manager let db_manager = Arc::new(DatabaseManager::new(&db_config).await?); // Create test storage config with proper field names let storage_config = StorageConfig { storage_type: "local".to_string(), local_base_path: Some(std::path::PathBuf::from("/tmp/ml_training_test_models")), enable_compression: false, }; // Create storage manager let storage_manager = Arc::new(ModelStorageManager::new(storage_config).await?); // Create orchestrator with test dependencies let orchestrator = Arc::new(TrainingOrchestrator::new(config.clone(), db_manager, storage_manager).await?); let tuning_script = std::env::var("TUNER_SCRIPT_PATH") .unwrap_or_else(|_| "services/ml_training_service/hyperparameter_tuner.py".to_string()); let working_dir = std::env::var("TUNING_WORKING_DIR").unwrap_or_else(|_| ".".to_string()); let tuning_manager = Arc::new(ml_training_service::tuning_manager::TuningManager::new( tuning_script, working_dir, )); let promotion_manager = Arc::new(ml_training_service::promotion_manager::PromotionManager::new()); Ok(MLTrainingServiceImpl::new( orchestrator, tuning_manager, config, promotion_manager, None, // no K8s dispatcher in tests None, // no job spawner in tests )) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_tlob_transformer() -> Result<()> { println!("\n=== Test: Start TLOB Transformer Training ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/orderbook_data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: Some(Hyperparameters { model_params: Some( ml_training_service::service::proto::hyperparameters::ModelParams::TlobParams( TlobParams { epochs: 100, learning_rate: 0.001, batch_size: 64, sequence_length: 50, hidden_dim: 128, num_heads: 8, num_layers: 4, dropout_rate: 0.1, use_positional_encoding: true, }, ), ), }), use_gpu: true, description: "test_tlob_training_001".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let response = service.start_training(request).await?; let job = response.into_inner(); println!("✓ TLOB training job started: {}", job.job_id); assert!(!job.job_id.is_empty()); assert_eq!(job.status, TrainingStatus::Pending as i32); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_mamba2() -> Result<()> { println!("\n=== Test: Start MAMBA-2 Training ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "mamba2".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/timeseries_data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: Some(Hyperparameters { model_params: Some( ml_training_service::service::proto::hyperparameters::ModelParams::MambaParams( MambaParams { epochs: 150, learning_rate: 0.0001, batch_size: 32, state_dim: 256, hidden_dim: 512, num_layers: 6, dt_min: 0.001, dt_max: 0.1, use_cuda_kernels: true, }, ), ), }), use_gpu: true, description: "test_mamba2_training_001".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let response = service.start_training(request).await?; let job = response.into_inner(); println!("✓ MAMBA-2 training job started: {}", job.job_id); assert!(!job.job_id.is_empty()); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_dqn() -> Result<()> { println!("\n=== Test: Start DQN Training ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "dqn".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/rl_environment_data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: Some(Hyperparameters { model_params: Some( ml_training_service::service::proto::hyperparameters::ModelParams::DqnParams( DqnParams { epochs: 200, learning_rate: 0.0005, batch_size: 128, replay_buffer_size: 100000, epsilon_start: 1.0, epsilon_end: 0.01, epsilon_decay_steps: 10000, gamma: 0.99, target_update_frequency: 100, use_double_dqn: true, use_dueling: false, use_prioritized_replay: false, }, ), ), }), use_gpu: true, description: "test_dqn_training_001".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let response = service.start_training(request).await?; let job = response.into_inner(); println!("✓ DQN training job started: {}", job.job_id); assert!(!job.job_id.is_empty()); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_invalid_model_type() -> Result<()> { println!("\n=== Test: Reject Invalid Model Type ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "invalid_model_type_xyz".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_invalid_model".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let result = service.start_training(request).await; assert!(result.is_err(), "Invalid model type should be rejected"); if let Err(status) = result { println!("✓ Rejected with: {}", status.message()); assert_eq!(status.code(), tonic::Code::InvalidArgument); assert!(status.message().contains("model type")); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_empty_dataset_path() -> Result<()> { println!("\n=== Test: Reject Empty Dataset Path ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath("".to_string()), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_empty_dataset".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let result = service.start_training(request).await; assert!(result.is_err(), "Empty dataset path should be rejected"); if let Err(status) = result { println!("✓ Rejected with: {}", status.message()); assert!(status.message().contains("dataset") || status.message().contains("data_source")); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_start_training_invalid_hyperparameters() -> Result<()> { println!("\n=== Test: Reject Invalid Hyperparameters ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: Some(Hyperparameters { model_params: Some( ml_training_service::service::proto::hyperparameters::ModelParams::TlobParams( TlobParams { epochs: 0, // Invalid: zero epochs learning_rate: -0.001, // Invalid: negative learning rate batch_size: 0, // Invalid: zero batch size sequence_length: 50, hidden_dim: 128, num_heads: 8, num_layers: 4, dropout_rate: 0.1, use_positional_encoding: true, }, ), ), }), use_gpu: false, description: "test_invalid_hyperparams".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let result = service.start_training(request).await; assert!( result.is_err(), "Invalid hyperparameters should be rejected" ); if let Err(status) = result { println!("✓ Rejected with: {}", status.message()); assert_eq!(status.code(), tonic::Code::InvalidArgument); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_stop_training_job() -> Result<()> { println!("\n=== Test: Stop Training Job ==="); let service = setup_ml_training_service().await?; // Start a training job first let start_request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_stop_job".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let start_response = service.start_training(start_request).await?; let job_id = start_response.into_inner().job_id; println!(" Training job started: {}", job_id); // Stop the job let stop_request = Request::new(StopTrainingRequest { job_id: job_id.clone(), reason: "test_stop".to_string(), }); let stop_response = service.stop_training(stop_request).await?; let stop_result = stop_response.into_inner(); println!("✓ Training job stopped: {}", job_id); assert!(stop_result.success); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_stop_nonexistent_job() -> Result<()> { println!("\n=== Test: Stop Nonexistent Training Job ==="); let service = setup_ml_training_service().await?; let request = Request::new(StopTrainingRequest { job_id: "nonexistent_job_12345".to_string(), reason: "test".to_string(), }); let result = service.stop_training(request).await; match result { Ok(response) => { let stop_result = response.into_inner(); assert!(!stop_result.success, "Stopping nonexistent job should fail"); println!("✓ Stop failed as expected: {}", stop_result.message); }, Err(status) => { println!("✓ Rejected with: {}", status.message()); assert_eq!(status.code(), tonic::Code::NotFound); }, } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_get_training_job_details() -> Result<()> { println!("\n=== Test: Get Training Job Details ==="); let service = setup_ml_training_service().await?; // Start a training job let start_request = Request::new(StartTrainingRequest { model_type: "mamba2".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_job_details".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let start_response = service.start_training(start_request).await?; let job_id = start_response.into_inner().job_id; // Get job details let details_request = Request::new(GetTrainingJobDetailsRequest { job_id: job_id.clone(), }); let details_response = service.get_training_job_details(details_request).await?; let details = details_response.into_inner(); println!("✓ Job details retrieved for: {}", job_id); if let Some(job_details) = details.job_details { assert_eq!(job_details.job_id, job_id); assert_eq!(job_details.description, "test_job_details"); assert_eq!(job_details.model_type, "mamba2"); println!( " Status: {:?}", TrainingStatus::try_from(job_details.status).unwrap_or(TrainingStatus::Unknown) ); } else { panic!("Expected job_details to be present"); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_list_training_jobs() -> Result<()> { println!("\n=== Test: List Training Jobs ==="); let service = setup_ml_training_service().await?; // Start a few training jobs for i in 1..=3 { let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: format!("test_list_job_{}", i), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let _ = service.start_training(request).await?; } // List all jobs let list_request = Request::new(ListTrainingJobsRequest { page: 1, page_size: 10, status_filter: 0, // UNKNOWN = 0, means no filter model_type_filter: "".to_string(), start_time: 0, end_time: 0, }); let list_response = service.list_training_jobs(list_request).await?; let jobs = list_response.into_inner(); println!("✓ Listed {} training jobs", jobs.jobs.len()); assert!(jobs.jobs.len() >= 3); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_list_available_models() -> Result<()> { println!("\n=== Test: List Available Models ==="); let service = setup_ml_training_service().await?; let request = Request::new(ListAvailableModelsRequest {}); let response = service.list_available_models(request).await?; let models = response.into_inner(); println!("✓ Available models:"); for model in &models.models { println!(" - {}: {}", model.model_type, model.description); } assert!(!models.models.is_empty(), "Should have available models"); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_concurrent_training_jobs() -> Result<()> { println!("\n=== Test: Concurrent Training Jobs ==="); let service = Arc::new(setup_ml_training_service().await?); let mut handles = vec![]; // Start 3 training jobs concurrently for i in 1..=3 { let svc = service.clone(); let handle = tokio::spawn(async move { let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: format!("concurrent_job_{}", i), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); svc.start_training(request).await }); handles.push(handle); } // Wait for all to complete let mut success_count = 0; for handle in handles { if let Ok(Ok(_)) = handle.await { success_count += 1; } } println!("✓ {}/3 concurrent training jobs started", success_count); assert_eq!(success_count, 3, "All concurrent jobs should start"); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_training_job_with_gpu() -> Result<()> { println!("\n=== Test: Training Job with GPU ==="); let service = setup_ml_training_service().await?; let request = Request::new(StartTrainingRequest { model_type: "mamba2".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: true, description: "test_gpu_training".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let response = service.start_training(request).await?; let job = response.into_inner(); println!("✓ Training job with GPU started: {}", job.job_id); assert!(!job.job_id.is_empty()); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_training_job_with_tags() -> Result<()> { println!("\n=== Test: Training Job with Tags ==="); let service = setup_ml_training_service().await?; let mut tags = HashMap::new(); tags.insert("experiment".to_string(), "baseline".to_string()); tags.insert("version".to_string(), "v1.0".to_string()); let request = Request::new(StartTrainingRequest { model_type: "tlob_transformer".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_tagged_job".to_string(), tags, mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let response = service.start_training(request).await?; let job = response.into_inner(); println!("✓ Training job with tags started: {}", job.job_id); assert!(!job.job_id.is_empty()); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL database and test infrastructure"] async fn test_training_job_lifecycle() -> Result<()> { println!("\n=== Test: Complete Training Job Lifecycle ==="); let service = setup_ml_training_service().await?; // 1. Start training let start_request = Request::new(StartTrainingRequest { model_type: "dqn".to_string(), data_source: Some(DataSource { source: Some( ml_training_service::service::proto::data_source::Source::FilePath( "/data/training/data.parquet".to_string(), ), ), start_time: 0, end_time: 0, }), hyperparameters: None, use_gpu: false, description: "test_lifecycle".to_string(), tags: HashMap::new(), mode: 0, resume_checkpoint_path: String::new(), max_epochs: 0, }); let start_response = service.start_training(start_request).await?; let job_id = start_response.into_inner().job_id; println!(" 1. Training started: {}", job_id); // 2. Check status let status_request = Request::new(GetTrainingJobDetailsRequest { job_id: job_id.clone(), }); let status_response = service.get_training_job_details(status_request).await?; let status_details = status_response.into_inner(); if let Some(job_details) = status_details.job_details { println!( " 2. Status checked: {:?}", TrainingStatus::try_from(job_details.status).unwrap_or(TrainingStatus::Unknown) ); } // 3. Stop training let stop_request = Request::new(StopTrainingRequest { job_id: job_id.clone(), reason: "test_lifecycle_complete".to_string(), }); let stop_response = service.stop_training(stop_request).await?; println!( " 3. Training stopped: {}", stop_response.into_inner().success ); // 4. Verify stopped status let final_status_request = Request::new(GetTrainingJobDetailsRequest { job_id: job_id.clone(), }); let final_status = service .get_training_job_details(final_status_request) .await?; if let Some(job_details) = final_status.into_inner().job_details { println!( " 4. Final status: {:?}", TrainingStatus::try_from(job_details.status).unwrap_or(TrainingStatus::Unknown) ); } println!("✓ Complete lifecycle test passed"); Ok(()) }