Replace the stub report_job_completion gRPC handler with a real implementation that: - Validates job_id as UUID upfront - On failure: updates DB status to Failed (best-effort), returns "failed" - On success: updates DB status to Completed, looks up model_type and symbol from child_jobs table, registers with PromotionManager, and returns the actual promotion status string Also adds JobSpawner::get_job_by_id() to fetch individual child jobs from PostgreSQL, and two new tests covering promotion status mapping and the PromotionManager integration path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
582 lines
17 KiB
Rust
582 lines
17 KiB
Rust
//! Job Spawner - Multi-Model Training Orchestration
|
||
//!
|
||
//! This module provides production-ready job spawning and orchestration for ML training.
|
||
//! It manages batch jobs that spawn multiple child training jobs across different models
|
||
//! and assets, with support for sequential execution, progress tracking, and rollback.
|
||
//!
|
||
//! ## Architecture
|
||
//!
|
||
//! - **BatchJob**: Parent job that orchestrates multiple child jobs
|
||
//! - **ChildJob**: Individual training job for a specific model and asset
|
||
//! - **Sequential Execution**: Jobs are executed DQN → PPO → MAMBA-2 → TFT to avoid GPU OOM
|
||
//! - **Database Persistence**: All jobs stored in PostgreSQL with transaction support
|
||
//! - **Rollback Safety**: Failed batch creation rolls back all changes
|
||
//!
|
||
//! ## Usage
|
||
//!
|
||
//! ```rust,no_run
|
||
//! use ml_training_service::job_spawner::{JobSpawner, Asset, ModelType};
|
||
//! use sqlx::PgPool;
|
||
//! use std::path::PathBuf;
|
||
//!
|
||
//! async fn example(pool: PgPool) -> anyhow::Result<()> {
|
||
//! let spawner = JobSpawner::new(pool);
|
||
//!
|
||
//! let assets = vec![
|
||
//! Asset {
|
||
//! symbol: "ES.FUT".to_string(),
|
||
//! data_file: PathBuf::from("/data/ES_FUT_180d.parquet"),
|
||
//! },
|
||
//! ];
|
||
//!
|
||
//! let models = vec![
|
||
//! ModelType::DQN,
|
||
//! ModelType::PPO,
|
||
//! ModelType::MAMBA,
|
||
//! ModelType::TFT,
|
||
//! ];
|
||
//!
|
||
//! let batch = spawner.spawn_batch(assets, models).await?;
|
||
//! println!("Created batch: {}", batch.batch_id);
|
||
//!
|
||
//! Ok(())
|
||
//! }
|
||
//! ```
|
||
|
||
use anyhow::{anyhow, Context, Result};
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
use sqlx::{PgPool, Postgres, Transaction};
|
||
use std::path::PathBuf;
|
||
use tracing::{debug, info, warn};
|
||
use uuid::Uuid;
|
||
|
||
// Use canonical ModelType from common crate
|
||
pub use common::model_types::ModelType;
|
||
|
||
/// Trading asset with data file
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Asset {
|
||
/// Asset symbol (e.g., "ES.FUT", "NQ.FUT")
|
||
pub symbol: String,
|
||
/// Path to training data file
|
||
pub data_file: PathBuf,
|
||
}
|
||
|
||
/// Parent batch job that orchestrates multiple child jobs
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct BatchJob {
|
||
/// Unique batch job identifier
|
||
pub batch_id: Uuid,
|
||
/// Assets to train on
|
||
pub assets: Vec<Asset>,
|
||
/// Models to train
|
||
pub models: Vec<ModelType>,
|
||
/// Batch status
|
||
pub status: String,
|
||
/// Creation timestamp
|
||
pub created_at: DateTime<Utc>,
|
||
}
|
||
|
||
/// Individual child training job
|
||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||
pub struct ChildJob {
|
||
/// Unique job identifier
|
||
pub id: Uuid,
|
||
/// Parent batch identifier
|
||
pub batch_id: Uuid,
|
||
/// Model type
|
||
pub model_type: String,
|
||
/// Job status
|
||
pub status: String,
|
||
/// Creation timestamp
|
||
pub created_at: DateTime<Utc>,
|
||
/// Job configuration JSON
|
||
pub config_json: serde_json::Value,
|
||
}
|
||
|
||
/// Batch status summary
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct BatchStatus {
|
||
/// Batch identifier
|
||
pub batch_id: Uuid,
|
||
/// Overall status
|
||
pub status: String,
|
||
/// Total number of jobs
|
||
pub total_jobs: i32,
|
||
/// Pending jobs count
|
||
pub pending_jobs: i32,
|
||
/// Running jobs count
|
||
pub running_jobs: i32,
|
||
/// Completed jobs count
|
||
pub completed_jobs: i32,
|
||
/// Failed jobs count
|
||
pub failed_jobs: i32,
|
||
/// Overall progress percentage
|
||
pub overall_progress: f64,
|
||
}
|
||
|
||
/// Job spawner for batch training orchestration
|
||
pub struct JobSpawner {
|
||
db_pool: PgPool,
|
||
}
|
||
|
||
impl JobSpawner {
|
||
/// Create a new job spawner
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `db_pool` - PostgreSQL connection pool
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,no_run
|
||
/// use ml_training_service::job_spawner::JobSpawner;
|
||
/// use sqlx::PgPool;
|
||
///
|
||
/// async fn example(pool: PgPool) {
|
||
/// let spawner = JobSpawner::new(pool);
|
||
/// }
|
||
/// ```
|
||
pub fn new(db_pool: PgPool) -> Self {
|
||
Self { db_pool }
|
||
}
|
||
|
||
/// Spawn a batch job with multiple child training jobs
|
||
///
|
||
/// Creates a parent batch job and spawns child jobs for each asset × model combination.
|
||
/// Jobs are ordered sequentially (DQN → PPO → MAMBA-2 → TFT) to avoid GPU OOM.
|
||
/// All operations are atomic - if any step fails, the entire batch is rolled back.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `assets` - List of trading assets to train on
|
||
/// * `models` - List of models to train (in execution order)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(BatchJob)` - Created batch job with all metadata
|
||
/// * `Err` - Database error or validation failure
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if:
|
||
/// - Assets list is empty
|
||
/// - Models list is empty
|
||
/// - Database transaction fails
|
||
/// - Child job creation fails
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,no_run
|
||
/// use ml_training_service::job_spawner::{JobSpawner, Asset, ModelType};
|
||
/// use sqlx::PgPool;
|
||
/// use std::path::PathBuf;
|
||
///
|
||
/// async fn example(pool: PgPool) -> anyhow::Result<()> {
|
||
/// let spawner = JobSpawner::new(pool);
|
||
///
|
||
/// let assets = vec![
|
||
/// Asset {
|
||
/// symbol: "ES.FUT".to_string(),
|
||
/// data_file: PathBuf::from("/data/ES.parquet"),
|
||
/// },
|
||
/// ];
|
||
///
|
||
/// let models = vec![ModelType::DQN, ModelType::PPO];
|
||
///
|
||
/// let batch = spawner.spawn_batch(assets, models).await?;
|
||
/// println!("Batch ID: {}", batch.batch_id);
|
||
///
|
||
/// Ok(())
|
||
/// }
|
||
/// ```
|
||
pub async fn spawn_batch(
|
||
&self,
|
||
assets: Vec<Asset>,
|
||
models: Vec<ModelType>,
|
||
) -> Result<BatchJob> {
|
||
// Validation
|
||
if assets.is_empty() {
|
||
return Err(anyhow!("Assets list cannot be empty"));
|
||
}
|
||
if models.is_empty() {
|
||
return Err(anyhow!("Models list cannot be empty"));
|
||
}
|
||
|
||
info!(
|
||
"Spawning batch job: {} assets × {} models = {} jobs",
|
||
assets.len(),
|
||
models.len(),
|
||
assets.len() * models.len()
|
||
);
|
||
|
||
// Start database transaction for atomicity
|
||
let mut tx = self
|
||
.db_pool
|
||
.begin()
|
||
.await
|
||
.context("Failed to begin transaction")?;
|
||
|
||
// Create parent batch job
|
||
let batch_id = Uuid::new_v4();
|
||
let created_at = Utc::now();
|
||
let total_jobs = (assets.len() * models.len()) as i32;
|
||
|
||
let batch = self
|
||
.create_batch_job(&mut tx, batch_id, &assets, &models, total_jobs, created_at)
|
||
.await
|
||
.context("Failed to create batch job")?;
|
||
|
||
// Create child jobs for each asset × model combination
|
||
let mut created_count = 0;
|
||
for asset in &assets {
|
||
for model in &models {
|
||
self.create_child_job(&mut tx, batch_id, asset, model, created_at)
|
||
.await
|
||
.with_context(|| {
|
||
format!(
|
||
"Failed to create child job for {} / {:?}",
|
||
asset.symbol, model
|
||
)
|
||
})?;
|
||
created_count += 1;
|
||
}
|
||
}
|
||
|
||
// Commit transaction
|
||
tx.commit()
|
||
.await
|
||
.context("Failed to commit batch job transaction")?;
|
||
|
||
info!(
|
||
"Successfully created batch {} with {} child jobs",
|
||
batch_id, created_count
|
||
);
|
||
|
||
Ok(batch)
|
||
}
|
||
|
||
/// Create the parent batch job record
|
||
async fn create_batch_job(
|
||
&self,
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
batch_id: Uuid,
|
||
assets: &[Asset],
|
||
models: &[ModelType],
|
||
total_jobs: i32,
|
||
created_at: DateTime<Utc>,
|
||
) -> Result<BatchJob> {
|
||
// Serialize assets and models to JSON for storage
|
||
let assets_json = serde_json::to_value(assets)
|
||
.context("Failed to serialize assets")?;
|
||
let models_json = serde_json::to_value(models)
|
||
.context("Failed to serialize models")?;
|
||
|
||
// Build batch name
|
||
let asset_symbols: Vec<_> = assets.iter().map(|a| a.symbol.as_str()).collect();
|
||
let batch_name = format!(
|
||
"Batch: {} ({})",
|
||
asset_symbols.join(", "),
|
||
models.len()
|
||
);
|
||
|
||
// Build config JSON
|
||
let config = serde_json::json!({
|
||
"assets": assets_json,
|
||
"models": models_json,
|
||
});
|
||
|
||
// Serialize config to string for JSONB column
|
||
let config_str = serde_json::to_string(&config)
|
||
.context("Failed to serialize batch config")?;
|
||
|
||
// Insert batch job
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO batch_jobs (
|
||
id, name, description, status, total_jobs,
|
||
pending_jobs, config_json, created_at
|
||
)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
||
"#,
|
||
)
|
||
.bind(batch_id)
|
||
.bind(&batch_name)
|
||
.bind("Multi-model training batch")
|
||
.bind("Pending")
|
||
.bind(total_jobs)
|
||
.bind(total_jobs) // All jobs start as pending
|
||
.bind(&config_str)
|
||
.bind(created_at)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.context("Failed to insert batch job")?;
|
||
|
||
debug!("Created batch job: {}", batch_id);
|
||
|
||
Ok(BatchJob {
|
||
batch_id,
|
||
assets: assets.to_vec(),
|
||
models: models.to_vec(),
|
||
status: "Pending".to_string(),
|
||
created_at,
|
||
})
|
||
}
|
||
|
||
/// Create a child training job
|
||
async fn create_child_job(
|
||
&self,
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
batch_id: Uuid,
|
||
asset: &Asset,
|
||
model: &ModelType,
|
||
created_at: DateTime<Utc>,
|
||
) -> Result<()> {
|
||
let job_id = Uuid::new_v4();
|
||
let model_str = model.to_db_string();
|
||
let model_weight = model.weight();
|
||
|
||
// Build job configuration
|
||
let config = serde_json::json!({
|
||
"asset": asset.symbol,
|
||
"data_file": asset.data_file.to_string_lossy(),
|
||
"model_type": model_str,
|
||
});
|
||
|
||
let config_str = serde_json::to_string(&config)
|
||
.context("Failed to serialize job config")?;
|
||
|
||
// Insert child job
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO child_jobs (
|
||
id, batch_id, model_type, model_weight,
|
||
status, config_json, created_at
|
||
)
|
||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||
"#,
|
||
)
|
||
.bind(job_id)
|
||
.bind(batch_id)
|
||
.bind(model_str)
|
||
.bind(model_weight)
|
||
.bind("Pending")
|
||
.bind(config_str)
|
||
.bind(created_at)
|
||
.execute(&mut **tx)
|
||
.await
|
||
.context("Failed to insert child job")?;
|
||
|
||
debug!(
|
||
"Created child job {} for {} / {}",
|
||
job_id, asset.symbol, model_str
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Get the next pending job in FIFO order
|
||
///
|
||
/// Returns the oldest pending job across all batches.
|
||
/// This ensures jobs are processed in the order they were created.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(Some(ChildJob))` - Next pending job
|
||
/// * `Ok(None)` - No pending jobs in queue
|
||
/// * `Err` - Database error
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,no_run
|
||
/// use ml_training_service::job_spawner::JobSpawner;
|
||
/// use sqlx::PgPool;
|
||
///
|
||
/// async fn example(pool: PgPool) -> anyhow::Result<()> {
|
||
/// let spawner = JobSpawner::new(pool);
|
||
///
|
||
/// if let Some(job) = spawner.get_next_pending_job().await? {
|
||
/// println!("Processing job: {}", job.id);
|
||
/// } else {
|
||
/// println!("No pending jobs");
|
||
/// }
|
||
///
|
||
/// Ok(())
|
||
/// }
|
||
/// ```
|
||
pub async fn get_next_pending_job(&self) -> Result<Option<ChildJob>> {
|
||
let job = sqlx::query_as::<_, ChildJob>(
|
||
r#"
|
||
SELECT id, batch_id, model_type, status, created_at, config_json
|
||
FROM child_jobs
|
||
WHERE status = 'Pending'
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
"#,
|
||
)
|
||
.fetch_optional(&self.db_pool)
|
||
.await
|
||
.context("Failed to fetch next pending job")?;
|
||
|
||
if let Some(ref j) = job {
|
||
debug!("Next pending job: {} ({})", j.id, j.model_type);
|
||
} else {
|
||
debug!("No pending jobs in queue");
|
||
}
|
||
|
||
Ok(job)
|
||
}
|
||
|
||
/// Get batch status and progress
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `batch_id` - Batch job identifier
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(Some(BatchStatus))` - Batch status summary
|
||
/// * `Ok(None)` - Batch not found
|
||
/// * `Err` - Database error
|
||
pub async fn get_batch_status(&self, batch_id: Uuid) -> Result<Option<BatchStatus>> {
|
||
let status = sqlx::query_as::<_, (Uuid, String, i32, i32, i32, i32, i32, f64)>(
|
||
r#"
|
||
SELECT
|
||
id, status, total_jobs, pending_jobs, running_jobs,
|
||
completed_jobs, failed_jobs, overall_progress
|
||
FROM batch_jobs
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(batch_id)
|
||
.fetch_optional(&self.db_pool)
|
||
.await
|
||
.context("Failed to fetch batch status")?;
|
||
|
||
Ok(status.map(|(id, status, total, pending, running, completed, failed, progress)| {
|
||
BatchStatus {
|
||
batch_id: id,
|
||
status,
|
||
total_jobs: total,
|
||
pending_jobs: pending,
|
||
running_jobs: running,
|
||
completed_jobs: completed,
|
||
failed_jobs: failed,
|
||
overall_progress: progress,
|
||
}
|
||
}))
|
||
}
|
||
|
||
/// Update child job status
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `job_id` - Child job identifier
|
||
/// * `new_status` - New status ("Running", "Completed", "Failed", etc.)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(())` - Status updated successfully
|
||
/// * `Err` - Database error or job not found
|
||
pub async fn update_job_status(&self, job_id: Uuid, new_status: &str) -> Result<()> {
|
||
let rows_affected = sqlx::query(
|
||
r#"
|
||
UPDATE child_jobs
|
||
SET status = $1, updated_at = NOW()
|
||
WHERE id = $2
|
||
"#,
|
||
)
|
||
.bind(new_status)
|
||
.bind(job_id)
|
||
.execute(&self.db_pool)
|
||
.await
|
||
.context("Failed to update job status")?
|
||
.rows_affected();
|
||
|
||
if rows_affected == 0 {
|
||
warn!("Job not found: {}", job_id);
|
||
return Err(anyhow!("Job not found: {}", job_id));
|
||
}
|
||
|
||
info!("Updated job {} status to {}", job_id, new_status);
|
||
Ok(())
|
||
}
|
||
|
||
/// Fetch a single child job by its ID
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `job_id` - Child job identifier
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(Some(ChildJob))` - The child job
|
||
/// * `Ok(None)` - Job not found
|
||
/// * `Err` - Database error
|
||
pub async fn get_job_by_id(&self, job_id: Uuid) -> Result<Option<ChildJob>> {
|
||
let job = sqlx::query_as::<_, ChildJob>(
|
||
r#"
|
||
SELECT id, batch_id, model_type, status, created_at, config_json
|
||
FROM child_jobs
|
||
WHERE id = $1
|
||
"#,
|
||
)
|
||
.bind(job_id)
|
||
.fetch_optional(&self.db_pool)
|
||
.await
|
||
.context("Failed to fetch job by id")?;
|
||
|
||
Ok(job)
|
||
}
|
||
|
||
/// Get all child jobs for a batch
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `batch_id` - Batch job identifier
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// * `Ok(Vec<ChildJob>)` - List of child jobs
|
||
/// * `Err` - Database error
|
||
pub async fn get_batch_jobs(&self, batch_id: Uuid) -> Result<Vec<ChildJob>> {
|
||
let jobs = sqlx::query_as::<_, ChildJob>(
|
||
r#"
|
||
SELECT id, batch_id, model_type, status, created_at, config_json
|
||
FROM child_jobs
|
||
WHERE batch_id = $1
|
||
ORDER BY created_at
|
||
"#,
|
||
)
|
||
.bind(batch_id)
|
||
.fetch_all(&self.db_pool)
|
||
.await
|
||
.context("Failed to fetch batch jobs")?;
|
||
|
||
Ok(jobs)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_model_type_to_db_string() {
|
||
assert_eq!(ModelType::DQN.to_db_string(), "DQN");
|
||
assert_eq!(ModelType::PPO.to_db_string(), "PPO");
|
||
assert_eq!(ModelType::MAMBA.to_db_string(), "MAMBA-2");
|
||
assert_eq!(ModelType::TFT.to_db_string(), "TFT");
|
||
}
|
||
|
||
#[test]
|
||
fn test_model_type_weight() {
|
||
assert_eq!(ModelType::DQN.weight(), 0.25);
|
||
assert_eq!(ModelType::PPO.weight(), 0.25);
|
||
assert_eq!(ModelType::MAMBA.weight(), 0.25);
|
||
assert_eq!(ModelType::TFT.weight(), 0.25);
|
||
}
|
||
}
|