//! Database Management for ML Training Service //! //! This module handles PostgreSQL database operations for storing training job metadata, //! configurations, and results. use std::collections::HashMap; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; use tracing::{debug, info}; use uuid::Uuid; use crate::orchestrator::{JobStatus, TrainingJob}; use common::database::DatabasePool; use config::database::DatabaseConfig; /// Database manager for training job persistence pub struct DatabaseManager { db_pool: DatabasePool, } /// Training job record for database storage #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrainingJobRecord { pub id: Uuid, pub model_type: String, pub status: String, pub config_json: String, pub created_at: DateTime, pub started_at: Option>, pub completed_at: Option>, pub description: String, pub tags_json: String, pub progress_percentage: f32, pub current_epoch: i32, pub total_epochs: i32, pub metrics_json: String, pub error_message: Option, pub model_artifact_path: Option, } impl TrainingJobRecord { /// Convert from TrainingJob to database record pub fn from_training_job(job: &TrainingJob) -> Self { Self { id: job.id, model_type: job.model_type.clone(), status: format!("{:?}", job.status), config_json: serde_json::to_string(&job.config).unwrap_or_default(), created_at: job.created_at, started_at: job.started_at, completed_at: job.completed_at, description: job.description.clone(), tags_json: serde_json::to_string(&job.tags).unwrap_or_default(), progress_percentage: job.progress_percentage, current_epoch: i32::try_from(job.current_epoch).unwrap_or(0), total_epochs: i32::try_from(job.total_epochs).unwrap_or(0), metrics_json: serde_json::to_string(&job.metrics).unwrap_or_default(), error_message: job.error_message.clone(), model_artifact_path: job.model_artifact_path.clone(), } } /// Convert from database record to TrainingJob pub fn to_training_job(&self) -> Result { let status = match self.status.as_str() { "Pending" => JobStatus::Pending, "Running" => JobStatus::Running, "Completed" => JobStatus::Completed, "Failed" => JobStatus::Failed, "Stopped" => JobStatus::Stopped, "Paused" => JobStatus::Paused, _ => JobStatus::Pending, }; let config = serde_json::from_str(&self.config_json).context("Failed to deserialize config")?; let tags: HashMap = serde_json::from_str(&self.tags_json).unwrap_or_default(); let metrics: HashMap = serde_json::from_str(&self.metrics_json).unwrap_or_default(); Ok(TrainingJob { id: self.id, model_type: self.model_type.clone(), status, config, created_at: self.created_at, started_at: self.started_at, completed_at: self.completed_at, description: self.description.clone(), tags, progress_percentage: self.progress_percentage, current_epoch: u32::try_from(self.current_epoch).unwrap_or(0), total_epochs: u32::try_from(self.total_epochs).unwrap_or(0), metrics, error_message: self.error_message.clone(), model_artifact_path: self.model_artifact_path.clone(), }) } } impl DatabaseManager { /// Create a new database manager pub async fn new(config: &DatabaseConfig) -> Result { Self::new_with_migrations(config, true).await } /// Create a new database manager with optional migrations pub async fn new_with_migrations( config: &DatabaseConfig, run_migrations: bool, ) -> Result { info!( "Connecting to database: {}", config.url.replace([':', '@'], "*") ); // Convert config::DatabaseConfig to common::database::LocalDatabaseConfig using From trait let common_config: common::database::LocalDatabaseConfig = config.clone().into(); let db_pool = DatabasePool::new(common_config) .await .map_err(|e| anyhow::anyhow!("Failed to create database pool: {}", e))?; info!("Database connection pool established with HFT optimizations"); let manager = Self { db_pool }; // Run migrations if requested if run_migrations { manager.run_migrations().await?; } Ok(manager) } /// Get a clone of the underlying `PgPool` for use by other components (e.g. `JobSpawner`). /// /// `PgPool` is an `Arc`-wrapped handle, so cloning is cheap. pub fn pg_pool(&self) -> sqlx::PgPool { self.db_pool.pool().clone() } /// Run database migrations pub async fn run_migrations(&self) -> Result<()> { info!("Running database migrations"); // Use advisory lock to prevent concurrent migrations sqlx::query("SELECT pg_advisory_lock(123456789)") .execute(self.db_pool.pool()) .await?; // Create training jobs table sqlx::query( r#" CREATE TABLE IF NOT EXISTS training_jobs ( id UUID PRIMARY KEY, model_type VARCHAR NOT NULL, status VARCHAR NOT NULL, config_json TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, description TEXT NOT NULL, tags_json TEXT NOT NULL DEFAULT '{}', progress_percentage REAL NOT NULL DEFAULT 0.0, current_epoch INTEGER NOT NULL DEFAULT 0, total_epochs INTEGER NOT NULL DEFAULT 0, metrics_json TEXT NOT NULL DEFAULT '{}', error_message TEXT, model_artifact_path TEXT, -- Indexes for common queries CONSTRAINT training_jobs_status_check CHECK ( status IN ('Pending', 'Running', 'Completed', 'Failed', 'Stopped', 'Paused') ) ) "#, ) .execute(self.db_pool.pool()) .await .context("Failed to create training_jobs table")?; // Create indexes sqlx::query("CREATE INDEX IF NOT EXISTS idx_training_jobs_status ON training_jobs(status)") .execute(self.db_pool.pool()) .await?; sqlx::query( "CREATE INDEX IF NOT EXISTS idx_training_jobs_model_type ON training_jobs(model_type)", ) .execute(self.db_pool.pool()) .await?; sqlx::query("CREATE INDEX IF NOT EXISTS idx_training_jobs_created_at ON training_jobs(created_at DESC)") .execute(self.db_pool.pool()) .await?; // Create training metrics table for detailed tracking sqlx::query( r#" CREATE TABLE IF NOT EXISTS training_metrics ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, job_id UUID NOT NULL REFERENCES training_jobs(id) ON DELETE CASCADE, epoch INTEGER NOT NULL, timestamp TIMESTAMPTZ NOT NULL, train_loss REAL, validation_loss REAL, metrics_json TEXT NOT NULL DEFAULT '{}', UNIQUE(job_id, epoch) ) "#, ) .execute(self.db_pool.pool()) .await .context("Failed to create training_metrics table")?; sqlx::query("CREATE INDEX IF NOT EXISTS idx_training_metrics_job_id ON training_metrics(job_id, epoch)") .execute(self.db_pool.pool()) .await?; // Release advisory lock sqlx::query("SELECT pg_advisory_unlock(123456789)") .execute(self.db_pool.pool()) .await?; info!("Database migrations completed successfully"); Ok(()) } /// Insert a new training job pub async fn insert_training_job(&self, job: &TrainingJobRecord) -> Result<()> { sqlx::query( r#" INSERT INTO training_jobs ( id, model_type, status, config_json, created_at, started_at, completed_at, description, tags_json, progress_percentage, current_epoch, total_epochs, metrics_json, error_message, model_artifact_path ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) "#, ) .bind(job.id) .bind(&job.model_type) .bind(&job.status) .bind(&job.config_json) .bind(job.created_at) .bind(job.started_at) .bind(job.completed_at) .bind(&job.description) .bind(&job.tags_json) .bind(job.progress_percentage) .bind(job.current_epoch) .bind(job.total_epochs) .bind(&job.metrics_json) .bind(&job.error_message) .bind(&job.model_artifact_path) .execute(self.db_pool.pool()) .await .context("Failed to insert training job")?; debug!("Inserted training job {}", job.id); Ok(()) } /// Update an existing training job pub async fn update_training_job(&self, job: &TrainingJobRecord) -> Result<()> { sqlx::query( r#" UPDATE training_jobs SET status = $2, started_at = $3, completed_at = $4, progress_percentage = $5, current_epoch = $6, total_epochs = $7, metrics_json = $8, error_message = $9, model_artifact_path = $10 WHERE id = $1 "#, ) .bind(job.id) .bind(&job.status) .bind(job.started_at) .bind(job.completed_at) .bind(job.progress_percentage) .bind(job.current_epoch) .bind(job.total_epochs) .bind(&job.metrics_json) .bind(&job.error_message) .bind(&job.model_artifact_path) .execute(self.db_pool.pool()) .await .context("Failed to update training job")?; debug!("Updated training job {}", job.id); Ok(()) } /// Get a training job by ID pub async fn get_training_job(&self, job_id: Uuid) -> Result> { let row = sqlx::query( r#" SELECT id, model_type, status, config_json, created_at, started_at, completed_at, description, tags_json, progress_percentage, current_epoch, total_epochs, metrics_json, error_message, model_artifact_path FROM training_jobs WHERE id = $1 "#, ) .bind(job_id) .fetch_optional(self.db_pool.pool()) .await .context("Failed to fetch training job")?; if let Some(row) = row { let record = TrainingJobRecord { id: row.get("id"), model_type: row.get("model_type"), status: row.get("status"), config_json: row.get("config_json"), created_at: row.get("created_at"), started_at: row.get("started_at"), completed_at: row.get("completed_at"), description: row.get("description"), tags_json: row.get("tags_json"), progress_percentage: row.get("progress_percentage"), current_epoch: row.get("current_epoch"), total_epochs: row.get("total_epochs"), metrics_json: row.get("metrics_json"), error_message: row.get("error_message"), model_artifact_path: row.get("model_artifact_path"), }; Ok(Some(record)) } else { Ok(None) } } /// List training jobs with filtering and pagination pub async fn list_training_jobs( &self, status_filter: Option<&str>, model_type_filter: Option<&str>, limit: Option, offset: Option, ) -> Result> { let mut query = String::from( r#" SELECT id, model_type, status, config_json, created_at, started_at, completed_at, description, tags_json, progress_percentage, current_epoch, total_epochs, metrics_json, error_message, model_artifact_path FROM training_jobs WHERE 1=1 "#, ); let mut bind_count = 0; if status_filter.is_some() { bind_count += 1; query.push_str(&format!(" AND status = ${}", bind_count)); } if model_type_filter.is_some() { bind_count += 1; query.push_str(&format!(" AND model_type = ${}", bind_count)); } query.push_str(" ORDER BY created_at DESC"); if limit.is_some() { bind_count += 1; query.push_str(&format!(" LIMIT ${}", bind_count)); } if offset.is_some() { bind_count += 1; query.push_str(&format!(" OFFSET ${}", bind_count)); } let mut sql_query = sqlx::query(&query); if let Some(status) = status_filter { sql_query = sql_query.bind(status); } if let Some(model_type) = model_type_filter { sql_query = sql_query.bind(model_type); } if let Some(limit) = limit { sql_query = sql_query.bind(limit); } if let Some(offset) = offset { sql_query = sql_query.bind(offset); } let rows = sql_query .fetch_all(self.db_pool.pool()) .await .context("Failed to fetch training jobs")?; let mut jobs = Vec::new(); for row in rows { let record = TrainingJobRecord { id: row.get("id"), model_type: row.get("model_type"), status: row.get("status"), config_json: row.get("config_json"), created_at: row.get("created_at"), started_at: row.get("started_at"), completed_at: row.get("completed_at"), description: row.get("description"), tags_json: row.get("tags_json"), progress_percentage: row.get("progress_percentage"), current_epoch: row.get("current_epoch"), total_epochs: row.get("total_epochs"), metrics_json: row.get("metrics_json"), error_message: row.get("error_message"), model_artifact_path: row.get("model_artifact_path"), }; jobs.push(record); } Ok(jobs) } /// Get training job count with optional filters pub async fn count_training_jobs( &self, status_filter: Option<&str>, model_type_filter: Option<&str>, ) -> Result { let mut query = String::from("SELECT COUNT(*) FROM training_jobs WHERE 1=1"); let mut bind_count = 0; if status_filter.is_some() { bind_count += 1; query.push_str(&format!(" AND status = ${}", bind_count)); } if model_type_filter.is_some() { bind_count += 1; query.push_str(&format!(" AND model_type = ${}", bind_count)); } let mut sql_query = sqlx::query_scalar(&query); if let Some(status) = status_filter { sql_query = sql_query.bind(status); } if let Some(model_type) = model_type_filter { sql_query = sql_query.bind(model_type); } let count: i64 = sql_query .fetch_one(self.db_pool.pool()) .await .context("Failed to count training jobs")?; Ok(count) } /// Insert training metrics for an epoch pub async fn insert_training_metrics( &self, job_id: Uuid, epoch: i32, train_loss: Option, validation_loss: Option, metrics: &HashMap, ) -> Result<()> { let metrics_json = serde_json::to_string(metrics).context("Failed to serialize metrics")?; sqlx::query( r#" INSERT INTO training_metrics (job_id, epoch, timestamp, train_loss, validation_loss, metrics_json) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (job_id, epoch) DO UPDATE SET timestamp = EXCLUDED.timestamp, train_loss = EXCLUDED.train_loss, validation_loss = EXCLUDED.validation_loss, metrics_json = EXCLUDED.metrics_json "# ) .bind(job_id) .bind(epoch) .bind(Utc::now()) .bind(train_loss.map(|x| x as f32)) .bind(validation_loss.map(|x| x as f32)) .bind(metrics_json) .execute(self.db_pool.pool()) .await .context("Failed to insert training metrics")?; debug!( "Inserted training metrics for job {} epoch {}", job_id, epoch ); Ok(()) } /// Get training metrics for a job pub async fn get_training_metrics( &self, job_id: Uuid, ) -> Result)>> { let rows = sqlx::query( r#" SELECT epoch, train_loss, validation_loss, metrics_json FROM training_metrics WHERE job_id = $1 ORDER BY epoch "#, ) .bind(job_id) .fetch_all(self.db_pool.pool()) .await .context("Failed to fetch training metrics")?; let mut metrics = Vec::new(); for row in rows { let epoch: i32 = row.get("epoch"); let train_loss: Option = row.get("train_loss"); let validation_loss: Option = row.get("validation_loss"); let metrics_json: String = row.get("metrics_json"); let parsed_metrics: HashMap = serde_json::from_str(&metrics_json).unwrap_or_default(); metrics.push(( epoch, train_loss.unwrap_or(0.0), validation_loss.unwrap_or(0.0), parsed_metrics, )); } Ok(metrics) } /// Delete a training job and its metrics pub async fn delete_training_job(&self, job_id: Uuid) -> Result { let result = sqlx::query("DELETE FROM training_jobs WHERE id = $1") .bind(job_id) .execute(self.db_pool.pool()) .await .context("Failed to delete training job")?; Ok(result.rows_affected() > 0) } /// Health check for database connectivity pub async fn health_check(&self) -> Result<()> { sqlx::query("SELECT 1") .execute(self.db_pool.pool()) .await .context("Database health check failed")?; Ok(()) } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use config::database::DatabaseConfig; // Note: These tests require a running PostgreSQL database // In a CI environment, you would use a test database async fn setup_test_db() -> Result { let config = DatabaseConfig { url: "postgresql://test:test@localhost:5432/test_ml_training".to_string(), max_connections: 5, min_connections: 1, connect_timeout: std::time::Duration::from_secs(10), query_timeout: std::time::Duration::from_secs(30), enable_query_logging: true, application_name: Some("ml_training_service_test".to_string()), pool: config::database::PoolConfig::default(), transaction: config::database::TransactionConfig::default(), }; DatabaseManager::new(&config).await } #[tokio::test] #[ignore = "Requires database setup"] async fn test_database_migrations() { let db = setup_test_db() .await .expect("Failed to setup test database"); // Migrations should have run automatically assert!(db.health_check().await.is_ok()); } #[tokio::test] #[ignore = "Requires database setup"] async fn test_insert_and_get_job() { let db = setup_test_db() .await .expect("Failed to setup test database"); let job_record = TrainingJobRecord { id: Uuid::new_v4(), model_type: "TLOB".to_string(), status: "Pending".to_string(), config_json: "{}".to_string(), created_at: Utc::now(), started_at: None, completed_at: None, description: "Test job".to_string(), tags_json: "{}".to_string(), progress_percentage: 0.0, current_epoch: 0, total_epochs: 100, metrics_json: "{}".to_string(), error_message: None, model_artifact_path: None, }; // Insert job db.insert_training_job(&job_record) .await .expect("Failed to insert job"); // Get job let retrieved = db .get_training_job(job_record.id) .await .expect("Failed to get job"); assert!(retrieved.is_some()); let retrieved = retrieved.unwrap(); assert_eq!(retrieved.id, job_record.id); assert_eq!(retrieved.model_type, job_record.model_type); assert_eq!(retrieved.status, job_record.status); } }