Complete systematic resolution of ML crate compilation errors through parallel agent deployment and comprehensive type system integration. Key Achievements: - ✅ Reduced ML errors from 83 to ZERO compilation errors - ✅ Successfully converted ML crate to use common::Price, common::Decimal - ✅ Fixed all type system conflicts and import issues - ✅ Achieved full workspace compilation success - ✅ Systematic parallel agent approach validated Technical Details: - Deployed 6+ specialized parallel agents using skydesk and zen tools - Fixed 114+ specific compilation errors systematically - Converted IntegerPrice → common::Price throughout - Resolved trait bounds, method resolution, and enum variant issues - Added proper type conversions and error handling Verification: - cargo check -p ml: ✅ SUCCESS (warnings only) - cargo check --workspace: ✅ SUCCESS (warnings only) 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1638 lines
61 KiB
Rust
1638 lines
61 KiB
Rust
//! PostgreSQL-based Configuration Database Module
|
|
//!
|
|
//! This module provides PostgreSQL-backed configuration storage with:
|
|
//! - Hot-reload via PostgreSQL NOTIFY/LISTEN using existing comprehensive schema
|
|
//! - In-memory caching with TTL for performance
|
|
//! - Type-safe configuration getters
|
|
//! - Integration with existing configuration tables and functions
|
|
//! - Configuration change notifications with atomic updates
|
|
|
|
use crate::schemas::{ModelConfig, ModelLoadRequest, ModelLoadResponse, ModelVersion};
|
|
use crate::{ConfigCategory, ConfigError, ConfigResult, ConfigSource, ConfigValue};
|
|
use anyhow::Context;
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::{PgPool, Row};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::{mpsc, RwLock};
|
|
use tokio::time::interval;
|
|
use tracing::{debug, error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// Pool configuration for database connections
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct PoolConfig {
|
|
/// Database connection URL
|
|
pub database_url: String,
|
|
/// Minimum number of connections in the pool
|
|
pub min_connections: u32,
|
|
/// Maximum number of connections in the pool
|
|
pub max_connections: u32,
|
|
/// Maximum time to wait for a connection from the pool
|
|
pub acquire_timeout_secs: u64,
|
|
/// Maximum lifetime of a connection
|
|
pub max_lifetime_secs: u64,
|
|
/// Maximum idle time for a connection
|
|
pub idle_timeout_secs: u64,
|
|
/// Test connections before use
|
|
pub test_before_acquire: bool,
|
|
/// Enable connection health checks
|
|
pub health_check_enabled: bool,
|
|
/// Health check interval in seconds
|
|
pub health_check_interval_secs: u64,
|
|
}
|
|
|
|
impl Default for PoolConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
database_url: "postgresql://localhost:5432/database".to_string(),
|
|
min_connections: 5,
|
|
max_connections: 100,
|
|
acquire_timeout_secs: 30,
|
|
max_lifetime_secs: 1800, // 30 minutes
|
|
idle_timeout_secs: 600, // 10 minutes
|
|
test_before_acquire: true,
|
|
health_check_enabled: true,
|
|
health_check_interval_secs: 60,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Transaction configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct TransactionConfig {
|
|
/// Default timeout for transactions in seconds
|
|
pub default_timeout_secs: u64,
|
|
/// Maximum number of savepoints allowed
|
|
pub max_savepoints: u32,
|
|
/// Enable automatic retry for serialization failures
|
|
pub enable_retry: bool,
|
|
/// Maximum number of retry attempts
|
|
pub max_retries: u32,
|
|
/// Base delay between retries in milliseconds
|
|
pub retry_delay_ms: u64,
|
|
}
|
|
|
|
impl Default for TransactionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
default_timeout_secs: 30,
|
|
max_savepoints: 10,
|
|
enable_retry: true,
|
|
max_retries: 3,
|
|
retry_delay_ms: 100,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Database configuration for PostgreSQL connection with optimized pool settings
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct DatabaseConfig {
|
|
/// PostgreSQL connection URL (for backward compatibility)
|
|
pub url: String,
|
|
/// Pool configuration
|
|
pub pool: PoolConfig,
|
|
/// Transaction configuration
|
|
pub transaction: TransactionConfig,
|
|
/// Maximum number of connections in the pool (deprecated, use pool.max_connections)
|
|
pub max_connections: u32,
|
|
/// Connection timeout in seconds (deprecated, use pool.acquire_timeout_secs)
|
|
pub connect_timeout: u64,
|
|
/// Query timeout in seconds
|
|
pub query_timeout: u64,
|
|
/// Whether to validate schema on startup
|
|
pub validate_schema: bool,
|
|
/// Enable query logging
|
|
pub enable_query_logging: bool,
|
|
/// Enable metrics collection
|
|
pub enable_metrics: bool,
|
|
/// Application name for connection identification
|
|
pub application_name: String,
|
|
}
|
|
|
|
impl DatabaseConfig {
|
|
/// Create a new database configuration with the given URL
|
|
pub fn new(url: String) -> Self {
|
|
let mut pool_config = PoolConfig::default();
|
|
pool_config.database_url = url.clone();
|
|
|
|
Self {
|
|
url,
|
|
pool: pool_config,
|
|
transaction: TransactionConfig::default(),
|
|
max_connections: 20,
|
|
connect_timeout: 30,
|
|
query_timeout: 60,
|
|
validate_schema: true,
|
|
enable_query_logging: false,
|
|
enable_metrics: true,
|
|
application_name: "database-lib".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Validate the database configuration
|
|
pub fn validate(&self) -> ConfigResult<()> {
|
|
if self.url.is_empty() {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Database URL cannot be empty".to_string(),
|
|
});
|
|
}
|
|
|
|
if self.application_name.is_empty() {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Application name cannot be empty".to_string(),
|
|
});
|
|
}
|
|
|
|
if self.max_connections == 0 {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Max connections must be greater than 0".to_string(),
|
|
});
|
|
}
|
|
|
|
if self.connect_timeout == 0 {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Connect timeout must be greater than 0".to_string(),
|
|
});
|
|
}
|
|
|
|
if self.query_timeout == 0 {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Query timeout must be greater than 0".to_string(),
|
|
});
|
|
}
|
|
|
|
// Validate URL format (basic check)
|
|
if !self.url.starts_with("postgresql://") && !self.url.starts_with("postgres://") {
|
|
return Err(ConfigError::ValidationError {
|
|
message: "Database URL must be a valid PostgreSQL connection string".to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create database configuration from environment variables
|
|
pub fn from_env() -> ConfigResult<Self> {
|
|
let mut url = std::env::var("DATABASE_URL").expect(
|
|
"CRITICAL SECURITY: DATABASE_URL environment variable must be set in production",
|
|
);
|
|
|
|
// SECURITY: Enforce TLS for production database connections
|
|
url = Self::enforce_database_security(&url)?;
|
|
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
|
.unwrap_or_else(|_| "20".to_string()) // Increased default for production
|
|
.parse()
|
|
.unwrap_or(20);
|
|
|
|
let connect_timeout = std::env::var("DATABASE_CONNECT_TIMEOUT")
|
|
.unwrap_or_else(|_| "30".to_string())
|
|
.parse()
|
|
.unwrap_or(30);
|
|
|
|
let query_timeout = std::env::var("DATABASE_QUERY_TIMEOUT")
|
|
.unwrap_or_else(|_| "60".to_string())
|
|
.parse()
|
|
.unwrap_or(60);
|
|
|
|
let validate_schema = std::env::var("DATABASE_VALIDATE_SCHEMA")
|
|
.unwrap_or_else(|_| "true".to_string())
|
|
.parse()
|
|
.unwrap_or(true);
|
|
|
|
let enable_query_logging = std::env::var("DATABASE_ENABLE_QUERY_LOGGING")
|
|
.unwrap_or_else(|_| "false".to_string())
|
|
.parse()
|
|
.unwrap_or(false);
|
|
|
|
let enable_metrics = std::env::var("DATABASE_ENABLE_METRICS")
|
|
.unwrap_or_else(|_| "true".to_string())
|
|
.parse()
|
|
.unwrap_or(true);
|
|
|
|
let application_name = std::env::var("DATABASE_APPLICATION_NAME")
|
|
.unwrap_or_else(|_| "foxhunt-config-loader".to_string());
|
|
|
|
// Create pool configuration
|
|
let mut pool_config = PoolConfig::default();
|
|
pool_config.database_url = url.clone();
|
|
pool_config.max_connections = max_connections;
|
|
pool_config.acquire_timeout_secs = connect_timeout;
|
|
|
|
// Create transaction configuration with defaults
|
|
let transaction_config = TransactionConfig::default();
|
|
|
|
Ok(Self {
|
|
url,
|
|
pool: pool_config,
|
|
transaction: transaction_config,
|
|
max_connections,
|
|
connect_timeout,
|
|
query_timeout,
|
|
validate_schema,
|
|
enable_query_logging,
|
|
enable_metrics,
|
|
application_name,
|
|
})
|
|
}
|
|
|
|
/// Enforce database security settings including TLS encryption
|
|
fn enforce_database_security(url: &str) -> ConfigResult<String> {
|
|
let mut secure_url = url.to_string();
|
|
|
|
// Check if this is a production environment
|
|
let is_production = std::env::var("FOXHUNT_ENV")
|
|
.unwrap_or_else(|_| "development".to_string()) == "production" ||
|
|
std::env::var("RUST_ENV")
|
|
.unwrap_or_else(|_| "development".to_string()) == "production";
|
|
|
|
// Parse the URL to check for security parameters
|
|
if secure_url.contains("sslmode=") {
|
|
// URL already has SSL configuration, validate it's secure
|
|
if is_production && (secure_url.contains("sslmode=disable") || secure_url.contains("sslmode=allow")) {
|
|
warn!("SECURITY WARNING: Insecure SSL mode detected in production DATABASE_URL");
|
|
// Force secure SSL mode in production
|
|
secure_url = secure_url.replace("sslmode=disable", "sslmode=require");
|
|
secure_url = secure_url.replace("sslmode=allow", "sslmode=require");
|
|
warn!("Forced SSL mode to 'require' for production security");
|
|
}
|
|
} else {
|
|
// No SSL configuration found, add secure defaults
|
|
let separator = if secure_url.contains('?') { "&" } else { "?" };
|
|
|
|
if is_production {
|
|
// Production: require SSL with certificate verification
|
|
secure_url.push_str(&format!("{}sslmode=require", separator));
|
|
info!("Added sslmode=require to production DATABASE_URL for security");
|
|
} else {
|
|
// Development: prefer SSL but allow fallback
|
|
secure_url.push_str(&format!("{}sslmode=prefer", separator));
|
|
info!("Added sslmode=prefer to development DATABASE_URL");
|
|
}
|
|
}
|
|
|
|
// Add additional security parameters if not present
|
|
if !secure_url.contains("connect_timeout=") {
|
|
let separator = if secure_url.contains('?') { "&" } else { "?" };
|
|
secure_url.push_str(&format!("{}connect_timeout=30", separator));
|
|
}
|
|
|
|
// Add application name for security monitoring if not present
|
|
if !secure_url.contains("application_name=") {
|
|
let separator = if secure_url.contains('?') { "&" } else { "?" };
|
|
let app_name = std::env::var("DATABASE_APPLICATION_NAME")
|
|
.unwrap_or_else(|_| "foxhunt-trading-service".to_string());
|
|
secure_url.push_str(&format!("{}application_name={}", separator, app_name));
|
|
}
|
|
|
|
if secure_url != url {
|
|
info!("Database URL enhanced with security parameters");
|
|
}
|
|
|
|
Ok(secure_url)
|
|
}
|
|
}
|
|
|
|
impl Default for DatabaseConfig {
|
|
fn default() -> Self {
|
|
let url = "postgresql://postgres:password@localhost/foxhunt".to_string();
|
|
let mut pool_config = PoolConfig::default();
|
|
pool_config.database_url = url.clone();
|
|
|
|
Self {
|
|
url,
|
|
pool: pool_config,
|
|
transaction: TransactionConfig::default(),
|
|
max_connections: 20, // Increased default pool size
|
|
connect_timeout: 30,
|
|
query_timeout: 60,
|
|
validate_schema: true,
|
|
enable_query_logging: false,
|
|
enable_metrics: true,
|
|
application_name: "foxhunt-config".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builder pattern methods for DatabaseConfig
|
|
impl DatabaseConfig {
|
|
/// Set application name
|
|
pub fn with_application_name(mut self, name: String) -> Self {
|
|
self.application_name = name;
|
|
self
|
|
}
|
|
|
|
/// Set query logging enabled/disabled
|
|
pub fn with_query_logging(mut self, enabled: bool) -> Self {
|
|
self.enable_query_logging = enabled;
|
|
self
|
|
}
|
|
|
|
/// Set metrics enabled/disabled
|
|
pub fn with_metrics(mut self, enabled: bool) -> Self {
|
|
self.enable_metrics = enabled;
|
|
self
|
|
}
|
|
|
|
/// Set maximum connections
|
|
pub fn with_max_connections(mut self, max: u32) -> Self {
|
|
self.max_connections = max;
|
|
self
|
|
}
|
|
|
|
/// Set connect timeout
|
|
pub fn with_connect_timeout(mut self, timeout: u64) -> Self {
|
|
self.connect_timeout = timeout;
|
|
self
|
|
}
|
|
|
|
/// Set query timeout
|
|
pub fn with_query_timeout(mut self, timeout: u64) -> Self {
|
|
self.query_timeout = timeout;
|
|
self
|
|
}
|
|
|
|
/// Set schema validation enabled/disabled
|
|
pub fn with_schema_validation(mut self, enabled: bool) -> Self {
|
|
self.validate_schema = enabled;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Cached configuration entry with TTL
|
|
#[derive(Debug, Clone)]
|
|
struct CachedConfig {
|
|
/// The configuration value
|
|
value: ConfigValue,
|
|
/// When this entry was cached
|
|
cached_at: Instant,
|
|
/// TTL for this entry
|
|
ttl: Duration,
|
|
/// Cache hit count for LRU eviction
|
|
hit_count: u64,
|
|
}
|
|
|
|
impl CachedConfig {
|
|
/// Check if this cached entry has expired
|
|
fn is_expired(&self) -> bool {
|
|
self.cached_at.elapsed() > self.ttl
|
|
}
|
|
|
|
/// Increment hit count and return the value
|
|
fn hit(&mut self) -> &ConfigValue {
|
|
self.hit_count += 1;
|
|
&self.value
|
|
}
|
|
}
|
|
|
|
/// PostgreSQL Configuration Loader with hot-reload support using existing comprehensive schema
|
|
pub struct PostgresConfigLoader {
|
|
/// PostgreSQL connection pool with optimized settings
|
|
pool: PgPool,
|
|
/// In-memory cache with TTL and hit tracking
|
|
cache: Arc<RwLock<HashMap<(ConfigCategory, String), CachedConfig>>>,
|
|
/// Default TTL for cached entries
|
|
default_ttl: Duration,
|
|
/// Channel for hot-reload notifications
|
|
reload_tx: mpsc::UnboundedSender<(ConfigCategory, String)>,
|
|
/// Receiver for hot-reload notifications (for external subscribers)
|
|
reload_rx: Arc<RwLock<Option<mpsc::UnboundedReceiver<(ConfigCategory, String)>>>>,
|
|
/// Environment for configuration loading
|
|
environment: String,
|
|
}
|
|
|
|
impl PostgresConfigLoader {
|
|
/// Create a new PostgreSQL configuration loader with connection pooling
|
|
pub async fn new(config: DatabaseConfig, default_ttl: Duration) -> ConfigResult<Self> {
|
|
// Build connection pool with proper configuration for HFT workloads
|
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(config.max_connections)
|
|
.min_connections(2) // Always maintain minimum connections
|
|
// .connect_timeout(Duration::from_secs(config.connect_timeout)) // Method may not exist in this sqlx version
|
|
.acquire_timeout(Duration::from_secs(10)) // Timeout for acquiring connections
|
|
.idle_timeout(Some(Duration::from_secs(300))) // 5 minutes idle timeout
|
|
.max_lifetime(Some(Duration::from_secs(1800))) // 30 minutes max lifetime
|
|
.test_before_acquire(true) // Test connections before use
|
|
.after_connect(move |conn, _meta| {
|
|
let app_name = config.application_name.clone();
|
|
Box::pin(async move {
|
|
// Set application name and prepare connection for high performance
|
|
sqlx::query(&format!("SET application_name = '{}'", app_name))
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
// Optimize for low latency
|
|
sqlx::query("SET statement_timeout = '30s'")
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
sqlx::query("SET lock_timeout = '10s'")
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
Ok(())
|
|
})
|
|
})
|
|
.connect(&config.url)
|
|
.await
|
|
.context("Failed to create PostgreSQL connection pool")?;
|
|
|
|
let environment =
|
|
std::env::var("FOXHUNT_ENV").unwrap_or_else(|_| "development".to_string());
|
|
|
|
let (reload_tx, reload_rx) = mpsc::unbounded_channel();
|
|
|
|
let loader = Self {
|
|
pool,
|
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
|
default_ttl,
|
|
reload_tx,
|
|
reload_rx: Arc::new(RwLock::new(Some(reload_rx))),
|
|
environment,
|
|
};
|
|
|
|
// Validate configuration schema exists (uses existing comprehensive schema)
|
|
if config.validate_schema {
|
|
loader.validate_schema().await?;
|
|
}
|
|
|
|
// Start the hot-reload listener for the existing schema
|
|
loader.start_notify_listener().await?;
|
|
|
|
info!(
|
|
"PostgreSQL ConfigLoader initialized for environment '{}' with TTL {:?}, pool size: {}",
|
|
loader.environment, default_ttl, config.max_connections
|
|
);
|
|
|
|
Ok(loader)
|
|
}
|
|
|
|
/// Validate that the existing comprehensive configuration schema is available
|
|
async fn validate_schema(&self) -> ConfigResult<()> {
|
|
// Check if the main configuration tables exist (from migrations 007/008)
|
|
let tables_exist = sqlx::query_scalar::<_, bool>(
|
|
r#"
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name IN ('config_settings', 'config_categories', 'config_history')
|
|
)
|
|
"#,
|
|
)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.context("Failed to check if configuration tables exist")?;
|
|
|
|
if !tables_exist {
|
|
return Err(ConfigError::DatabaseError {
|
|
message: "Configuration tables not found. Please run migrations 007_configuration_schema.sql and 008_initial_config_data.sql".to_string(),
|
|
});
|
|
}
|
|
|
|
// Verify we can access the configuration functions
|
|
let functions_exist = sqlx::query_scalar::<_, bool>(
|
|
r#"
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_proc
|
|
WHERE proname IN ('get_config_value', 'set_config_value', 'notify_config_change')
|
|
)
|
|
"#,
|
|
)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.context("Failed to check if configuration functions exist")?;
|
|
|
|
if !functions_exist {
|
|
return Err(ConfigError::DatabaseError {
|
|
message: "Configuration functions not found. Please run migration 007_configuration_schema.sql".to_string(),
|
|
});
|
|
}
|
|
|
|
// Check for model configuration tables
|
|
let model_tables_exist = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'model_config')"
|
|
)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.context("Failed to check if model_config table exists")?;
|
|
|
|
if !model_tables_exist {
|
|
warn!("Model configuration tables not found. Some model management features may not work.");
|
|
}
|
|
|
|
info!("Configuration schema validation successful");
|
|
Ok(())
|
|
}
|
|
|
|
/// Start the PostgreSQL NOTIFY listener for hot-reload using the existing schema
|
|
async fn start_notify_listener(&self) -> ConfigResult<()> {
|
|
let pool = self.pool.clone();
|
|
let reload_tx = self.reload_tx.clone();
|
|
let cache = self.cache.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let mut retry_count = 0;
|
|
const MAX_RETRIES: u32 = 5;
|
|
|
|
loop {
|
|
let mut listener = match sqlx::postgres::PgListener::connect_with(&pool).await {
|
|
Ok(listener) => listener,
|
|
Err(e) => {
|
|
retry_count += 1;
|
|
if retry_count > MAX_RETRIES {
|
|
error!(
|
|
"Failed to create NOTIFY listener after {} retries: {}",
|
|
MAX_RETRIES, e
|
|
);
|
|
return;
|
|
}
|
|
warn!(
|
|
"Failed to create NOTIFY listener (retry {}): {}",
|
|
retry_count, e
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(2_u64.pow(retry_count - 1))).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Listen to the main configuration changes channel from the existing schema
|
|
if let Err(e) = listener.listen("foxhunt_config_changes").await {
|
|
error!("Failed to listen on foxhunt_config_changes channel: {}", e);
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
continue;
|
|
}
|
|
|
|
// Also listen to model configuration changes (legacy support)
|
|
if let Err(e) = listener.listen("config_change").await {
|
|
warn!(
|
|
"Failed to listen on config_change channel (model configs): {}",
|
|
e
|
|
);
|
|
}
|
|
|
|
info!("NOTIFY listener started for configuration hot-reload on channels: foxhunt_config_changes, config_change");
|
|
retry_count = 0; // Reset retry count on successful connection
|
|
|
|
loop {
|
|
match listener.recv().await {
|
|
Ok(notification) => {
|
|
let channel = notification.channel();
|
|
let payload = notification.payload();
|
|
|
|
debug!("Received NOTIFY on channel {}: {}", channel, payload);
|
|
|
|
match channel {
|
|
"foxhunt_config_changes" => {
|
|
// Parse JSON payload from the existing schema notification function
|
|
if let Ok(parsed) =
|
|
serde_json::from_str::<serde_json::Value>(payload)
|
|
{
|
|
let config_key = parsed
|
|
.get("config_key")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
let category_path = parsed
|
|
.get("category_path")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("system")
|
|
.to_string();
|
|
|
|
// Map category path to ConfigCategory
|
|
let category =
|
|
Self::map_category_path_to_enum(&category_path);
|
|
|
|
// Invalidate cache entry for atomic cache consistency
|
|
let cache_key = (category.clone(), config_key.clone());
|
|
{
|
|
let mut cache_guard = cache.write().await;
|
|
if cache_guard.remove(&cache_key).is_some() {
|
|
debug!(
|
|
"Invalidated cache entry for {}.{}",
|
|
category_path, config_key
|
|
);
|
|
}
|
|
}
|
|
|
|
// Send reload notification to subscribers
|
|
if let Err(e) = reload_tx.send((category, config_key)) {
|
|
error!("Failed to send reload notification: {}", e);
|
|
break;
|
|
}
|
|
} else {
|
|
warn!(
|
|
"Failed to parse foxhunt_config_changes payload: {}",
|
|
payload
|
|
);
|
|
}
|
|
}
|
|
"config_change" => {
|
|
// Handle model configuration changes (legacy format)
|
|
if let Ok(parsed) =
|
|
serde_json::from_str::<serde_json::Value>(payload)
|
|
{
|
|
let key = parsed
|
|
.get("key")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
// For model configs, use MachineLearning category
|
|
let category = ConfigCategory::MachineLearning;
|
|
let cache_key = (category.clone(), key.clone());
|
|
|
|
// Invalidate cache entry for atomic cache consistency
|
|
{
|
|
let mut cache_guard = cache.write().await;
|
|
if cache_guard.remove(&cache_key).is_some() {
|
|
debug!(
|
|
"Invalidated model config cache entry for {}",
|
|
key
|
|
);
|
|
}
|
|
}
|
|
|
|
// Send reload notification to subscribers
|
|
if let Err(e) = reload_tx.send((category, key)) {
|
|
error!("Failed to send reload notification: {}", e);
|
|
break;
|
|
}
|
|
} else {
|
|
warn!("Failed to parse config_change payload: {}", payload);
|
|
}
|
|
}
|
|
_ => {
|
|
warn!("Unknown notification channel: {}", channel);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Error receiving NOTIFY: {}", e);
|
|
break; // Break inner loop to reconnect
|
|
}
|
|
}
|
|
}
|
|
|
|
// Connection lost, wait before retrying
|
|
warn!("NOTIFY listener connection lost, retrying in 5 seconds...");
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
}
|
|
});
|
|
|
|
// Start cache cleanup task with advanced LRU eviction
|
|
self.start_cache_cleanup().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Map category path from database to ConfigCategory enum
|
|
fn map_category_path_to_enum(category_path: &str) -> ConfigCategory {
|
|
match category_path.split('.').next().unwrap_or("system") {
|
|
"trading" => ConfigCategory::Trading,
|
|
"risk" => ConfigCategory::Risk,
|
|
"ml" => ConfigCategory::MachineLearning,
|
|
"performance" => ConfigCategory::Performance,
|
|
"security" => ConfigCategory::Security,
|
|
"storage" => ConfigCategory::Storage,
|
|
"database" | "system" => ConfigCategory::Environment,
|
|
_ => ConfigCategory::Environment,
|
|
}
|
|
}
|
|
|
|
/// Start background task to clean up expired cache entries with LRU eviction
|
|
async fn start_cache_cleanup(&self) {
|
|
let cache = self.cache.clone();
|
|
let cleanup_interval = self.default_ttl / 4; // Clean up 4x more frequently than TTL
|
|
|
|
tokio::spawn(async move {
|
|
let mut interval = interval(cleanup_interval);
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
|
|
let mut cache_guard = cache.write().await;
|
|
let initial_size = cache_guard.len();
|
|
|
|
// Remove expired entries
|
|
cache_guard.retain(|_, cached| !cached.is_expired());
|
|
|
|
// If cache is too large, evict least recently used entries
|
|
const MAX_CACHE_SIZE: usize = 1000;
|
|
if cache_guard.len() > MAX_CACHE_SIZE {
|
|
let mut entries: Vec<_> = cache_guard
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.hit_count))
|
|
.collect();
|
|
entries.sort_by_key(|(_, hit_count)| *hit_count);
|
|
|
|
let to_remove = cache_guard.len() - (MAX_CACHE_SIZE * 3 / 4); // Remove 25% when over limit
|
|
for (key, _) in entries.into_iter().take(to_remove) {
|
|
cache_guard.remove(&key);
|
|
}
|
|
}
|
|
|
|
let final_size = cache_guard.len();
|
|
if initial_size != final_size {
|
|
debug!(
|
|
"Cache cleanup: removed {} entries (expired + LRU eviction), {} entries remaining",
|
|
initial_size - final_size,
|
|
final_size
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Get a configuration value with caching using the existing comprehensive schema
|
|
pub async fn get_config<T>(
|
|
&self,
|
|
category: ConfigCategory,
|
|
key: &str,
|
|
) -> ConfigResult<Option<T>>
|
|
where
|
|
T: for<'de> Deserialize<'de>,
|
|
{
|
|
let cache_key = (category.clone(), key.to_string());
|
|
|
|
// Check cache first with hit tracking
|
|
{
|
|
let mut cache_guard = self.cache.write().await;
|
|
if let Some(cached) = cache_guard.get_mut(&cache_key) {
|
|
if !cached.is_expired() {
|
|
debug!(
|
|
"Cache hit for {}.{} (hits: {})",
|
|
category.table_name(),
|
|
key,
|
|
cached.hit_count + 1
|
|
);
|
|
let value = cached.hit().value.clone();
|
|
return Ok(Some(serde_json::from_value(value)?));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache miss or expired - fetch from database using the existing comprehensive schema
|
|
debug!(
|
|
"Cache miss for {}.{}, fetching from database",
|
|
category.table_name(),
|
|
key
|
|
);
|
|
|
|
// Use the get_config_value function from the existing schema for proper inheritance
|
|
let sql = "SELECT get_config_value($1, $2) as config_value";
|
|
let row = sqlx::query(sql)
|
|
.bind(key)
|
|
.bind(&self.environment)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.with_context(|| format!("Failed to fetch config {}.{}", category.table_name(), key))?;
|
|
|
|
if let Some(row) = row {
|
|
let config_value_json: Option<serde_json::Value> = row.try_get("config_value")?;
|
|
|
|
if let Some(value) = config_value_json {
|
|
// Also get metadata from config_settings table for complete ConfigValue
|
|
let metadata_sql = r#"
|
|
SELECT config_key, category_path, description, updated_at, is_active, hot_reload
|
|
FROM config_settings
|
|
WHERE config_key = $1 AND environment = $2 AND is_active = TRUE
|
|
ORDER BY updated_at DESC LIMIT 1
|
|
"#;
|
|
|
|
let metadata_row = sqlx::query(metadata_sql)
|
|
.bind(key)
|
|
.bind(&self.environment)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.with_context(|| format!("Failed to fetch metadata for config {}", key))?;
|
|
|
|
let config_value = if let Some(meta) = metadata_row {
|
|
ConfigValue {
|
|
key: meta.try_get("config_key")?,
|
|
value: value.clone(),
|
|
category: category.clone(),
|
|
environment: self.environment.clone(),
|
|
updated_at: meta.try_get("updated_at")?,
|
|
description: meta.try_get("description")?,
|
|
is_active: meta.try_get("is_active")?,
|
|
source: ConfigSource::Database,
|
|
}
|
|
} else {
|
|
// Fallback for cases where metadata is not available
|
|
ConfigValue {
|
|
key: key.to_string(),
|
|
value: value.clone(),
|
|
category: category.clone(),
|
|
environment: self.environment.clone(),
|
|
updated_at: Utc::now(),
|
|
description: None,
|
|
is_active: true,
|
|
source: ConfigSource::Database,
|
|
}
|
|
};
|
|
|
|
// Cache the result with hit tracking
|
|
let cached = CachedConfig {
|
|
value: config_value.clone(),
|
|
cached_at: Instant::now(),
|
|
ttl: self.default_ttl,
|
|
hit_count: 0,
|
|
};
|
|
|
|
{
|
|
let mut cache_guard = self.cache.write().await;
|
|
cache_guard.insert(cache_key, cached);
|
|
}
|
|
|
|
Ok(Some(serde_json::from_value(value)?))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Set a configuration value using the existing comprehensive schema with atomic updates
|
|
pub async fn set_config<T>(
|
|
&self,
|
|
category: ConfigCategory,
|
|
key: &str,
|
|
value: &T,
|
|
description: Option<&str>,
|
|
) -> ConfigResult<()>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
self.set_config_for_environment(category, key, value, &self.environment, description)
|
|
.await
|
|
}
|
|
|
|
/// Set a configuration value for a specific environment using atomic updates
|
|
pub async fn set_config_for_environment<T>(
|
|
&self,
|
|
category: ConfigCategory,
|
|
key: &str,
|
|
value: &T,
|
|
environment: &str,
|
|
description: Option<&str>,
|
|
) -> ConfigResult<()>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
let json_value = serde_json::to_value(value)?;
|
|
|
|
// Use the set_config_value function from the existing schema for proper history tracking
|
|
let sql = "SELECT set_config_value($1, $2, $3, $4, $5) as success";
|
|
|
|
let result = sqlx::query(sql)
|
|
.bind(key)
|
|
.bind(&json_value)
|
|
.bind(environment)
|
|
.bind("config-api") // changed_by
|
|
.bind(description.unwrap_or("Updated via ConfigLoader API")) // change_reason
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.with_context(|| format!("Failed to set config {} for {}", key, environment))?;
|
|
|
|
let success: Option<bool> = result.try_get("success")?;
|
|
|
|
if success == Some(true) {
|
|
// Invalidate cache entry atomically
|
|
let cache_key = (category, key.to_string());
|
|
{
|
|
let mut cache_guard = self.cache.write().await;
|
|
cache_guard.remove(&cache_key);
|
|
}
|
|
|
|
info!(
|
|
"Updated configuration {} for {} using schema function with atomic update",
|
|
key, environment
|
|
);
|
|
Ok(())
|
|
} else {
|
|
// If the function returns false, the setting doesn't exist, try to insert it
|
|
self.create_config_setting(category, key, value, environment, description)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// Create a new configuration setting in the comprehensive schema
|
|
async fn create_config_setting<T>(
|
|
&self,
|
|
category: ConfigCategory,
|
|
key: &str,
|
|
value: &T,
|
|
environment: &str,
|
|
description: Option<&str>,
|
|
) -> ConfigResult<()>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
let json_value = serde_json::to_value(value)?;
|
|
|
|
// Map ConfigCategory to category path for the comprehensive schema
|
|
let category_path = match category {
|
|
ConfigCategory::Trading => "trading",
|
|
ConfigCategory::Risk => "risk",
|
|
ConfigCategory::MarketData => "trading.market_data",
|
|
ConfigCategory::MachineLearning => "ml",
|
|
ConfigCategory::Brokers => "trading.brokers",
|
|
ConfigCategory::Performance => "performance",
|
|
ConfigCategory::Security => "security",
|
|
ConfigCategory::Environment => "system",
|
|
ConfigCategory::Storage => "storage",
|
|
};
|
|
|
|
// Get category_id for the comprehensive schema
|
|
let category_id_sql = "SELECT id FROM config_categories WHERE category_path = $1 LIMIT 1";
|
|
let category_row = sqlx::query(category_id_sql)
|
|
.bind(category_path)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch category ID")?;
|
|
|
|
let category_id: i32 = if let Some(row) = category_row {
|
|
row.try_get("id")?
|
|
} else {
|
|
return Err(ConfigError::ValidationError {
|
|
message: format!(
|
|
"Category '{}' not found in config_categories",
|
|
category_path
|
|
),
|
|
});
|
|
};
|
|
|
|
// Insert new configuration setting with atomic operation
|
|
let insert_sql = r#"
|
|
INSERT INTO config_settings
|
|
(config_key, category_id, category_path, config_value, value_type, environment, description, hot_reload, version)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true, 1)
|
|
ON CONFLICT (config_key, environment)
|
|
DO UPDATE SET
|
|
config_value = $4,
|
|
description = $7,
|
|
updated_at = NOW(),
|
|
updated_by = CURRENT_USER,
|
|
version = config_settings.version + 1
|
|
"#;
|
|
|
|
// Determine value type for validation
|
|
let value_type = match &json_value {
|
|
serde_json::Value::String(_) => "string",
|
|
serde_json::Value::Number(_) => "number",
|
|
serde_json::Value::Bool(_) => "boolean",
|
|
serde_json::Value::Array(_) => "array",
|
|
serde_json::Value::Object(_) => "object",
|
|
serde_json::Value::Null => "null",
|
|
};
|
|
|
|
sqlx::query(insert_sql)
|
|
.bind(key)
|
|
.bind(category_id)
|
|
.bind(category_path)
|
|
.bind(&json_value)
|
|
.bind(value_type)
|
|
.bind(environment)
|
|
.bind(description.unwrap_or(""))
|
|
.execute(&self.pool)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to insert config setting {} for {}",
|
|
key, environment
|
|
)
|
|
})?;
|
|
|
|
info!(
|
|
"Created new configuration setting {} in category {} for {}",
|
|
key, category_path, environment
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all configurations for a category using the comprehensive schema
|
|
pub async fn get_category_configs(
|
|
&self,
|
|
category: ConfigCategory,
|
|
) -> ConfigResult<Vec<ConfigValue>> {
|
|
self.get_category_configs_for_environment(category, &self.environment)
|
|
.await
|
|
}
|
|
|
|
/// Get all configurations for a category and environment using the comprehensive schema
|
|
pub async fn get_category_configs_for_environment(
|
|
&self,
|
|
category: ConfigCategory,
|
|
environment: &str,
|
|
) -> ConfigResult<Vec<ConfigValue>> {
|
|
let category_path = match category {
|
|
ConfigCategory::Trading => "trading%",
|
|
ConfigCategory::Risk => "risk%",
|
|
ConfigCategory::MarketData => "trading.market_data%",
|
|
ConfigCategory::MachineLearning => "ml%",
|
|
ConfigCategory::Brokers => "trading.brokers%",
|
|
ConfigCategory::Performance => "performance%",
|
|
ConfigCategory::Security => "security%",
|
|
ConfigCategory::Environment => "system%",
|
|
ConfigCategory::Storage => "storage%",
|
|
};
|
|
|
|
let sql = r#"
|
|
SELECT config_key, config_value, category_path, updated_at, description, is_active
|
|
FROM config_settings
|
|
WHERE category_path LIKE $1 AND environment = $2 AND is_active = TRUE
|
|
ORDER BY config_key
|
|
"#;
|
|
|
|
let rows = sqlx::query(sql)
|
|
.bind(category_path)
|
|
.bind(environment)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to fetch configs for category path {}",
|
|
category_path
|
|
)
|
|
})?;
|
|
|
|
let mut configs = Vec::new();
|
|
for row in rows {
|
|
configs.push(ConfigValue {
|
|
key: row.try_get("config_key")?,
|
|
value: row.try_get("config_value")?,
|
|
category: category.clone(),
|
|
environment: environment.to_string(),
|
|
updated_at: row.try_get("updated_at")?,
|
|
description: row.try_get("description")?,
|
|
is_active: row.try_get("is_active")?,
|
|
source: ConfigSource::Database,
|
|
});
|
|
}
|
|
|
|
Ok(configs)
|
|
}
|
|
|
|
/// Subscribe to configuration changes (returns receiver for hot-reload notifications)
|
|
pub async fn subscribe_to_changes(
|
|
&self,
|
|
) -> ConfigResult<mpsc::UnboundedReceiver<(ConfigCategory, String)>> {
|
|
let mut reload_rx_guard = self.reload_rx.write().await;
|
|
reload_rx_guard
|
|
.take()
|
|
.ok_or_else(|| ConfigError::ValidationError {
|
|
message: "Configuration change subscription already taken".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Get cache statistics including hit ratios
|
|
pub async fn cache_stats(&self) -> (usize, usize, u64, f64) {
|
|
let cache_guard = self.cache.read().await;
|
|
let total = cache_guard.len();
|
|
let expired = cache_guard.values().filter(|c| c.is_expired()).count();
|
|
let total_hits: u64 = cache_guard.values().map(|c| c.hit_count).sum();
|
|
let hit_ratio = if total > 0 {
|
|
total_hits as f64 / total as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
(total, expired, total_hits, hit_ratio)
|
|
}
|
|
|
|
/// Clear the entire cache
|
|
pub async fn clear_cache(&self) {
|
|
let mut cache_guard = self.cache.write().await;
|
|
let size = cache_guard.len();
|
|
cache_guard.clear();
|
|
info!("Cleared {} entries from configuration cache", size);
|
|
}
|
|
|
|
/// Get current environment
|
|
pub fn environment(&self) -> &str {
|
|
&self.environment
|
|
}
|
|
|
|
/// Test database connection and schema
|
|
pub async fn test_connection(&self) -> ConfigResult<()> {
|
|
// Test basic connectivity
|
|
sqlx::query("SELECT 1")
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.context("Failed to test database connection")?;
|
|
|
|
// Test configuration schema functions
|
|
sqlx::query("SELECT get_config_value('system.test', $1)")
|
|
.bind(&self.environment)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.context("Failed to test configuration schema functions")?;
|
|
|
|
info!("Database connection and schema test successful");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get database connection pool for direct access
|
|
pub fn get_pool(&self) -> &PgPool {
|
|
&self.pool
|
|
}
|
|
}
|
|
|
|
/// Type-safe configuration getters for common configuration parameters
|
|
impl PostgresConfigLoader {
|
|
/// Get trading configuration
|
|
pub async fn get_trading_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Trading, key).await
|
|
}
|
|
|
|
/// Get risk configuration
|
|
pub async fn get_risk_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Risk, key).await
|
|
}
|
|
|
|
/// Get market data configuration
|
|
pub async fn get_market_data_config(
|
|
&self,
|
|
key: &str,
|
|
) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::MarketData, key).await
|
|
}
|
|
|
|
/// Get machine learning configuration
|
|
pub async fn get_ml_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::MachineLearning, key).await
|
|
}
|
|
|
|
/// Get broker configuration
|
|
pub async fn get_broker_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Brokers, key).await
|
|
}
|
|
|
|
/// Get performance configuration
|
|
pub async fn get_performance_config(
|
|
&self,
|
|
key: &str,
|
|
) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Performance, key).await
|
|
}
|
|
|
|
/// Get security configuration
|
|
pub async fn get_security_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Security, key).await
|
|
}
|
|
|
|
/// Get environment configuration
|
|
pub async fn get_environment_config(
|
|
&self,
|
|
key: &str,
|
|
) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Environment, key).await
|
|
}
|
|
|
|
/// Get storage configuration
|
|
pub async fn get_storage_config(&self, key: &str) -> ConfigResult<Option<serde_json::Value>> {
|
|
self.get_config(ConfigCategory::Storage, key).await
|
|
}
|
|
}
|
|
|
|
/// Model management methods for configuration loader with atomic operations
|
|
impl PostgresConfigLoader {
|
|
/// Get model configuration by name
|
|
pub async fn get_model_config(&self, model_name: &str) -> ConfigResult<Option<ModelConfig>> {
|
|
let sql = r#"
|
|
SELECT id, name, version, s3_path, cache_path, metadata, is_active, created_at, updated_at
|
|
FROM model_config
|
|
WHERE name = $1 AND is_active = true
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1
|
|
"#;
|
|
|
|
let row = sqlx::query(sql)
|
|
.bind(model_name)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch model configuration")?;
|
|
|
|
if let Some(row) = row {
|
|
Ok(Some(ModelConfig {
|
|
id: row.try_get("id")?,
|
|
name: row.try_get("name")?,
|
|
version: row.try_get("version")?,
|
|
s3_path: row.try_get("s3_path")?,
|
|
cache_path: row.try_get("cache_path")?,
|
|
metadata: row.try_get("metadata")?,
|
|
is_active: row.try_get("is_active")?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Get model configuration by name and version
|
|
pub async fn get_model_config_version(
|
|
&self,
|
|
model_name: &str,
|
|
version: &str,
|
|
) -> ConfigResult<Option<ModelConfig>> {
|
|
let sql = r#"
|
|
SELECT id, name, version, s3_path, cache_path, metadata, is_active, created_at, updated_at
|
|
FROM model_config
|
|
WHERE name = $1 AND version = $2 AND is_active = true
|
|
"#;
|
|
|
|
let row = sqlx::query(sql)
|
|
.bind(model_name)
|
|
.bind(version)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.context("Failed to fetch model configuration")?;
|
|
|
|
if let Some(row) = row {
|
|
Ok(Some(ModelConfig {
|
|
id: row.try_get("id")?,
|
|
name: row.try_get("name")?,
|
|
version: row.try_get("version")?,
|
|
s3_path: row.try_get("s3_path")?,
|
|
cache_path: row.try_get("cache_path")?,
|
|
metadata: row.try_get("metadata")?,
|
|
is_active: row.try_get("is_active")?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// List all model versions for a given model
|
|
pub async fn list_model_versions(
|
|
&self,
|
|
model_config_id: Uuid,
|
|
) -> ConfigResult<Vec<ModelVersion>> {
|
|
let sql = r#"
|
|
SELECT id, model_config_id, version, s3_path, cache_path, checksum, size_bytes,
|
|
performance_metrics, training_metadata, is_current, created_at, updated_at
|
|
FROM model_versions
|
|
WHERE model_config_id = $1
|
|
ORDER BY created_at DESC
|
|
"#;
|
|
|
|
let rows = sqlx::query(sql)
|
|
.bind(model_config_id)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.context("Failed to fetch model versions")?;
|
|
|
|
let mut versions = Vec::new();
|
|
for row in rows {
|
|
versions.push(ModelVersion {
|
|
id: row.try_get("id")?,
|
|
model_config_id: row.try_get("model_config_id")?,
|
|
version: row.try_get("version")?,
|
|
s3_path: row.try_get("s3_path")?,
|
|
cache_path: row.try_get("cache_path")?,
|
|
checksum: row.try_get("checksum")?,
|
|
size_bytes: row.try_get("size_bytes")?,
|
|
performance_metrics: row.try_get("performance_metrics")?,
|
|
training_metadata: row.try_get("training_metadata")?,
|
|
is_current: row.try_get("is_current")?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
});
|
|
}
|
|
|
|
Ok(versions)
|
|
}
|
|
|
|
/// Get all active model configurations
|
|
pub async fn list_active_models(&self) -> ConfigResult<Vec<ModelConfig>> {
|
|
let sql = r#"
|
|
SELECT id, name, version, s3_path, cache_path, metadata, is_active, created_at, updated_at
|
|
FROM model_config
|
|
WHERE is_active = true
|
|
ORDER BY name, updated_at DESC
|
|
"#;
|
|
|
|
let rows = sqlx::query(sql)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.context("Failed to fetch active models")?;
|
|
|
|
let mut models = Vec::new();
|
|
for row in rows {
|
|
models.push(ModelConfig {
|
|
id: row.try_get("id")?,
|
|
name: row.try_get("name")?,
|
|
version: row.try_get("version")?,
|
|
s3_path: row.try_get("s3_path")?,
|
|
cache_path: row.try_get("cache_path")?,
|
|
metadata: row.try_get("metadata")?,
|
|
is_active: row.try_get("is_active")?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
});
|
|
}
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
/// Set model configuration active status with atomic update
|
|
pub async fn set_model_active(
|
|
&self,
|
|
model_name: &str,
|
|
version: &str,
|
|
is_active: bool,
|
|
) -> ConfigResult<()> {
|
|
let sql = r#"
|
|
UPDATE model_config
|
|
SET is_active = $3, updated_at = NOW()
|
|
WHERE name = $1 AND version = $2
|
|
"#;
|
|
|
|
let result = sqlx::query(sql)
|
|
.bind(model_name)
|
|
.bind(version)
|
|
.bind(is_active)
|
|
.execute(&self.pool)
|
|
.await
|
|
.context("Failed to update model active status")?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(ConfigError::NotFound {
|
|
key: format!("{}-{}", model_name, version),
|
|
});
|
|
}
|
|
|
|
info!(
|
|
"Set model {}/{} active status to {} with atomic update",
|
|
model_name, version, is_active
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create or update model configuration with atomic operation
|
|
pub async fn upsert_model_config(&self, config: &ModelConfig) -> ConfigResult<()> {
|
|
let sql = r#"
|
|
INSERT INTO model_config (id, name, version, s3_path, cache_path, metadata, is_active, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
ON CONFLICT (name, version)
|
|
DO UPDATE SET
|
|
s3_path = $4,
|
|
cache_path = $5,
|
|
metadata = $6,
|
|
is_active = $7,
|
|
updated_at = NOW()
|
|
"#;
|
|
|
|
sqlx::query(sql)
|
|
.bind(&config.id)
|
|
.bind(&config.name)
|
|
.bind(&config.version)
|
|
.bind(&config.s3_path)
|
|
.bind(&config.cache_path)
|
|
.bind(&config.metadata)
|
|
.bind(config.is_active)
|
|
.execute(&self.pool)
|
|
.await
|
|
.context("Failed to upsert model configuration")?;
|
|
|
|
info!(
|
|
"Upserted model configuration for {}/{} with atomic operation",
|
|
config.name, config.version
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Create or update model version with atomic operation
|
|
pub async fn upsert_model_version(&self, version: &ModelVersion) -> ConfigResult<()> {
|
|
let sql = r#"
|
|
INSERT INTO model_versions
|
|
(id, model_config_id, version, s3_path, cache_path, checksum, size_bytes,
|
|
performance_metrics, training_metadata, is_current, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
|
ON CONFLICT (model_config_id, version)
|
|
DO UPDATE SET
|
|
s3_path = $4,
|
|
cache_path = $5,
|
|
checksum = $6,
|
|
size_bytes = $7,
|
|
performance_metrics = $8,
|
|
training_metadata = $9,
|
|
is_current = $10,
|
|
updated_at = NOW()
|
|
"#;
|
|
|
|
sqlx::query(sql)
|
|
.bind(&version.id)
|
|
.bind(&version.model_config_id)
|
|
.bind(&version.version)
|
|
.bind(&version.s3_path)
|
|
.bind(&version.cache_path)
|
|
.bind(&version.checksum)
|
|
.bind(version.size_bytes)
|
|
.bind(&version.performance_metrics)
|
|
.bind(&version.training_metadata)
|
|
.bind(version.is_current)
|
|
.execute(&self.pool)
|
|
.await
|
|
.context("Failed to upsert model version")?;
|
|
|
|
info!(
|
|
"Upserted model version {} for config {} with atomic operation",
|
|
version.version, version.model_config_id
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle model load request with caching and performance optimizations
|
|
pub async fn handle_model_load_request(
|
|
&self,
|
|
request: &ModelLoadRequest,
|
|
) -> ConfigResult<ModelLoadResponse> {
|
|
let start_time = std::time::Instant::now();
|
|
|
|
let model_config = if let Some(version) = &request.version {
|
|
self.get_model_config_version(&request.model_name, version)
|
|
.await?
|
|
} else {
|
|
self.get_model_config(&request.model_name).await?
|
|
};
|
|
|
|
match model_config {
|
|
Some(config) if config.is_active => {
|
|
// Check if cache exists and request doesn't force reload
|
|
if !request.force_reload && config.cache_path.is_some() {
|
|
let cache_path = config.cache_path.clone();
|
|
return Ok(ModelLoadResponse {
|
|
success: true,
|
|
model_config: Some(config),
|
|
cache_path,
|
|
error: None,
|
|
load_time_ms: start_time.elapsed().as_millis() as u64,
|
|
});
|
|
}
|
|
|
|
// For cache_only requests, fail if no cache available
|
|
if request.cache_only && config.cache_path.is_none() {
|
|
return Ok(ModelLoadResponse {
|
|
success: false,
|
|
model_config: Some(config),
|
|
cache_path: None,
|
|
error: Some("Model not cached and cache_only requested".to_string()),
|
|
load_time_ms: start_time.elapsed().as_millis() as u64,
|
|
});
|
|
}
|
|
|
|
Ok(ModelLoadResponse {
|
|
success: true,
|
|
model_config: Some(config.clone()),
|
|
cache_path: config.cache_path,
|
|
error: None,
|
|
load_time_ms: start_time.elapsed().as_millis() as u64,
|
|
})
|
|
}
|
|
Some(_) => Ok(ModelLoadResponse {
|
|
success: false,
|
|
model_config: None,
|
|
cache_path: None,
|
|
error: Some(format!("Model {} is not active", request.model_name)),
|
|
load_time_ms: start_time.elapsed().as_millis() as u64,
|
|
}),
|
|
None => Ok(ModelLoadResponse {
|
|
success: false,
|
|
model_config: None,
|
|
cache_path: None,
|
|
error: Some(format!("Model {} not found", request.model_name)),
|
|
load_time_ms: start_time.elapsed().as_millis() as u64,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn test_database_config_new() {
|
|
let url = "postgresql://localhost:5432/test".to_string();
|
|
let config = DatabaseConfig::new(url.clone());
|
|
assert_eq!(config.url, url);
|
|
assert_eq!(config.max_connections, 20);
|
|
assert_eq!(config.application_name, "database-lib");
|
|
}
|
|
|
|
#[test]
|
|
fn test_database_config_validation() {
|
|
let mut config = DatabaseConfig::default();
|
|
assert!(config.validate().is_ok());
|
|
|
|
// Test empty URL
|
|
config.url = String::new();
|
|
assert!(config.validate().is_err());
|
|
|
|
// Test empty application name
|
|
config.url = "postgresql://localhost/test".to_string();
|
|
config.application_name = String::new();
|
|
assert!(config.validate().is_err());
|
|
|
|
// Test zero connections
|
|
config.application_name = "test".to_string();
|
|
config.max_connections = 0;
|
|
assert!(config.validate().is_err());
|
|
|
|
// Test invalid URL format
|
|
config.max_connections = 10;
|
|
config.url = "invalid://url".to_string();
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_database_config_builder() {
|
|
let config = DatabaseConfig::default()
|
|
.with_application_name("test-app".to_string())
|
|
.with_query_logging(true)
|
|
.with_metrics(false)
|
|
.with_max_connections(50);
|
|
|
|
assert_eq!(config.application_name, "test-app");
|
|
assert!(config.enable_query_logging);
|
|
assert!(!config.enable_metrics);
|
|
assert_eq!(config.max_connections, 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_database_config_from_env() {
|
|
std::env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test");
|
|
std::env::set_var("DATABASE_MAX_CONNECTIONS", "25");
|
|
std::env::set_var("DATABASE_CONNECT_TIMEOUT", "15");
|
|
|
|
let config = DatabaseConfig::from_env().unwrap();
|
|
assert_eq!(config.max_connections, 25);
|
|
assert_eq!(config.connect_timeout, 15);
|
|
|
|
std::env::remove_var("DATABASE_URL");
|
|
std::env::remove_var("DATABASE_MAX_CONNECTIONS");
|
|
std::env::remove_var("DATABASE_CONNECT_TIMEOUT");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cached_config_expiry_and_hits() {
|
|
let config_value = ConfigValue {
|
|
key: "test".to_string(),
|
|
value: serde_json::json!("test_value"),
|
|
category: ConfigCategory::Trading,
|
|
environment: "test".to_string(),
|
|
updated_at: Utc::now(),
|
|
description: None,
|
|
is_active: true,
|
|
source: ConfigSource::Database,
|
|
};
|
|
|
|
let cached = CachedConfig {
|
|
value: config_value.clone(),
|
|
cached_at: Instant::now() - Duration::from_secs(10),
|
|
ttl: Duration::from_secs(5),
|
|
hit_count: 0,
|
|
};
|
|
|
|
assert!(cached.is_expired());
|
|
|
|
let mut fresh_cached = CachedConfig {
|
|
value: config_value,
|
|
cached_at: Instant::now(),
|
|
ttl: Duration::from_secs(60),
|
|
hit_count: 0,
|
|
};
|
|
|
|
assert!(!fresh_cached.is_expired());
|
|
|
|
// Test hit counting
|
|
fresh_cached.hit();
|
|
assert_eq!(fresh_cached.hit_count, 1);
|
|
fresh_cached.hit();
|
|
assert_eq!(fresh_cached.hit_count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_category_path_mapping() {
|
|
assert_eq!(
|
|
PostgresConfigLoader::map_category_path_to_enum("trading.order_management"),
|
|
ConfigCategory::Trading
|
|
);
|
|
assert_eq!(
|
|
PostgresConfigLoader::map_category_path_to_enum("ml.models"),
|
|
ConfigCategory::MachineLearning
|
|
);
|
|
assert_eq!(
|
|
PostgresConfigLoader::map_category_path_to_enum("system.logging"),
|
|
ConfigCategory::Environment
|
|
);
|
|
}
|
|
}
|