Wire JobSpawner into the gRPC service struct so that training jobs can be persisted to PostgreSQL before being dispatched to K8s. This is the first step toward a durable job queue that survives pod restarts. - Add `job_spawner: Option<Arc<JobSpawner>>` to MLTrainingServiceImpl - Extend `new()` constructor to accept the spawner parameter - Add `DatabaseManager::pg_pool()` accessor for cheap PgPool cloning - Construct JobSpawner in main.rs and pass `Some(job_spawner)` to service - Add compile-time test verifying the field exists Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
651 lines
22 KiB
Rust
651 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
|
|
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<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)
|
|
}
|
|
|
|
/// 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<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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
}
|
|
}
|