✅ Validation Results: - PPO training: 24.2s (1 epoch, 950 samples, dim=225) - Feature extraction: 105μs/bar (9.5x faster than target) - Model checkpoint: 293KB (147KB actor + 146KB critic) - GPU memory: 145MB used (96.4% headroom) - Zero dimension mismatches 📊 Success Criteria (5/5): ✅ Feature dimension = 225 (Wave C 201 + Wave D 24) ✅ Model state_dim = 225 ✅ Training completed without errors ✅ Checkpoint saved successfully ✅ No dimension mismatch errors 📁 Training Data Ready: - ES.FUT: 2.9MB, 180 days - NQ.FUT: 4.4MB, 180 days - 6E.FUT: 2.8MB, 180 days - ZN.FUT: 65KB, 90 days (clean) 🚀 Next: Full production model retraining (4 models, ~10min GPU time) 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
525 lines
16 KiB
Rust
525 lines
16 KiB
Rust
//! Database Integration Tests
|
|
//!
|
|
//! Tests migration 046 schema validation, foreign key cascades, triggers, and indexes
|
|
//!
|
|
//! **Test Coverage:**
|
|
//! - Migration 046 schema validation
|
|
//! - Parent-child foreign key cascade
|
|
//! - Trigger-based batch aggregation
|
|
//! - Concurrent transaction isolation
|
|
//! - Index performance validation (<10ms queries)
|
|
|
|
use std::time::Instant;
|
|
|
|
use sqlx::PgPool;
|
|
use std::time::Duration;
|
|
use uuid::Uuid;
|
|
|
|
/// Create test database pool
|
|
async fn create_test_pool() -> PgPool {
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
PgPool::connect(&database_url)
|
|
.await
|
|
.expect("Failed to connect to database")
|
|
}
|
|
|
|
/// Helper to create a test batch job
|
|
async fn create_test_batch(pool: &PgPool, name: &str) -> Uuid {
|
|
let id = Uuid::new_v4();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO batch_jobs (id, name, description, status, config_json)
|
|
VALUES ($1, $2, $3, 'Pending', '{}'::jsonb)"
|
|
)
|
|
.bind(id)
|
|
.bind(name)
|
|
.bind("Test batch job")
|
|
.execute(pool)
|
|
.await
|
|
.expect("Failed to create batch job");
|
|
|
|
id
|
|
}
|
|
|
|
/// Helper to create a test child job
|
|
async fn create_test_child(pool: &PgPool, batch_id: Uuid, model_type: &str) -> Uuid {
|
|
let id = Uuid::new_v4();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO child_jobs (id, batch_id, model_type, status, total_epochs, config_json)
|
|
VALUES ($1, $2, $3, 'Pending', 10, '{}'::jsonb)"
|
|
)
|
|
.bind(id)
|
|
.bind(batch_id)
|
|
.bind(model_type)
|
|
.execute(pool)
|
|
.await
|
|
.expect("Failed to create child job");
|
|
|
|
id
|
|
}
|
|
|
|
/// Clean up test data
|
|
async fn cleanup_test_data(pool: &PgPool, batch_id: Uuid) {
|
|
// Delete batch (cascade should handle children)
|
|
sqlx::query("DELETE FROM batch_jobs WHERE id = $1")
|
|
.bind(batch_id)
|
|
.execute(pool)
|
|
.await
|
|
.expect("Failed to cleanup batch");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 1: Migration 046 schema validation
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_migration_046_schema() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Verify batch_jobs table exists
|
|
let batch_table_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_name = 'batch_jobs'
|
|
)"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to check batch_jobs table");
|
|
|
|
assert!(batch_table_exists, "batch_jobs table does not exist");
|
|
println!("✅ batch_jobs table exists");
|
|
|
|
// Verify child_jobs table exists
|
|
let child_table_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_name = 'child_jobs'
|
|
)"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to check child_jobs table");
|
|
|
|
assert!(child_table_exists, "child_jobs table does not exist");
|
|
println!("✅ child_jobs table exists");
|
|
|
|
// Verify batch_jobs columns
|
|
let batch_columns: Vec<String> = sqlx::query_scalar(
|
|
"SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'batch_jobs'
|
|
ORDER BY column_name"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to fetch batch_jobs columns");
|
|
|
|
let expected_batch_columns = vec![
|
|
"completed_at", "completed_jobs", "config_json", "created_at",
|
|
"description", "failed_jobs", "id", "name", "overall_progress",
|
|
"pending_jobs", "running_jobs", "started_at", "status", "total_jobs"
|
|
];
|
|
|
|
for col in &expected_batch_columns {
|
|
assert!(
|
|
batch_columns.contains(&col.to_string()),
|
|
"Missing batch_jobs column: {}",
|
|
col
|
|
);
|
|
}
|
|
|
|
println!("✅ All batch_jobs columns present: {}", batch_columns.len());
|
|
|
|
// Verify child_jobs columns
|
|
let child_columns: Vec<String> = sqlx::query_scalar(
|
|
"SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'child_jobs'
|
|
ORDER BY column_name"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to fetch child_jobs columns");
|
|
|
|
let expected_child_columns = vec![
|
|
"batch_id", "completed_at", "config_json", "created_at", "current_epoch",
|
|
"error_message", "id", "model_type", "model_weight", "progress_pct",
|
|
"started_at", "status", "total_epochs", "updated_at"
|
|
];
|
|
|
|
for col in &expected_child_columns {
|
|
assert!(
|
|
child_columns.contains(&col.to_string()),
|
|
"Missing child_jobs column: {}",
|
|
col
|
|
);
|
|
}
|
|
|
|
println!("✅ All child_jobs columns present: {}", child_columns.len());
|
|
|
|
// Verify constraints
|
|
let constraints: Vec<String> = sqlx::query_scalar(
|
|
"SELECT constraint_name FROM information_schema.table_constraints
|
|
WHERE table_name IN ('batch_jobs', 'child_jobs')
|
|
AND constraint_type = 'CHECK'"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to fetch constraints");
|
|
|
|
assert!(constraints.len() >= 5, "Expected at least 5 CHECK constraints");
|
|
println!("✅ CHECK constraints validated: {}", constraints.len());
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Parent-child foreign key cascade
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_foreign_key_cascade() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create a batch with children
|
|
let batch_id = create_test_batch(&pool, "cascade_test").await;
|
|
let child1_id = create_test_child(&pool, batch_id, "DQN").await;
|
|
let child2_id = create_test_child(&pool, batch_id, "PPO").await;
|
|
|
|
println!("✅ Created batch {} with 2 children", batch_id);
|
|
|
|
// Verify children exist
|
|
let child_count: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to count children");
|
|
|
|
assert_eq!(child_count, 2);
|
|
println!("✅ Verified 2 children exist");
|
|
|
|
// Delete parent (should cascade to children)
|
|
sqlx::query("DELETE FROM batch_jobs WHERE id = $1")
|
|
.bind(batch_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to delete batch");
|
|
|
|
println!("✅ Deleted parent batch");
|
|
|
|
// Verify children are gone (cascade)
|
|
let remaining_children: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM child_jobs WHERE batch_id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to count remaining children");
|
|
|
|
assert_eq!(remaining_children, 0);
|
|
println!("✅ CASCADE delete validated: children removed");
|
|
|
|
// Verify specific children don't exist
|
|
let child1_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (SELECT 1 FROM child_jobs WHERE id = $1)"
|
|
)
|
|
.bind(child1_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to check child1");
|
|
|
|
let child2_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (SELECT 1 FROM child_jobs WHERE id = $1)"
|
|
)
|
|
.bind(child2_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to check child2");
|
|
|
|
assert!(!child1_exists && !child2_exists);
|
|
println!("✅ Both children cascade-deleted successfully");
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: Trigger-based batch aggregation
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_trigger_batch_aggregation() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create batch
|
|
let batch_id = create_test_batch(&pool, "aggregation_test").await;
|
|
|
|
// Create 4 children (DQN, PPO, MAMBA-2, TFT)
|
|
let models = vec!["DQN", "PPO", "MAMBA-2", "TFT"];
|
|
for model in &models {
|
|
create_test_child(&pool, batch_id, model).await;
|
|
}
|
|
|
|
println!("✅ Created batch with 4 children");
|
|
|
|
// Verify batch aggregates (trigger should update)
|
|
let batch: (i32, i32, i32, i32, i32, f64) = sqlx::query_as(
|
|
"SELECT total_jobs, pending_jobs, running_jobs, completed_jobs, failed_jobs, overall_progress
|
|
FROM batch_jobs WHERE id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to fetch batch");
|
|
|
|
assert_eq!(batch.0, 4); // total_jobs
|
|
assert_eq!(batch.1, 4); // pending_jobs
|
|
assert_eq!(batch.2, 0); // running_jobs
|
|
assert_eq!(batch.3, 0); // completed_jobs
|
|
assert_eq!(batch.4, 0); // failed_jobs
|
|
println!("✅ Initial aggregates: total=4, pending=4");
|
|
|
|
// Update one child to Running
|
|
sqlx::query(
|
|
"UPDATE child_jobs
|
|
SET status = 'Running', progress_pct = 50.0
|
|
WHERE batch_id = $1 AND model_type = 'DQN'"
|
|
)
|
|
.bind(batch_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to update child");
|
|
|
|
// Verify trigger updated batch
|
|
let batch_after: (String, i32, i32, f64) = sqlx::query_as(
|
|
"SELECT status, pending_jobs, running_jobs, overall_progress
|
|
FROM batch_jobs WHERE id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to fetch batch");
|
|
|
|
assert_eq!(batch_after.0, "Running"); // status
|
|
assert_eq!(batch_after.1, 3); // pending_jobs (3 left)
|
|
assert_eq!(batch_after.2, 1); // running_jobs (1 running)
|
|
assert!(batch_after.3 > 0.0); // overall_progress > 0
|
|
println!("✅ Trigger updated batch: status=Running, progress={:.1}%", batch_after.3);
|
|
|
|
// Complete all children
|
|
sqlx::query(
|
|
"UPDATE child_jobs
|
|
SET status = 'Completed', progress_pct = 100.0
|
|
WHERE batch_id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("Failed to complete children");
|
|
|
|
// Verify batch is Completed
|
|
let final_batch: (String, i32, f64) = sqlx::query_as(
|
|
"SELECT status, completed_jobs, overall_progress
|
|
FROM batch_jobs WHERE id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to fetch final batch");
|
|
|
|
assert_eq!(final_batch.0, "Completed");
|
|
assert_eq!(final_batch.1, 4); // all completed
|
|
assert_eq!(final_batch.2, 100.0); // 100% progress
|
|
println!("✅ Batch completion detected: status=Completed, progress=100.0%");
|
|
|
|
cleanup_test_data(&pool, batch_id).await;
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Concurrent transaction isolation
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_transactions() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create batch
|
|
let batch_id = create_test_batch(&pool, "concurrent_test").await;
|
|
|
|
// Create 2 children
|
|
let child1_id = create_test_child(&pool, batch_id, "DQN").await;
|
|
let child2_id = create_test_child(&pool, batch_id, "PPO").await;
|
|
|
|
println!("✅ Created batch with 2 children");
|
|
|
|
// Spawn concurrent updates
|
|
let pool1 = pool.clone();
|
|
let pool2 = pool.clone();
|
|
|
|
let handle1 = tokio::spawn(async move {
|
|
// Transaction 1: Update child1
|
|
let mut tx = pool1.begin().await.expect("Failed to begin tx1");
|
|
|
|
sqlx::query(
|
|
"UPDATE child_jobs
|
|
SET status = 'Running', progress_pct = 25.0
|
|
WHERE id = $1"
|
|
)
|
|
.bind(child1_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.expect("Failed to update child1");
|
|
|
|
// Small delay to test isolation
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
tx.commit().await.expect("Failed to commit tx1");
|
|
});
|
|
|
|
let handle2 = tokio::spawn(async move {
|
|
// Transaction 2: Update child2
|
|
let mut tx = pool2.begin().await.expect("Failed to begin tx2");
|
|
|
|
sqlx::query(
|
|
"UPDATE child_jobs
|
|
SET status = 'Running', progress_pct = 75.0
|
|
WHERE id = $1"
|
|
)
|
|
.bind(child2_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.expect("Failed to update child2");
|
|
|
|
// Small delay to test isolation
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
tx.commit().await.expect("Failed to commit tx2");
|
|
});
|
|
|
|
// Wait for both transactions
|
|
handle1.await.expect("Transaction 1 panicked");
|
|
handle2.await.expect("Transaction 2 panicked");
|
|
|
|
println!("✅ Concurrent transactions completed");
|
|
|
|
// Verify both updates succeeded
|
|
let children: Vec<(String, f64)> = sqlx::query_as(
|
|
"SELECT status, progress_pct FROM child_jobs
|
|
WHERE batch_id = $1
|
|
ORDER BY model_type"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to fetch children");
|
|
|
|
assert_eq!(children.len(), 2);
|
|
assert_eq!(children[0].0, "Running"); // DQN
|
|
assert_eq!(children[0].1, 25.0);
|
|
assert_eq!(children[1].0, "Running"); // PPO
|
|
assert_eq!(children[1].1, 75.0);
|
|
|
|
println!("✅ Transaction isolation validated: both updates persisted");
|
|
|
|
cleanup_test_data(&pool, batch_id).await;
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Index performance validation (<10ms queries)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_index_performance() {
|
|
let pool = create_test_pool().await;
|
|
|
|
// Create test data
|
|
let batch_id = create_test_batch(&pool, "perf_test").await;
|
|
for model in &["DQN", "PPO", "MAMBA-2", "TFT"] {
|
|
create_test_child(&pool, batch_id, model).await;
|
|
}
|
|
|
|
println!("✅ Created test data");
|
|
|
|
// Test 1: Query by batch status (should use idx_batch_jobs_status)
|
|
let start = Instant::now();
|
|
let _batches: Vec<String> = sqlx::query_scalar(
|
|
"SELECT name FROM batch_jobs WHERE status = 'Pending'"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to query batches");
|
|
let duration1 = start.elapsed();
|
|
|
|
assert!(duration1 < Duration::from_millis(10), "Query 1 too slow: {:?}", duration1);
|
|
println!("✅ Batch status query: {:?} (<10ms)", duration1);
|
|
|
|
// Test 2: Query by child batch_id (should use idx_child_jobs_batch_id)
|
|
let start = Instant::now();
|
|
let _children: Vec<String> = sqlx::query_scalar(
|
|
"SELECT model_type FROM child_jobs WHERE batch_id = $1"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to query children");
|
|
let duration2 = start.elapsed();
|
|
|
|
assert!(duration2 < Duration::from_millis(10), "Query 2 too slow: {:?}", duration2);
|
|
println!("✅ Child batch_id query: {:?} (<10ms)", duration2);
|
|
|
|
// Test 3: Composite query (should use idx_child_jobs_batch_status)
|
|
let start = Instant::now();
|
|
let _count: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM child_jobs
|
|
WHERE batch_id = $1 AND status = 'Pending'"
|
|
)
|
|
.bind(batch_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to count children");
|
|
let duration3 = start.elapsed();
|
|
|
|
assert!(duration3 < Duration::from_millis(10), "Query 3 too slow: {:?}", duration3);
|
|
println!("✅ Composite batch+status query: {:?} (<10ms)", duration3);
|
|
|
|
// Test 4: JSONB query (should use GIN index)
|
|
let start = Instant::now();
|
|
let _batches: Vec<String> = sqlx::query_scalar(
|
|
"SELECT name FROM batch_jobs
|
|
WHERE config_json @> '{}'::jsonb"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to query JSONB");
|
|
let duration4 = start.elapsed();
|
|
|
|
assert!(duration4 < Duration::from_millis(10), "Query 4 too slow: {:?}", duration4);
|
|
println!("✅ JSONB GIN index query: {:?} (<10ms)", duration4);
|
|
|
|
// Test 5: ORDER BY created_at (should use idx_batch_jobs_created_at)
|
|
let start = Instant::now();
|
|
let _batches: Vec<String> = sqlx::query_scalar(
|
|
"SELECT name FROM batch_jobs
|
|
ORDER BY created_at DESC
|
|
LIMIT 10"
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.expect("Failed to query ordered batches");
|
|
let duration5 = start.elapsed();
|
|
|
|
assert!(duration5 < Duration::from_millis(10), "Query 5 too slow: {:?}", duration5);
|
|
println!("✅ ORDER BY created_at query: {:?} (<10ms)", duration5);
|
|
|
|
println!("✅ All index performance tests passed (<10ms)");
|
|
|
|
cleanup_test_data(&pool, batch_id).await;
|
|
pool.close().await;
|
|
}
|