Files
foxhunt/services/ml_training_service/src/database.rs
jgrusewski c79cca5564 chore(clippy): add deny(unwrap_used) to ml_training_service, fix 58 violations
Add #![deny(clippy::unwrap_used, clippy::expect_used)] to lib.rs and main.rs.

Fix all violations by category:
- training_metrics.rs / simple_metrics.rs: file-level #![allow] with safety
  comment (Prometheus register_*!() macros with literal names are infallible)
- asset_parser.rs: function-level #[allow] for invariant regex literal expect()
- technical_indicators.rs: replace unwrap() on VecDeque::back()/get() with
  let-else early returns
- data_config.rs: bind start/end before assigning to avoid unwrap()
- data_loader.rs: convert 3x database.as_ref().expect() to .ok_or_else()?;
  fix Price construction chain with .or_else().map_err()?
- dbn_data_loader.rs: fix Price::from_f64().unwrap_or_else() chains with
  .or_else().unwrap_or_default()
- checkpoint_manager.rs: convert serde_json::to_value().unwrap() to .map_err()?
- orchestrator.rs: use unwrap_or_default() for Price in map() closures
- main.rs: fix rustls expect, metrics encoder, spawn closure error handling
- validation_pipeline.rs: fix path UTF-8 expect and last().expect() calls
- batch_tuning_manager.rs: fix current_dir().expect() with unwrap_or_else
- All test modules: add #[allow(clippy::unwrap_used, clippy::expect_used)]

Result: ml_training_service generates zero clippy warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:56:22 +01:00

654 lines
22 KiB
Rust

//! 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<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
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<String>,
pub model_artifact_path: Option<String>,
}
impl TrainingJobRecord {
/// Convert from TrainingJob to database record
#[allow(dead_code)]
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
#[allow(dead_code)]
pub fn to_training_job(&self) -> Result<TrainingJob> {
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<String, String> =
serde_json::from_str(&self.tags_json).unwrap_or_default();
let metrics: HashMap<String, f64> =
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> {
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<Self> {
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)
}
/// 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
#[allow(dead_code)]
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
#[allow(dead_code)]
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
#[allow(dead_code)]
pub async fn get_training_job(&self, job_id: Uuid) -> Result<Option<TrainingJobRecord>> {
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
#[allow(dead_code)]
pub async fn list_training_jobs(
&self,
status_filter: Option<&str>,
model_type_filter: Option<&str>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<TrainingJobRecord>> {
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
#[allow(dead_code)]
pub async fn count_training_jobs(
&self,
status_filter: Option<&str>,
model_type_filter: Option<&str>,
) -> Result<i64> {
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
#[allow(dead_code)]
pub async fn insert_training_metrics(
&self,
job_id: Uuid,
epoch: i32,
train_loss: Option<f64>,
validation_loss: Option<f64>,
metrics: &HashMap<String, f64>,
) -> 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
#[allow(dead_code)]
pub async fn get_training_metrics(
&self,
job_id: Uuid,
) -> Result<Vec<(i32, f32, f32, HashMap<String, f64>)>> {
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<f32> = row.get("train_loss");
let validation_loss: Option<f32> = row.get("validation_loss");
let metrics_json: String = row.get("metrics_json");
let parsed_metrics: HashMap<String, f64> =
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
#[allow(dead_code)]
pub async fn delete_training_job(&self, job_id: Uuid) -> Result<bool> {
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<DatabaseManager> {
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);
}
}