Removed 3 duplicate ModelType enums (model_loader, hyperopt campaign, job_spawner). Canonical definition in ml/src/lib.rs with 15 variants. model_loader and job_spawner now re-export from ml. Added as_str(), s3_prefix(), Display, to_db_string(), and weight() to canonical enum. Replaced conflicting ToString impl with Display. Fixed variant name mismatches (Dqn->DQN, Mamba2->MAMBA, Liquid->LNN, TlobTransformer->TLOB). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
426 lines
13 KiB
Rust
426 lines
13 KiB
Rust
//! Job Spawner Tests
|
||
//!
|
||
//! Comprehensive test suite for the JobSpawner module following TDD principles.
|
||
//! Tests cover batch creation, child job spawning, ordering, rollback, and edge cases.
|
||
|
||
use ml_training_service::job_spawner::{Asset, ChildJob, JobSpawner, ModelType};
|
||
use sqlx::PgPool;
|
||
use std::path::PathBuf;
|
||
use uuid::Uuid;
|
||
|
||
/// Helper function to run migrations on test database
|
||
async fn run_migrations(pool: &PgPool) {
|
||
sqlx::migrate!("../../migrations")
|
||
.run(pool)
|
||
.await
|
||
.expect("Failed to run migrations");
|
||
}
|
||
|
||
/// Helper function to create test assets
|
||
fn create_test_assets(count: usize) -> Vec<Asset> {
|
||
(0..count)
|
||
.map(|i| Asset {
|
||
symbol: format!("TEST{}", i),
|
||
data_file: PathBuf::from(format!("/test/data/TEST{}.parquet", i)),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Helper function to create test models in correct training order
|
||
fn create_test_models() -> Vec<ModelType> {
|
||
vec![
|
||
ModelType::DQN,
|
||
ModelType::PPO,
|
||
ModelType::MAMBA,
|
||
ModelType::TFT,
|
||
]
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_single_asset_4_models(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(1);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets.clone(), models.clone())
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Verify batch was created
|
||
assert!(batch.batch_id != Uuid::nil());
|
||
assert_eq!(batch.status, "Pending");
|
||
assert_eq!(batch.assets.len(), 1);
|
||
assert_eq!(batch.models.len(), 4);
|
||
|
||
// Query child jobs
|
||
let child_jobs: Vec<ChildJob> = sqlx::query_as(
|
||
"SELECT id, batch_id, model_type, status, created_at, config_json FROM child_jobs WHERE batch_id = $1 ORDER BY created_at",
|
||
)
|
||
.bind(batch.batch_id)
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("Failed to fetch child jobs");
|
||
|
||
// Should create 4 child jobs (1 asset × 4 models)
|
||
assert_eq!(child_jobs.len(), 4);
|
||
|
||
// Verify all jobs are Pending
|
||
for job in &child_jobs {
|
||
assert_eq!(job.status, "Pending");
|
||
assert_eq!(job.batch_id, batch.batch_id);
|
||
}
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_2_assets_4_models(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(2);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Query child jobs
|
||
let child_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1")
|
||
.bind(batch.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count jobs");
|
||
|
||
// Should create 8 child jobs (2 assets × 4 models)
|
||
assert_eq!(child_count, 8);
|
||
|
||
// Verify batch metadata
|
||
let batch_record: (i32,) = sqlx::query_as("SELECT total_jobs FROM batch_jobs WHERE id = $1")
|
||
.bind(batch.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to fetch batch");
|
||
|
||
assert_eq!(batch_record.0, 8);
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_sequential_order(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(1);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Fetch jobs in creation order
|
||
let jobs: Vec<(String,)> = sqlx::query_as(
|
||
"SELECT model_type FROM child_jobs WHERE batch_id = $1 ORDER BY created_at",
|
||
)
|
||
.bind(batch.batch_id)
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("Failed to fetch jobs");
|
||
|
||
// Verify correct sequential order: DQN → PPO → MAMBA-2 → TFT
|
||
assert_eq!(jobs.len(), 4);
|
||
assert_eq!(jobs[0].0, "DQN");
|
||
assert_eq!(jobs[1].0, "PPO");
|
||
assert_eq!(jobs[2].0, "MAMBA-2");
|
||
assert_eq!(jobs[3].0, "TFT");
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_creates_parent(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(1);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets.clone(), models.clone())
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Verify batch exists in database
|
||
let batch_record: (Uuid, String, i32) =
|
||
sqlx::query_as("SELECT id, status, total_jobs FROM batch_jobs WHERE id = $1")
|
||
.bind(batch.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to fetch batch");
|
||
|
||
assert_eq!(batch_record.0, batch.batch_id);
|
||
assert_eq!(batch_record.1, "Pending");
|
||
assert_eq!(batch_record.2, 4); // 1 asset × 4 models
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_all_pending_status(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(2);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Count pending jobs
|
||
let pending_count: i64 = sqlx::query_scalar(
|
||
"SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1 AND status = 'Pending'",
|
||
)
|
||
.bind(batch.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count pending jobs");
|
||
|
||
// All 8 jobs should be Pending
|
||
assert_eq!(pending_count, 8);
|
||
|
||
// Verify batch status
|
||
let batch_status: (String, i32, i32) = sqlx::query_as(
|
||
"SELECT status, pending_jobs, total_jobs FROM batch_jobs WHERE id = $1",
|
||
)
|
||
.bind(batch.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to fetch batch status");
|
||
|
||
assert_eq!(batch_status.0, "Pending");
|
||
assert_eq!(batch_status.1, 8); // pending_jobs
|
||
assert_eq!(batch_status.2, 8); // total_jobs
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_unique_job_ids(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(3);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Fetch all job IDs
|
||
let job_ids: Vec<(Uuid,)> =
|
||
sqlx::query_as("SELECT id FROM child_jobs WHERE batch_id = $1")
|
||
.bind(batch.batch_id)
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("Failed to fetch job IDs");
|
||
|
||
// Should have 12 jobs (3 assets × 4 models)
|
||
assert_eq!(job_ids.len(), 12);
|
||
|
||
// Verify all IDs are unique
|
||
let unique_ids: std::collections::HashSet<_> = job_ids.iter().map(|r| r.0).collect();
|
||
assert_eq!(unique_ids.len(), 12);
|
||
|
||
// Verify batch ID is also unique and not nil
|
||
assert!(batch.batch_id != Uuid::nil());
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_get_next_pending_job_returns_oldest(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
|
||
// Create first batch
|
||
let assets1 = vec![Asset {
|
||
symbol: "ES.FUT".to_string(),
|
||
data_file: PathBuf::from("/test/ES.parquet"),
|
||
}];
|
||
let models1 = vec![ModelType::DQN];
|
||
|
||
let batch1 = spawner
|
||
.spawn_batch(assets1, models1)
|
||
.await
|
||
.expect("Failed to spawn first batch");
|
||
|
||
// Wait a moment to ensure different timestamps
|
||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||
|
||
// Create second batch
|
||
let assets2 = vec![Asset {
|
||
symbol: "NQ.FUT".to_string(),
|
||
data_file: PathBuf::from("/test/NQ.parquet"),
|
||
}];
|
||
let models2 = vec![ModelType::PPO];
|
||
|
||
let _batch2 = spawner
|
||
.spawn_batch(assets2, models2)
|
||
.await
|
||
.expect("Failed to spawn second batch");
|
||
|
||
// Get next pending job
|
||
let next_job = spawner
|
||
.get_next_pending_job()
|
||
.await
|
||
.expect("Failed to get next job")
|
||
.expect("No pending job found");
|
||
|
||
// Should return job from first batch (FIFO order)
|
||
assert_eq!(next_job.batch_id, batch1.batch_id);
|
||
assert_eq!(next_job.model_type, "DQN");
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_get_next_pending_job_empty_queue(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
|
||
// No jobs in queue
|
||
let next_job = spawner
|
||
.get_next_pending_job()
|
||
.await
|
||
.expect("Failed to query jobs");
|
||
|
||
assert!(next_job.is_none());
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_rollback_on_error(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
|
||
// Try to create a batch with invalid model type (this should fail)
|
||
// We'll simulate this by creating a batch and then trying to insert
|
||
// a child job with a NULL model_type which violates NOT NULL constraint
|
||
|
||
// First, verify tables are empty
|
||
let initial_batch_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM batch_jobs")
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count batches");
|
||
let initial_job_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs")
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count jobs");
|
||
|
||
// Create a batch with empty assets (should fail validation)
|
||
let empty_assets: Vec<Asset> = vec![];
|
||
let models = create_test_models();
|
||
|
||
let result = spawner.spawn_batch(empty_assets, models).await;
|
||
|
||
// Should return an error
|
||
assert!(result.is_err());
|
||
|
||
// Verify no records were created (rollback successful)
|
||
let final_batch_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM batch_jobs")
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count batches");
|
||
let final_job_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs")
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count jobs");
|
||
|
||
assert_eq!(final_batch_count, initial_batch_count);
|
||
assert_eq!(final_job_count, initial_job_count);
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_batch_with_config_metadata(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(1);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Verify config_json is populated in child jobs
|
||
let configs: Vec<(serde_json::Value,)> =
|
||
sqlx::query_as("SELECT config_json FROM child_jobs WHERE batch_id = $1")
|
||
.bind(batch.batch_id)
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("Failed to fetch configs");
|
||
|
||
// All jobs should have valid JSON config
|
||
for (config,) in configs {
|
||
assert!(config.is_object());
|
||
}
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_spawn_multiple_batches_independently(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
|
||
// Create batch 1
|
||
let assets1 = create_test_assets(1);
|
||
let models1 = vec![ModelType::DQN, ModelType::PPO];
|
||
let batch1 = spawner
|
||
.spawn_batch(assets1, models1)
|
||
.await
|
||
.expect("Failed to spawn batch 1");
|
||
|
||
// Create batch 2
|
||
let assets2 = create_test_assets(2);
|
||
let models2 = vec![ModelType::MAMBA];
|
||
let batch2 = spawner
|
||
.spawn_batch(assets2, models2)
|
||
.await
|
||
.expect("Failed to spawn batch 2");
|
||
|
||
// Verify batches are independent
|
||
assert_ne!(batch1.batch_id, batch2.batch_id);
|
||
|
||
// Count jobs for each batch
|
||
let batch1_jobs: i64 =
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1")
|
||
.bind(batch1.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count batch1 jobs");
|
||
|
||
let batch2_jobs: i64 =
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1")
|
||
.bind(batch2.batch_id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("Failed to count batch2 jobs");
|
||
|
||
assert_eq!(batch1_jobs, 2); // 1 asset × 2 models
|
||
assert_eq!(batch2_jobs, 2); // 2 assets × 1 model
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_get_batch_status(pool: PgPool) {
|
||
run_migrations(&pool).await;
|
||
let spawner = JobSpawner::new(pool.clone());
|
||
let assets = create_test_assets(1);
|
||
let models = create_test_models();
|
||
|
||
let batch = spawner
|
||
.spawn_batch(assets, models)
|
||
.await
|
||
.expect("Failed to spawn batch");
|
||
|
||
// Get batch status
|
||
let status = spawner
|
||
.get_batch_status(batch.batch_id)
|
||
.await
|
||
.expect("Failed to get batch status")
|
||
.expect("Batch not found");
|
||
|
||
assert_eq!(status.batch_id, batch.batch_id);
|
||
assert_eq!(status.status, "Pending");
|
||
assert_eq!(status.total_jobs, 4);
|
||
assert_eq!(status.pending_jobs, 4);
|
||
assert_eq!(status.completed_jobs, 0);
|
||
assert_eq!(status.failed_jobs, 0);
|
||
}
|