Wave 144: Enable 112 infrastructure and E2E tests - Remove #[ignore] from PostgreSQL tests (41 tests) - Remove #[ignore] from Redis tests (18 tests) - Remove #[ignore] from Vault tests (11 tests) - Remove #[ignore] from E2E tests (42 tests: service health, backtesting, trading) - Fix test_metrics_output (add metrics initialization) - Create infrastructure health check script Wave 145: Fix JWT authentication for E2E tests - Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to Trading Service - Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to Backtesting Service - Add JWT_SECRET, JWT_ISSUER, JWT_AUDIENCE to ML Training Service - Fix auth_helpers.rs hardcoded issuer/audience values - Migrate E2E tests to TestAuthConfig pattern Root Cause (Wave 145): Backend services missing JWT environment variables Solution: Unified JWT configuration across all services Result: Services healthy, E2E tests need .env sourced for validation Agents: 311-320 (Wave 144), 331-342 (Wave 145) Files Modified: 35 (14 modified, 21 created) Documentation: 21 reports created (1,455+ lines) Co-Authored-By: Claude <noreply@anthropic.com>
915 lines
26 KiB
Rust
915 lines
26 KiB
Rust
//! Hot-Reload Integration Tests for PostgreSQL NOTIFY/LISTEN
|
|
//!
|
|
//! These tests verify the hot-reload functionality of the adaptive strategy
|
|
//! configuration system, including:
|
|
//! - PostgreSQL NOTIFY/LISTEN lifecycle
|
|
//! - Configuration change propagation
|
|
//! - Concurrent update handling
|
|
//! - ACID transaction verification
|
|
//! - Cache invalidation
|
|
//! - Performance characteristics
|
|
//!
|
|
//! ## Prerequisites
|
|
//! - PostgreSQL database running
|
|
//! - Migrations 015 and 016 applied
|
|
//! - DATABASE_URL environment variable set
|
|
|
|
#![allow(unused_crate_dependencies)]
|
|
//!
|
|
//! ## Running Tests
|
|
//! ```bash
|
|
//! # Run all hot-reload tests
|
|
//! cargo test --test hot_reload_integration --features postgres
|
|
//!
|
|
//! # Run performance tests (normally ignored)
|
|
//! cargo test --test hot_reload_integration --features postgres -- --ignored
|
|
//! ```
|
|
|
|
#![cfg(feature = "postgres")]
|
|
|
|
use adaptive_strategy::database_loader::DatabaseConfigLoader;
|
|
use serde_json::json;
|
|
use sqlx::{Executor, PgPool, Row};
|
|
use std::env;
|
|
use std::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
// ============================================================================
|
|
// TEST HELPERS
|
|
// ============================================================================
|
|
|
|
/// Get database URL from environment or use default test database
|
|
fn get_database_url() -> String {
|
|
env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://postgres:postgres@localhost:5432/foxhunt_test".to_string()
|
|
})
|
|
}
|
|
|
|
/// Create a DatabaseConfigLoader for testing
|
|
async fn create_loader() -> DatabaseConfigLoader {
|
|
let url = get_database_url();
|
|
DatabaseConfigLoader::new(&url)
|
|
.await
|
|
.expect("Failed to create DatabaseConfigLoader")
|
|
}
|
|
|
|
/// Create a separate connection pool for test operations
|
|
async fn create_test_pool() -> PgPool {
|
|
let url = get_database_url();
|
|
PgPool::connect(&url)
|
|
.await
|
|
.expect("Failed to create test pool")
|
|
}
|
|
|
|
/// Helper to wait for a notification with timeout
|
|
async fn wait_for_notification(
|
|
loader: &mut DatabaseConfigLoader,
|
|
timeout_secs: u64,
|
|
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
let result = timeout(
|
|
Duration::from_secs(timeout_secs),
|
|
loader.check_for_updates(),
|
|
)
|
|
.await??;
|
|
Ok(result)
|
|
}
|
|
|
|
/// Cleanup helper to remove test strategy after test completes
|
|
async fn cleanup_test_strategy(pool: &PgPool, strategy_id: &str) {
|
|
let _ = sqlx::query("DELETE FROM adaptive_strategy_config WHERE strategy_id = $1")
|
|
.bind(strategy_id)
|
|
.execute(pool)
|
|
.await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CATEGORY 1: HOT-RELOAD NOTIFICATION TESTS
|
|
// ============================================================================
|
|
|
|
/// Test 1: Verify basic NOTIFY/LISTEN lifecycle
|
|
#[tokio::test]
|
|
async fn test_hot_reload_enable_and_listen() {
|
|
let mut loader = create_loader().await;
|
|
|
|
// Enable hot-reload
|
|
loader
|
|
.enable_hot_reload()
|
|
.await
|
|
.expect("Failed to enable hot-reload");
|
|
|
|
// Trigger a change via SQL UPDATE
|
|
let pool = create_test_pool().await;
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET name = 'Updated Test Strategy', updated_at = NOW()
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to update config");
|
|
|
|
// Wait for notification (with timeout to prevent hanging)
|
|
let notification = wait_for_notification(&mut loader, 5)
|
|
.await
|
|
.expect("Timeout waiting for notification");
|
|
|
|
assert!(
|
|
notification.is_some(),
|
|
"Expected notification to be received"
|
|
);
|
|
assert_eq!(
|
|
notification.unwrap(),
|
|
"default-production",
|
|
"Notification should contain correct strategy_id"
|
|
);
|
|
}
|
|
|
|
/// Test 2: Verify model changes trigger notifications
|
|
#[tokio::test]
|
|
async fn test_hot_reload_notification_on_model_update() {
|
|
let mut loader = create_loader().await;
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
let pool = create_test_pool().await;
|
|
|
|
// Get a model to update
|
|
let model_id: uuid::Uuid = sqlx::query_scalar(
|
|
"SELECT id FROM adaptive_strategy_models
|
|
WHERE strategy_config_id = (
|
|
SELECT id FROM adaptive_strategy_config
|
|
WHERE strategy_id = 'default-production'
|
|
) LIMIT 1"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to find model");
|
|
|
|
// Update the model
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_models
|
|
SET initial_weight = 0.35, updated_at = NOW()
|
|
WHERE id = $1"
|
|
)
|
|
.bind(model_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to update model");
|
|
|
|
// Wait for notification
|
|
let notification = wait_for_notification(&mut loader, 5)
|
|
.await
|
|
.expect("Timeout waiting for notification");
|
|
|
|
assert!(
|
|
notification.is_some(),
|
|
"Model update should trigger notification"
|
|
);
|
|
}
|
|
|
|
/// Test 3: Verify feature changes trigger notifications
|
|
#[tokio::test]
|
|
async fn test_hot_reload_notification_on_feature_update() {
|
|
let mut loader = create_loader().await;
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
let pool = create_test_pool().await;
|
|
|
|
// Get a feature to update
|
|
let feature_id: uuid::Uuid = sqlx::query_scalar(
|
|
"SELECT id FROM adaptive_strategy_features
|
|
WHERE strategy_config_id = (
|
|
SELECT id FROM adaptive_strategy_config
|
|
WHERE strategy_id = 'default-production'
|
|
) LIMIT 1"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to find feature");
|
|
|
|
// Update the feature
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_features
|
|
SET enabled = NOT enabled, updated_at = NOW()
|
|
WHERE id = $1"
|
|
)
|
|
.bind(feature_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to update feature");
|
|
|
|
// Wait for notification
|
|
let notification = wait_for_notification(&mut loader, 5)
|
|
.await
|
|
.expect("Timeout waiting for notification");
|
|
|
|
assert!(
|
|
notification.is_some(),
|
|
"Feature update should trigger notification"
|
|
);
|
|
}
|
|
|
|
/// Test 4: Verify multiple listeners receive same notification
|
|
#[tokio::test]
|
|
async fn test_hot_reload_multiple_listeners() {
|
|
// Create 3 separate loaders (simulating multiple services)
|
|
let mut loader1 = create_loader().await;
|
|
let mut loader2 = create_loader().await;
|
|
let mut loader3 = create_loader().await;
|
|
|
|
loader1.enable_hot_reload().await.unwrap();
|
|
loader2.enable_hot_reload().await.unwrap();
|
|
loader3.enable_hot_reload().await.unwrap();
|
|
|
|
// Trigger a change
|
|
let pool = create_test_pool().await;
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET max_position_size = 0.08
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// All 3 listeners should receive notification
|
|
let notif1 = wait_for_notification(&mut loader1, 5).await.unwrap();
|
|
let notif2 = wait_for_notification(&mut loader2, 5).await.unwrap();
|
|
let notif3 = wait_for_notification(&mut loader3, 5).await.unwrap();
|
|
|
|
assert!(notif1.is_some(), "Loader 1 should receive notification");
|
|
assert!(notif2.is_some(), "Loader 2 should receive notification");
|
|
assert!(notif3.is_some(), "Loader 3 should receive notification");
|
|
|
|
// All should have same payload
|
|
assert_eq!(notif1, notif2);
|
|
assert_eq!(notif2, notif3);
|
|
}
|
|
|
|
/// Test 5: Verify notification payload format
|
|
#[tokio::test]
|
|
async fn test_hot_reload_notification_format() {
|
|
let mut loader = create_loader().await;
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
let pool = create_test_pool().await;
|
|
|
|
// Trigger INSERT operation
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (
|
|
strategy_id, name, description
|
|
) VALUES ($1, $2, $3)
|
|
ON CONFLICT (strategy_id) DO UPDATE SET name = EXCLUDED.name"
|
|
)
|
|
.bind("test-notification-format")
|
|
.bind("Test Notification Format")
|
|
.bind("Testing notification payload structure")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let notification = wait_for_notification(&mut loader, 5)
|
|
.await
|
|
.unwrap()
|
|
.expect("Should receive notification");
|
|
|
|
assert_eq!(
|
|
notification, "test-notification-format",
|
|
"Payload should contain strategy_id"
|
|
);
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool, "test-notification-format").await;
|
|
}
|
|
|
|
/// Test 6: Verify listener reconnection after connection loss
|
|
#[tokio::test]
|
|
async fn test_hot_reload_listener_reconnection() {
|
|
let mut loader = create_loader().await;
|
|
|
|
// Enable hot-reload
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
// Simulate connection loss by re-enabling (creates new listener)
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
// Verify notifications still work
|
|
let pool = create_test_pool().await;
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET description = 'Reconnection test'
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let notification = wait_for_notification(&mut loader, 5)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
notification.is_some(),
|
|
"Notifications should work after reconnection"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CATEGORY 2: ACID TRANSACTION TESTS
|
|
// ============================================================================
|
|
|
|
/// Test 7: Verify atomic commit across 3 tables
|
|
#[tokio::test]
|
|
async fn test_atomic_config_update_all_or_nothing() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create test strategy
|
|
let test_id = "test-atomic-update";
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (
|
|
strategy_id, name
|
|
) VALUES ($1, $2)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Atomic Test Strategy")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Get config ID
|
|
let config_id: uuid::Uuid = sqlx::query_scalar(
|
|
"SELECT id FROM adaptive_strategy_config WHERE strategy_id = $1"
|
|
)
|
|
.bind(test_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Start transaction
|
|
let mut tx = pool.begin().await.unwrap();
|
|
|
|
// Update all 3 tables atomically
|
|
sqlx::query("UPDATE adaptive_strategy_config SET name = 'Updated' WHERE id = $1")
|
|
.bind(config_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_models (
|
|
strategy_config_id, model_id, model_name, model_type, parameters, initial_weight
|
|
) VALUES ($1, $2, $3, $4, $5, $6)"
|
|
)
|
|
.bind(config_id)
|
|
.bind("test-model")
|
|
.bind("Test Model")
|
|
.bind("mamba2")
|
|
.bind(json!({}))
|
|
.bind(0.5)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_features (
|
|
strategy_config_id, feature_name, feature_type, parameters
|
|
) VALUES ($1, $2, $3, $4)"
|
|
)
|
|
.bind(config_id)
|
|
.bind("test-feature")
|
|
.bind("microstructure")
|
|
.bind(json!({}))
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Commit transaction
|
|
tx.commit().await.unwrap();
|
|
|
|
// Verify all changes persisted
|
|
let name: String = sqlx::query_scalar(
|
|
"SELECT name FROM adaptive_strategy_config WHERE id = $1"
|
|
)
|
|
.bind(config_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(name, "Updated", "Config update should persist");
|
|
|
|
let model_count: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM adaptive_strategy_models WHERE strategy_config_id = $1"
|
|
)
|
|
.bind(config_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(model_count, 1, "Model should be inserted");
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool, test_id).await;
|
|
}
|
|
|
|
/// Test 8: Verify rollback on constraint violation
|
|
#[tokio::test]
|
|
async fn test_transaction_rollback_on_constraint_violation() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Get original config
|
|
let original_name: String = sqlx::query_scalar(
|
|
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = 'default-production'"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Start transaction
|
|
let mut tx = pool.begin().await.unwrap();
|
|
|
|
// Update config
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config SET name = 'Will be rolled back'
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Attempt to insert duplicate strategy_id (UNIQUE constraint violation)
|
|
let result = sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (strategy_id, name)
|
|
VALUES ('default-production', 'Duplicate')"
|
|
)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Should fail on UNIQUE constraint");
|
|
|
|
// Transaction will auto-rollback when tx is dropped
|
|
drop(tx);
|
|
|
|
// Verify original config unchanged
|
|
let current_name: String = sqlx::query_scalar(
|
|
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = 'default-production'"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
current_name, original_name,
|
|
"Config should be unchanged after rollback"
|
|
);
|
|
}
|
|
|
|
/// Test 9: Verify transaction isolation
|
|
#[tokio::test]
|
|
async fn test_transaction_isolation_read_committed() {
|
|
let pool1 = create_test_pool().await;
|
|
let pool2 = create_test_pool().await;
|
|
|
|
// Start transaction T1
|
|
let mut tx1 = pool1.begin().await.unwrap();
|
|
|
|
// T1: Read original value
|
|
let original: f64 = sqlx::query_scalar(
|
|
"SELECT max_position_size FROM adaptive_strategy_config
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.fetch_one(&mut *tx1)
|
|
.await
|
|
.unwrap();
|
|
|
|
// T1: Update value
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config SET max_position_size = 0.20
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.execute(&mut *tx1)
|
|
.await
|
|
.unwrap();
|
|
|
|
// T2: Read value BEFORE T1 commits (should see original due to READ COMMITTED)
|
|
let uncommitted_read: f64 = sqlx::query_scalar(
|
|
"SELECT max_position_size FROM adaptive_strategy_config
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.fetch_one(&pool2)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
uncommitted_read, original,
|
|
"Should see original value before T1 commits"
|
|
);
|
|
|
|
// T1: Commit
|
|
tx1.commit().await.unwrap();
|
|
|
|
// T2: Read value AFTER T1 commits (should see updated value)
|
|
let committed_read: f64 = sqlx::query_scalar(
|
|
"SELECT max_position_size FROM adaptive_strategy_config
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.fetch_one(&pool2)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
committed_read, 0.20,
|
|
"Should see updated value after T1 commits"
|
|
);
|
|
|
|
// Restore original value
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config SET max_position_size = $1
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.bind(original)
|
|
.execute(&pool1)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Test 10: Verify validation prevents invalid configs
|
|
#[tokio::test]
|
|
async fn test_transaction_consistency_validation() {
|
|
let loader = create_loader().await;
|
|
|
|
// Attempt to load a config with invalid data (would fail validation)
|
|
// This tests that validation happens AFTER database load
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create invalid config
|
|
let test_id = "test-invalid-config";
|
|
let result = sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (
|
|
strategy_id, name, max_position_size
|
|
) VALUES ($1, $2, $3)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Invalid Config")
|
|
.bind(1.5) // Invalid: max_position_size > 1.0
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
// Insert should succeed (DB doesn't validate business rules)
|
|
assert!(result.is_ok(), "DB insert should succeed");
|
|
|
|
// But load_config should fail validation
|
|
let config_result = loader.load_config(test_id).await;
|
|
|
|
assert!(
|
|
config_result.is_err(),
|
|
"load_config should fail validation for invalid config"
|
|
);
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool, test_id).await;
|
|
}
|
|
|
|
/// Test 11: Verify durability after commit
|
|
#[tokio::test]
|
|
async fn test_transaction_durability_after_commit() {
|
|
let pool1 = create_test_pool().await;
|
|
|
|
let test_id = "test-durability";
|
|
|
|
// Create and commit config
|
|
{
|
|
let mut tx = pool1.begin().await.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (strategy_id, name)
|
|
VALUES ($1, $2)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Durability Test")
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
} // tx and pool1 dropped here
|
|
|
|
// Create new pool (simulating application restart)
|
|
let pool2 = create_test_pool().await;
|
|
|
|
// Verify config persisted
|
|
let name: Option<String> = sqlx::query_scalar(
|
|
"SELECT name FROM adaptive_strategy_config WHERE strategy_id = $1"
|
|
)
|
|
.bind(test_id)
|
|
.fetch_optional(&pool2)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
name,
|
|
Some("Durability Test".to_string()),
|
|
"Committed changes should persist after connection close"
|
|
);
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool2, test_id).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CATEGORY 3: CONCURRENT UPDATE TESTS
|
|
// ============================================================================
|
|
|
|
/// Test 15: Verify concurrent updates with version checking
|
|
#[tokio::test]
|
|
async fn test_concurrent_updates_with_version() {
|
|
let pool = create_test_pool().await;
|
|
let test_id = "test-concurrent-version";
|
|
|
|
// Create test config with version=1
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (strategy_id, name, version)
|
|
VALUES ($1, $2, 1)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Concurrent Test")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Simulate optimistic locking: two tasks try to update based on version=1
|
|
let pool1 = pool.clone();
|
|
let pool2 = pool.clone();
|
|
let id1 = test_id.to_string();
|
|
let id2 = test_id.to_string();
|
|
|
|
let task1 = tokio::spawn(async move {
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET name = 'Task 1 Update', version = version + 1
|
|
WHERE strategy_id = $1 AND version = 1"
|
|
)
|
|
.bind(&id1)
|
|
.execute(&pool1)
|
|
.await
|
|
});
|
|
|
|
let task2 = tokio::spawn(async move {
|
|
// Small delay to ensure task1 goes first
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET name = 'Task 2 Update', version = version + 1
|
|
WHERE strategy_id = $1 AND version = 1"
|
|
)
|
|
.bind(&id2)
|
|
.execute(&pool2)
|
|
.await
|
|
});
|
|
|
|
let result1 = task1.await.unwrap().unwrap();
|
|
let result2 = task2.await.unwrap().unwrap();
|
|
|
|
// One should succeed (rows_affected=1), one should fail (rows_affected=0)
|
|
assert_eq!(
|
|
result1.rows_affected() + result2.rows_affected(),
|
|
1,
|
|
"Only one update should succeed"
|
|
);
|
|
|
|
// Verify final version is 2
|
|
let version: i32 = sqlx::query_scalar(
|
|
"SELECT version FROM adaptive_strategy_config WHERE strategy_id = $1"
|
|
)
|
|
.bind(test_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(version, 2, "Version should be incremented once");
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool, test_id).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CATEGORY 4: PERFORMANCE TESTS (IGNORED BY DEFAULT)
|
|
// ============================================================================
|
|
|
|
/// Test 18: Measure config loading latency
|
|
#[tokio::test]
|
|
async fn test_config_load_latency_benchmark() {
|
|
let loader = create_loader().await;
|
|
|
|
let mut latencies = Vec::new();
|
|
|
|
// Load config 100 times
|
|
for _ in 0..100 {
|
|
let start = std::time::Instant::now();
|
|
let _ = loader.load_config("default-production").await.unwrap();
|
|
let elapsed = start.elapsed();
|
|
latencies.push(elapsed);
|
|
}
|
|
|
|
// Calculate percentiles
|
|
latencies.sort();
|
|
let p50 = latencies[50];
|
|
let p95 = latencies[95];
|
|
let p99 = latencies[99];
|
|
|
|
println!("Config load latency:");
|
|
println!(" p50: {:?}", p50);
|
|
println!(" p95: {:?}", p95);
|
|
println!(" p99: {:?}", p99);
|
|
|
|
// Assert p99 < 100ms (reasonable for database query)
|
|
assert!(
|
|
p99 < Duration::from_millis(100),
|
|
"p99 latency should be < 100ms, got {:?}",
|
|
p99
|
|
);
|
|
}
|
|
|
|
/// Test 20: Measure notification propagation delay
|
|
#[tokio::test]
|
|
async fn test_notification_propagation_delay() {
|
|
let mut loader = create_loader().await;
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
let pool = create_test_pool().await;
|
|
|
|
// Measure 10 notification propagation delays
|
|
let mut delays = Vec::new();
|
|
|
|
for i in 0..10 {
|
|
let start = std::time::Instant::now();
|
|
|
|
// Trigger update
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config
|
|
SET description = $1
|
|
WHERE strategy_id = 'default-production'"
|
|
)
|
|
.bind(format!("Delay test {}", i))
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Wait for notification
|
|
let _ = wait_for_notification(&mut loader, 5).await.unwrap();
|
|
|
|
let elapsed = start.elapsed();
|
|
delays.push(elapsed);
|
|
}
|
|
|
|
// Calculate average delay
|
|
let avg_delay: Duration = delays.iter().sum::<Duration>() / delays.len() as u32;
|
|
|
|
println!("Notification propagation delay: {:?}", avg_delay);
|
|
|
|
// Assert average delay < 100ms
|
|
assert!(
|
|
avg_delay < Duration::from_millis(100),
|
|
"Average notification delay should be < 100ms, got {:?}",
|
|
avg_delay
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CATEGORY 5: EDGE CASES & FAILURE MODES
|
|
// ============================================================================
|
|
|
|
/// Test 22: Handle malformed notification payload gracefully
|
|
#[tokio::test]
|
|
async fn test_malformed_notification_payload() {
|
|
let mut loader = create_loader().await;
|
|
loader.enable_hot_reload().await.unwrap();
|
|
|
|
let pool = create_test_pool().await;
|
|
|
|
// Send malformed notification directly
|
|
sqlx::query("SELECT pg_notify('adaptive_strategy_config_change', 'invalid-json')")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// check_for_updates should handle gracefully (return None or skip)
|
|
let result = wait_for_notification(&mut loader, 2).await;
|
|
|
|
// Should not panic, either returns None or times out
|
|
assert!(result.is_ok(), "Should handle malformed payload gracefully");
|
|
}
|
|
|
|
/// Test 24: Verify config persists during service restart simulation
|
|
#[tokio::test]
|
|
async fn test_config_update_during_service_restart() {
|
|
let pool = create_test_pool().await;
|
|
let test_id = "test-service-restart";
|
|
|
|
// Create config
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (strategy_id, name)
|
|
VALUES ($1, $2)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Original Name")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Update config
|
|
sqlx::query(
|
|
"UPDATE adaptive_strategy_config SET name = 'Updated Name'
|
|
WHERE strategy_id = $1"
|
|
)
|
|
.bind(test_id)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Immediately close and create new loader (simulates restart)
|
|
drop(pool);
|
|
|
|
let new_loader = create_loader().await;
|
|
let config = new_loader.load_config(test_id).await.unwrap().unwrap();
|
|
|
|
assert_eq!(
|
|
config.name, "Updated Name",
|
|
"Config should reflect latest update after restart"
|
|
);
|
|
|
|
// Cleanup
|
|
let pool = create_test_pool().await;
|
|
cleanup_test_strategy(&pool, test_id).await;
|
|
}
|
|
|
|
/// Test 25: Verify partial transaction failure causes complete rollback
|
|
#[tokio::test]
|
|
async fn test_partial_transaction_failure() {
|
|
let pool = create_test_pool().await;
|
|
let test_id = "test-partial-failure";
|
|
|
|
// Create test config
|
|
sqlx::query(
|
|
"INSERT INTO adaptive_strategy_config (strategy_id, name)
|
|
VALUES ($1, $2)"
|
|
)
|
|
.bind(test_id)
|
|
.bind("Partial Failure Test")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let config_id: uuid::Uuid = sqlx::query_scalar(
|
|
"SELECT id FROM adaptive_strategy_config WHERE strategy_id = $1"
|
|
)
|
|
.bind(test_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Start transaction
|
|
let mut tx = pool.begin().await.unwrap();
|
|
|
|
// Step 1: Update config (succeeds)
|
|
sqlx::query("UPDATE adaptive_strategy_config SET name = 'Updated' WHERE id = $1")
|
|
.bind(config_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Step 2: Insert model with invalid foreign key (fails)
|
|
let invalid_uuid = uuid::Uuid::new_v4();
|
|
let result = sqlx::query(
|
|
"INSERT INTO adaptive_strategy_models (
|
|
strategy_config_id, model_id, model_name, model_type, parameters, initial_weight
|
|
) VALUES ($1, $2, $3, $4, $5, $6)"
|
|
)
|
|
.bind(invalid_uuid) // Invalid foreign key
|
|
.bind("test-model")
|
|
.bind("Test Model")
|
|
.bind("mamba2")
|
|
.bind(json!({}))
|
|
.bind(0.5)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Should fail on foreign key constraint");
|
|
|
|
// Transaction auto-rolls back
|
|
drop(tx);
|
|
|
|
// Verify config unchanged
|
|
let name: String = sqlx::query_scalar(
|
|
"SELECT name FROM adaptive_strategy_config WHERE id = $1"
|
|
)
|
|
.bind(config_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
name, "Partial Failure Test",
|
|
"Config should be unchanged after transaction rollback"
|
|
);
|
|
|
|
// Cleanup
|
|
cleanup_test_strategy(&pool, test_id).await;
|
|
}
|