Files
foxhunt/services/ml_training_service/src/job_spawner.rs
jgrusewski 8324b98f72 feat(serving): wire report_job_completion to PromotionManager
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>
2026-02-27 22:56:39 +01:00

582 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}