Files
foxhunt/services/api/src/config/manager.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

323 lines
10 KiB
Rust

//! Configuration management with PostgreSQL NOTIFY/LISTEN hot-reload and Redis caching
use crate::config::validator::ConfigValidator;
use crate::error::{GatewayConfigError, GatewayConfigResult};
use chrono::{DateTime, Utc};
use redis::aio::ConnectionManager;
use serde_json::Value;
use sqlx::PgPool;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
use uuid::Uuid;
/// Configuration item from database
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct ConfigItem {
pub id: Uuid,
pub service_scope: String,
pub config_key: String,
pub config_value: Value,
pub data_type: String,
pub validation_rules: Option<Value>,
pub description: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub updated_by: Option<String>,
}
/// Centralized configuration manager with hot-reload and caching
pub struct ConfigurationManager {
/// Postgre`SQL` connection pool
db_pool: Arc<PgPool>,
/// Redis connection manager for caching
redis: Arc<RwLock<ConnectionManager>>,
/// Configuration validator
validator: Arc<RwLock<ConfigValidator>>,
/// Postgre`SQL` NOTIFY listener
listener: Option<sqlx::postgres::PgListener>,
}
impl ConfigurationManager {
/// Creates a new ConfigurationManager
///
/// # Arguments
/// * `db_pool` - Postgre`SQL` connection pool
///
/// * `redis` - Redis connection manager
pub async fn new(db_pool: PgPool, redis: ConnectionManager) -> GatewayConfigResult<Self> {
Ok(Self {
db_pool: Arc::new(db_pool),
redis: Arc::new(RwLock::new(redis)),
validator: Arc::new(RwLock::new(ConfigValidator::new())),
listener: None,
})
}
/// Starts listening for configuration changes via Postgre`SQL` NOTIFY
pub async fn start_listening(&mut self) -> GatewayConfigResult<()> {
let mut listener = sqlx::postgres::PgListener::connect_with(&self.db_pool).await?;
// Listen to global config updates channel
listener.listen("config_updates_global").await?;
info!("Started listening for configuration updates on 'config_updates_global'");
self.listener = Some(listener);
Ok(())
}
/// Processes configuration change notifications
pub async fn handle_notifications(&mut self) -> GatewayConfigResult<()> {
// Collect notifications first to avoid borrow checker issues
let mut invalidations = Vec::new();
if let Some(listener) = &mut self.listener {
while let Some(notification) = listener.try_recv().await? {
let payload = notification.payload();
debug!("Received config notification: {}", payload);
// Parse notification payload
if let Ok(payload_json) = serde_json::from_str::<Value>(payload) {
if let (Some(service_scope), Some(config_key)) = (
payload_json.get("service_scope").and_then(|v| v.as_str()),
payload_json.get("config_key").and_then(|v| v.as_str()),
) {
invalidations.push((service_scope.to_string(), config_key.to_string()));
}
}
}
}
// Now invalidate caches without holding the listener borrow
for (service_scope, config_key) in invalidations {
self.invalidate_cache(&service_scope, &config_key).await?;
info!("Invalidated cache for {}/{}", service_scope, config_key);
}
Ok(())
}
/// Retrieves a configuration value
///
/// # Arguments
/// * `service_scope` - Service scope (e.g., "trading", "global")
///
/// * `config_key` - Configuration key
///
/// # Returns
///
/// Configuration item if found
pub async fn get_config(
&self,
service_scope: &str,
config_key: &str,
) -> GatewayConfigResult<ConfigItem> {
// Try Redis cache first
if let Some(cached) = self.get_from_cache(service_scope, config_key).await? {
debug!("Cache hit for {}/{}", service_scope, config_key);
return Ok(cached);
}
// Cache miss - load from PostgreSQL
debug!("Cache miss for {}/{}", service_scope, config_key);
let config = self.load_from_db(service_scope, config_key).await?;
// Update Redis cache
self.set_in_cache(&config).await?;
Ok(config)
}
/// Updates a configuration value with validation and audit logging
///
/// # Arguments
/// * `service_scope` - Service scope
///
/// * `config_key` - Configuration key
/// * `new_value` - New configuration value
///
/// * `updated_by` - User ID making the change
pub async fn update_config(
&self,
service_scope: &str,
config_key: &str,
new_value: Value,
updated_by: &str,
) -> GatewayConfigResult<()> {
// Load current configuration for validation rules and audit
let current = self.load_from_db(service_scope, config_key).await?;
// Validate new value
{
let mut validator = self.validator.write().await;
validator.validate(
&new_value,
&current.data_type,
current.validation_rules.as_ref(),
)?;
}
// Start transaction
let mut tx = self.db_pool.begin().await?;
// Update config_settings
sqlx::query(
r#"
UPDATE config_settings
SET config_value = $1, updated_by = $2, updated_at = NOW()
WHERE service_scope = $3 AND config_key = $4
"#,
)
.bind(&new_value)
.bind(updated_by)
.bind(service_scope)
.bind(config_key)
.execute(&mut *tx)
.await?;
// Insert audit log entry
sqlx::query(
r#"
INSERT INTO config_audit_log (action, service_scope, config_key, old_value, new_value, changed_by)
VALUES ('UPDATE', $1, $2, $3, $4, $5)
"#,
)
.bind(service_scope)
.bind(config_key)
.bind(&current.config_value)
.bind(&new_value)
.bind(updated_by)
.execute(&mut *tx)
.await?;
// Commit transaction (this triggers NOTIFY via database trigger)
tx.commit().await?;
info!(
"Updated configuration {}/{} by {}",
service_scope, config_key, updated_by
);
Ok(())
}
/// Lists all configurations for a service scope
///
/// # Arguments
/// * `service_scope` - Service scope (None for all scopes)
pub async fn list_configs(&self, service_scope: Option<&str>) -> GatewayConfigResult<Vec<ConfigItem>> {
let configs = if let Some(scope) = service_scope {
sqlx::query_as::<_, ConfigItem>(
r#"
SELECT * FROM config_settings
WHERE service_scope = $1 AND is_active = TRUE
ORDER BY config_key
"#,
)
.bind(scope)
.fetch_all(&*self.db_pool)
.await?
} else {
sqlx::query_as::<_, ConfigItem>(
r#"
SELECT * FROM config_settings
WHERE is_active = TRUE
ORDER BY service_scope, config_key
"#,
)
.fetch_all(&*self.db_pool)
.await?
};
Ok(configs)
}
/// Loads configuration from Postgre`SQL` database
async fn load_from_db(
&self,
service_scope: &str,
config_key: &str,
) -> GatewayConfigResult<ConfigItem> {
let config = sqlx::query_as::<_, ConfigItem>(
r#"
SELECT * FROM config_settings
WHERE service_scope = $1 AND config_key = $2 AND is_active = TRUE
"#,
)
.bind(service_scope)
.bind(config_key)
.fetch_optional(&*self.db_pool)
.await?
.ok_or_else(|| GatewayConfigError::NotFound {
service_scope: service_scope.to_string(),
key: config_key.to_string(),
})?;
Ok(config)
}
/// Retrieves configuration from Redis cache
async fn get_from_cache(
&self,
service_scope: &str,
config_key: &str,
) -> GatewayConfigResult<Option<ConfigItem>> {
let redis_key = format!("config:{}:{}", service_scope, config_key);
let mut redis = self.redis.write().await;
let cached: Option<String> = redis::cmd("GET")
.arg(&redis_key)
.query_async(&mut *redis)
.await
.map_err(GatewayConfigError::Redis)?;
if let Some(cached_json) = cached {
let config: ConfigItem = serde_json::from_str(&cached_json)?;
Ok(Some(config))
} else {
Ok(None)
}
}
/// Stores configuration in Redis cache
async fn set_in_cache(&self, config: &ConfigItem) -> GatewayConfigResult<()> {
let redis_key = format!("config:{}:{}", config.service_scope, config.config_key);
let config_json = serde_json::to_string(config)?;
let mut redis = self.redis.write().await;
// Set with 5-minute TTL
redis::cmd("SETEX")
.arg(&redis_key)
.arg(300) // 5 minutes
.arg(&config_json)
.query_async::<()>(&mut *redis)
.await
.map_err(GatewayConfigError::Redis)?;
Ok(())
}
/// Invalidates Redis cache for a configuration
async fn invalidate_cache(&self, service_scope: &str, config_key: &str) -> GatewayConfigResult<()> {
let redis_key = format!("config:{}:{}", service_scope, config_key);
let mut redis = self.redis.write().await;
redis::cmd("DEL")
.arg(&redis_key)
.query_async::<()>(&mut *redis)
.await
.map_err(GatewayConfigError::Redis)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
// Note: These tests require a running PostgreSQL and Redis instance
// They are integration tests and should be run with --ignored flag
}