use std::time::Duration;
use serde_json::json;
use tokio::sync::watch;
use sync_engine::{
SyncEngine, SyncEngineConfig, SyncItem, EngineState, BackpressureLevel,
};
use testcontainers::{clients::Cli, Container, GenericImage, core::WaitFor};
fn redis_container(docker: &Cli) -> Container<'_, GenericImage> {
let image = GenericImage::new("redis/redis-stack-server", "latest")
.with_exposed_port(6379)
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"));
docker.run(image)
}
fn mysql_container(docker: &Cli) -> Container<'_, GenericImage> {
let image = GenericImage::new("mysql", "8.0")
.with_env_var("MYSQL_ROOT_PASSWORD", "test")
.with_env_var("MYSQL_DATABASE", "test")
.with_env_var("MYSQL_USER", "test")
.with_env_var("MYSQL_PASSWORD", "test")
.with_exposed_port(3306)
.with_wait_for(WaitFor::message_on_stderr("ready for connections"));
docker.run(image)
}
fn test_item(id: &str) -> SyncItem {
SyncItem::from_json(id.to_string(), json!({"test": "data", "id": id}))
}
fn unique_wal_path(name: &str) -> String {
format!("./temp/test_wal_{}_{}.db", name, uuid::Uuid::new_v4())
}
fn cleanup_wal_files(wal_path: &str) {
let _ = std::fs::remove_file(wal_path);
let _ = std::fs::remove_file(format!("{}-shm", wal_path));
let _ = std::fs::remove_file(format!("{}-wal", wal_path));
}
#[tokio::test]
#[ignore] async fn happy_engine_lifecycle_with_redis() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("lifecycle");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
assert_eq!(engine.state(), EngineState::Created);
engine.start().await.expect("Failed to start engine");
assert!(engine.is_ready());
for i in 0..10 {
let item = SyncItem::from_json(
format!("uk.nhs.patient.record.{}", i),
json!({
"name": format!("Patient {}", i),
"nhs_number": format!("{:010}", i),
}),
);
engine.submit(item).await.expect("Failed to submit item");
}
let (count, _bytes) = engine.l1_stats();
assert_eq!(count, 10);
let item = engine.get("uk.nhs.patient.record.5").await
.expect("Failed to get item")
.expect("Item not found");
assert_eq!(item.object_id, "uk.nhs.patient.record.5");
assert_eq!(engine.pressure(), BackpressureLevel::Normal);
engine.shutdown().await;
assert_eq!(engine.state(), EngineState::ShuttingDown);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_batch_flush_to_redis() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("batch");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
batch_flush_ms: 50,
batch_flush_count: 5,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
for i in 0..20 {
engine.submit(test_item(&format!("batch-item-{}", i))).await
.expect("Failed to submit");
}
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
let item = engine.get("batch-item-10").await
.expect("Get failed")
.expect("Item not found");
assert_eq!(item.object_id, "batch-item-10");
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_merkle_tree_updates() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("merkle");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
batch_flush_ms: 50,
batch_flush_count: 3,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let ids = [
"uk.nhs.patient.record.001",
"uk.nhs.patient.record.002",
"uk.nhs.doctor.license.001",
"uk.nhs.appointment.slot.001",
];
for id in ids {
engine.submit(SyncItem::from_json(id.to_string(), json!({"id": id}))).await
.expect("Failed to submit");
}
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
for id in ids {
let item = engine.get(id).await
.expect("Get failed")
.unwrap_or_else(|| panic!("Item {} not found", id));
assert_eq!(item.object_id, id);
}
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn failure_redis_unavailable_at_startup() {
let wal_path = unique_wal_path("redis_unavail");
let config = SyncEngineConfig {
redis_url: Some("redis://127.0.0.1:59999".to_string()), sql_url: None,
wal_path: Some(wal_path.clone()),
l1_max_bytes: 10 * 1024 * 1024,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
let result = tokio::time::timeout(
Duration::from_secs(5),
engine.start()
).await;
match result {
Ok(Ok(())) => {
assert!(engine.is_ready());
engine.submit(test_item("no-redis-item")).await
.expect("Should accept writes in L1-only mode");
let item = engine.get("no-redis-item").await
.expect("Get should work")
.expect("Item should be in L1");
assert_eq!(item.object_id, "no-redis-item");
engine.shutdown().await;
}
Ok(Err(e)) => {
println!("Engine start failed (expected): {:?}", e);
}
Err(_) => {
println!("Connection timed out (expected for non-existent port)");
}
}
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn failure_redis_dies_mid_operation() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("redis_dies");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
wal_path: Some(wal_path.clone()),
batch_flush_ms: 50,
batch_flush_count: 3,
l1_max_bytes: 10 * 1024 * 1024,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Should start with Redis");
for i in 0..5 {
engine.submit(test_item(&format!("before-kill-{}", i))).await
.expect("Should write before Redis death");
}
tokio::time::sleep(Duration::from_millis(100)).await;
drop(redis);
tokio::time::sleep(Duration::from_millis(100)).await;
for i in 0..5 {
engine.submit(test_item(&format!("after-kill-{}", i))).await
.expect("Should accept writes even with dead Redis (L1 fallback)");
}
let item = engine.get("after-kill-2").await
.expect("Get should work")
.expect("Item should be in L1");
assert_eq!(item.object_id, "after-kill-2");
let _ = tokio::time::timeout(Duration::from_secs(2), engine.shutdown()).await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn failure_filter_consistency_on_failed_write() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("filter_consistency");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
wal_path: Some(wal_path.clone()),
batch_flush_ms: 10000, batch_flush_count: 100, l1_max_bytes: 10 * 1024 * 1024,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Should start");
let (l3_before, _, _) = engine.l3_filter_stats();
engine.submit(test_item("filter-test-item")).await.unwrap();
let (l3_after_submit, _, _) = engine.l3_filter_stats();
assert_eq!(l3_before, l3_after_submit, "Filter should not update on submit");
drop(redis);
tokio::time::sleep(Duration::from_millis(100)).await;
engine.force_flush().await;
let (l3_after_failed_flush, _, _) = engine.l3_filter_stats();
assert_eq!(l3_before, l3_after_failed_flush,
"Filter should not update on failed flush");
let _ = tokio::time::timeout(Duration::from_secs(2), engine.shutdown()).await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn failure_concurrent_writes_during_redis_outage() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("concurrent_fail");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
wal_path: Some(wal_path.clone()),
batch_flush_ms: 50,
batch_flush_count: 10,
l1_max_bytes: 10 * 1024 * 1024,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Should start");
let engine = std::sync::Arc::new(tokio::sync::Mutex::new(engine));
let mut handles = vec![];
for writer_id in 0..5 {
let engine = engine.clone();
handles.push(tokio::spawn(async move {
for i in 0..10 {
let item = test_item(&format!("writer-{}-item-{}", writer_id, i));
let eng = engine.lock().await;
let _ = eng.submit(item).await; drop(eng);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
drop(redis);
for handle in handles {
handle.await.unwrap();
}
let eng = engine.lock().await;
assert!(eng.is_ready(), "Engine should remain ready after Redis death");
let _ = tokio::time::timeout(Duration::from_secs(2), eng.shutdown()).await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn failure_mysql_unavailable_triggers_wal() {
let wal_path = unique_wal_path("mysql_wal");
let config = SyncEngineConfig {
redis_url: None,
sql_url: Some("mysql://test:test@127.0.0.1:59999/test".to_string()), wal_path: Some(wal_path.clone()),
batch_flush_ms: 50,
batch_flush_count: 2,
l1_max_bytes: 10 * 1024 * 1024,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
let result = tokio::time::timeout(
Duration::from_secs(5),
engine.start()
).await;
match result {
Ok(Ok(())) => {
assert!(engine.is_ready());
engine.submit(test_item("wal-fallback-item")).await
.expect("Should accept writes via WAL fallback");
engine.shutdown().await;
assert!(std::path::Path::new(&wal_path).exists(), "WAL file should exist");
}
Ok(Err(_e)) => {
println!("Engine correctly requires MySQL at startup (ground-truth design)");
}
Err(_) => {
println!("MySQL connection timed out (expected for non-existent port)");
}
}
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_full_stack_lifecycle() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
println!("Starting MySQL container (this takes ~30s)...");
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("full_stack");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: Some(format!("mysql://test:test@127.0.0.1:{}/test", mysql_port)),
l1_max_bytes: 10 * 1024 * 1024,
batch_flush_ms: 50,
batch_flush_count: 5,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
let start_result = engine.start().await;
if start_result.is_err() {
println!("Could not connect to MySQL: {:?}", start_result);
println!("(This is expected if MySQL needs schema setup)");
drop(mysql);
drop(redis);
return;
}
assert!(engine.is_ready());
for i in 0..10 {
engine.submit(test_item(&format!("full-stack-item-{}", i))).await
.expect("Failed to submit");
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let (l3_entries, _, _) = engine.l3_filter_stats();
println!("L3 (MySQL) filter: {} entries", l3_entries);
let item = engine.get("full-stack-item-5").await
.expect("Get failed")
.expect("Item not found");
assert_eq!(item.object_id, "full-stack-item-5");
engine.shutdown().await;
drop(mysql);
drop(redis);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_redis_lookup_miss() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("redis_miss");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start");
let result = engine.get("nonexistent.item.never.submitted").await
.expect("Get should not error");
assert!(result.is_none(), "Should return None for non-existent item");
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_l3_fallback_retrieval() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("l3_fallback");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: Some(format!("mysql://test:test@127.0.0.1:{}/test", mysql_port)),
l1_max_bytes: 1024, batch_flush_ms: 50,
batch_flush_count: 2,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
if engine.start().await.is_err() {
println!("MySQL not ready, skipping test");
return;
}
for i in 0..5 {
engine.submit(test_item(&format!("l3-test-{}", i))).await.ok();
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
for i in 100..200 {
engine.submit(test_item(&format!("evict-{}", i))).await.ok();
}
let _result = engine.get("l3-test-2").await;
engine.shutdown().await;
drop(mysql);
drop(redis);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_high_pressure_eviction() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("pressure");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 5 * 1024, batch_flush_ms: 50,
batch_flush_count: 10,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start");
for i in 0..100 {
let item = SyncItem::from_json(
format!("pressure-test-{}", i),
json!({
"data": "x".repeat(200), "index": i
}),
);
let _ = engine.submit(item).await; }
let pressure = engine.pressure();
println!("Pressure level: {:?}", pressure);
let can_write = engine.should_accept_writes();
println!("Should accept writes: {}", can_write);
engine.force_flush().await;
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_merkle_batch_apply_and_diff() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("merkle_diff");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
batch_flush_ms: 20,
batch_flush_count: 2,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start");
let paths = [
"org.example.users.active.user001",
"org.example.users.active.user002",
"org.example.users.inactive.user003",
"org.example.orders.pending.order001",
"org.example.orders.completed.order002",
];
for path in &paths {
engine.submit(test_item(path)).await.expect("Submit failed");
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
engine.submit(test_item("org.example.users.active.user004")).await.ok();
engine.force_flush().await;
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_merkle_delete_operations() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("merkle_delete");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
batch_flush_ms: 20,
batch_flush_count: 2,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start");
engine.submit(test_item("delete.test.item1")).await.ok();
engine.submit(test_item("delete.test.item2")).await.ok();
engine.force_flush().await;
let deleted = engine.delete("delete.test.item1").await.expect("Delete failed");
assert!(deleted, "Item should have been deleted");
let result = engine.get("delete.test.item1").await.expect("Get failed");
assert!(result.is_none(), "Deleted item should not be found");
let deleted_again = engine.delete("delete.test.nonexistent").await.expect("Delete failed");
println!("Delete non-existent returned: {}", deleted_again);
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_sql_batch_operations() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("sql_batch");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: Some(format!("mysql://test:test@127.0.0.1:{}/test", mysql_port)),
l1_max_bytes: 50 * 1024 * 1024,
batch_flush_ms: 30,
batch_flush_count: 5, wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
if engine.start().await.is_err() {
println!("MySQL not ready, skipping");
return;
}
for i in 0..15 {
engine.submit(test_item(&format!("sql-batch-{}", i))).await.ok();
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(300)).await;
let (l3_entries, _, _) = engine.l3_filter_stats();
println!("L3 entries after batch: {}", l3_entries);
engine.shutdown().await;
drop(mysql);
drop(redis);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_wal_drain_on_startup() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("wal_drain");
{
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None, wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Start failed");
for i in 0..5 {
engine.submit(test_item(&format!("wal-item-{}", i))).await.ok();
}
engine.force_flush().await;
engine.shutdown().await;
}
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
{
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: Some(format!("mysql://test:test@127.0.0.1:{}/test", mysql_port)),
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
if engine.start().await.is_ok() {
println!("Engine started with WAL drain");
engine.shutdown().await;
}
}
drop(mysql);
drop(redis);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_wal_pressure_threshold() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("wal_pressure");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
wal_path: Some(wal_path.clone()),
wal_max_items: Some(10), ..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Start failed");
for i in 0..12 {
let _ = engine.submit(test_item(&format!("wal-pressure-{}", i))).await;
}
engine.force_flush().await;
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_filter_persistence_restore() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("filter_persist");
let redis_url = format!("redis://127.0.0.1:{}", redis_port);
let sql_url = format!("mysql://test:test@127.0.0.1:{}/test", mysql_port);
{
let config = SyncEngineConfig {
redis_url: Some(redis_url.clone()),
sql_url: Some(sql_url.clone()),
wal_path: Some(wal_path.clone()),
batch_flush_ms: 20,
batch_flush_count: 2,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
if engine.start().await.is_err() {
println!("MySQL not ready, skipping");
drop(mysql);
drop(redis);
return;
}
for i in 0..10 {
engine.submit(test_item(&format!("persist-{}", i))).await.ok();
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let (l3_entries, _, _) = engine.l3_filter_stats();
println!("L2 entries before shutdown: {}", l3_entries);
engine.shutdown().await;
}
{
let config = SyncEngineConfig {
redis_url: Some(redis_url.clone()),
sql_url: Some(sql_url.clone()),
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
if engine.start().await.is_ok() {
let (l3_entries, _, trust) = engine.l3_filter_stats();
println!("L2 entries after restart: {}, trust: {:?}", l3_entries, trust);
engine.shutdown().await;
}
}
drop(mysql);
drop(redis);
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_redis_exists_batch() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("redis_exists");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
batch_flush_ms: 20,
batch_flush_count: 3,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Start failed");
for i in 0..10 {
engine.submit(test_item(&format!("exists-{}", i))).await.ok();
}
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let item = engine.get("exists-5").await
.expect("Get should work")
.expect("Item should be found");
assert_eq!(item.object_id, "exists-5");
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_engine_state_transitions() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("state_transitions");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
let mut state_rx = engine.state_receiver();
assert_eq!(engine.state(), EngineState::Created);
assert!(!engine.is_ready());
engine.start().await.expect("Start failed");
let current_state = engine.state();
assert!(matches!(current_state, EngineState::Ready | EngineState::Running));
assert!(engine.is_ready());
engine.submit(test_item("state-test")).await.expect("Submit should work");
engine.shutdown().await;
assert_eq!(engine.state(), EngineState::ShuttingDown);
assert!(!engine.is_ready());
let _ = state_rx.changed().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_redis_json_storage() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("redis_json");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
redis_prefix: Some("test:".to_string()),
sql_url: None,
batch_flush_ms: 50,
batch_flush_count: 2,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Start failed");
let items = vec![
("user.alice", json!({"name": "Alice", "role": "admin", "requests": 42000})),
("user.bob", json!({"name": "Bob", "role": "user", "requests": 100})),
("config.app", json!({"debug": false, "version": "1.0.0"})),
];
for (id, payload) in &items {
let item = SyncItem::from_json(id.to_string(), payload.clone());
engine.submit(item).await.expect("Submit failed");
}
tokio::time::sleep(Duration::from_millis(100)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(50)).await;
for (id, expected_payload) in &items {
let item = engine.get(id).await
.expect("Get failed")
.expect("Item should exist");
assert_eq!(item.object_id, *id);
let payload = item.content_as_json().expect("Should be JSON");
assert_eq!(&payload, expected_payload);
}
let client = redis::Client::open(format!("redis://127.0.0.1:{}", redis_port)).unwrap();
let mut conn = redis::aio::ConnectionManager::new(client).await.unwrap();
let keys: Vec<String> = redis::cmd("KEYS")
.arg("test:user.*")
.query_async(&mut conn)
.await
.expect("KEYS failed");
assert!(!keys.is_empty(), "Should have test:user.* keys in Redis");
for key in &keys {
let key_type: String = redis::cmd("TYPE")
.arg(key)
.query_async(&mut conn)
.await
.expect("TYPE failed");
assert_eq!(key_type, "ReJSON-RL", "Key {} should be RedisJSON type", key);
}
engine.shutdown().await;
let _ = std::fs::remove_file(&wal_path);
}
#[tokio::test]
#[ignore] async fn coverage_merkle_with_prefix() {
use sync_engine::merkle::RedisMerkleStore;
use redis::Client;
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let redis_url = format!("redis://127.0.0.1:{}", redis_port);
let client = Client::open(redis_url.as_str()).expect("Redis client failed");
let conn = redis::aio::ConnectionManager::new(client).await.expect("Connection failed");
let merkle = RedisMerkleStore::with_prefix(conn.clone(), Some("myapp:"));
use sync_engine::merkle::{MerkleBatch, PathMerkle};
let mut batch = MerkleBatch::new();
let hash = PathMerkle::leaf_hash("user.test", 1, &[0u8; 32]);
batch.insert("user.test".to_string(), hash);
merkle.apply_batch(&batch).await.expect("Apply batch failed");
let mut raw_conn = conn.clone();
let keys: Vec<String> = redis::cmd("KEYS")
.arg("myapp:merkle:*")
.query_async(&mut raw_conn)
.await
.expect("KEYS failed");
assert!(!keys.is_empty(), "Merkle keys should have myapp: prefix");
for key in &keys {
assert!(key.starts_with("myapp:merkle:"), "Key {} should start with myapp:merkle:", key);
}
let unprefixed: Vec<String> = redis::cmd("KEYS")
.arg("merkle:*")
.query_async(&mut raw_conn)
.await
.expect("KEYS failed");
let truly_unprefixed: Vec<_> = unprefixed.iter()
.filter(|k| !k.starts_with("myapp:"))
.collect();
assert!(truly_unprefixed.is_empty(), "Should have no unprefixed merkle keys: {:?}", truly_unprefixed);
}
#[tokio::test]
#[ignore] async fn happy_state_management() {
let docker = Cli::default();
let redis = redis_container(&docker);
let mysql = mysql_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(30)).await;
let wal_path = unique_wal_path("state_mgmt");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
redis_prefix: Some("state_test:".to_string()),
sql_url: Some(format!(
"mysql://test:test@127.0.0.1:{}/test",
mysql_port
)),
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
use sync_engine::SubmitOptions;
for i in 0..5 {
let item = SyncItem::from_json(
format!("crdt.delta.{}", i),
json!({"operation": "add", "value": i})
).with_state("delta");
engine.submit(item).await.expect("Submit delta failed");
}
for i in 0..3 {
let item = SyncItem::from_json(
format!("crdt.base.{}", i),
json!({"total": i * 10})
).with_state("base");
engine.submit(item).await.expect("Submit base failed");
}
for i in 0..2 {
let item = SyncItem::from_json(
format!("regular.{}", i),
json!({"data": "normal"})
);
engine.submit(item).await.expect("Submit regular failed");
}
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let deltas = engine.get_by_state("delta", 100).await.expect("get_by_state failed");
assert_eq!(deltas.len(), 5, "Should have 5 delta items");
for item in &deltas {
assert_eq!(item.state, "delta");
assert!(item.object_id.starts_with("crdt.delta."));
}
let bases = engine.get_by_state("base", 100).await.expect("get_by_state failed");
assert_eq!(bases.len(), 3, "Should have 3 base items");
let defaults = engine.get_by_state("default", 100).await.expect("get_by_state failed");
assert_eq!(defaults.len(), 2, "Should have 2 default items");
assert_eq!(engine.count_by_state("delta").await.unwrap(), 5);
assert_eq!(engine.count_by_state("base").await.unwrap(), 3);
assert_eq!(engine.count_by_state("default").await.unwrap(), 2);
assert_eq!(engine.count_by_state("nonexistent").await.unwrap(), 0);
let delta_ids = engine.list_state_ids("delta", 100).await.expect("list_state_ids failed");
assert_eq!(delta_ids.len(), 5);
assert!(delta_ids.iter().all(|id| id.starts_with("crdt.delta.")));
let updated = engine.set_state("crdt.delta.0", "merged").await.expect("set_state failed");
assert!(updated, "Should have updated the item");
assert_eq!(engine.count_by_state("delta").await.unwrap(), 4);
assert_eq!(engine.count_by_state("merged").await.unwrap(), 1);
let deleted = engine.delete_by_state("merged").await.expect("delete_by_state failed");
assert_eq!(deleted, 1, "Should have deleted 1 merged item");
assert_eq!(engine.count_by_state("merged").await.unwrap(), 0);
let item = SyncItem::from_json("override.test".into(), json!({"test": true}))
.with_state("original");
engine.submit_with(item, SubmitOptions::default().with_state("overridden"))
.await.expect("submit_with failed");
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(engine.count_by_state("overridden").await.unwrap(), 1);
assert_eq!(engine.count_by_state("original").await.unwrap(), 0);
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_prefix_scan() {
let docker = Cli::default();
let redis = redis_container(&docker);
let mysql = mysql_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(30)).await;
let wal_path = unique_wal_path("prefix_scan");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
redis_prefix: Some("prefix_test:".to_string()),
sql_url: Some(format!(
"mysql://test:test@127.0.0.1:{}/test",
mysql_port
)),
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
for i in 0..3 {
let item = SyncItem::from_json(
format!("delta:user.123:op{:03}", i),
json!({"op": "+1", "ts": i})
).with_state("delta");
engine.submit(item).await.expect("Submit delta failed");
}
for i in 0..2 {
let item = SyncItem::from_json(
format!("delta:user.456:op{:03}", i),
json!({"op": "-1", "ts": i})
).with_state("delta");
engine.submit(item).await.expect("Submit delta failed");
}
let base123 = SyncItem::from_json(
"base:user.123".into(),
json!({"total": 10})
).with_state("base");
engine.submit(base123).await.expect("Submit base failed");
let base456 = SyncItem::from_json(
"base:user.456".into(),
json!({"total": 5})
).with_state("base");
engine.submit(base456).await.expect("Submit base failed");
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let user123_deltas = engine.scan_prefix("delta:user.123:", 100)
.await.expect("scan_prefix failed");
assert_eq!(user123_deltas.len(), 3, "Should have 3 deltas for user.123");
for item in &user123_deltas {
assert!(item.object_id.starts_with("delta:user.123:"));
assert_eq!(item.state, "delta");
}
let user456_deltas = engine.scan_prefix("delta:user.456:", 100)
.await.expect("scan_prefix failed");
assert_eq!(user456_deltas.len(), 2, "Should have 2 deltas for user.456");
let all_deltas = engine.scan_prefix("delta:", 100)
.await.expect("scan_prefix failed");
assert_eq!(all_deltas.len(), 5, "Should have 5 total deltas");
assert_eq!(engine.count_prefix("delta:user.123:").await.unwrap(), 3);
assert_eq!(engine.count_prefix("delta:user.456:").await.unwrap(), 2);
assert_eq!(engine.count_prefix("delta:").await.unwrap(), 5);
assert_eq!(engine.count_prefix("base:").await.unwrap(), 2);
assert_eq!(engine.count_prefix("nonexistent:").await.unwrap(), 0);
let deleted = engine.delete_prefix("delta:user.123:")
.await.expect("delete_prefix failed");
assert_eq!(deleted, 3, "Should have deleted 3 deltas");
assert_eq!(engine.count_prefix("delta:user.123:").await.unwrap(), 0);
assert_eq!(engine.count_prefix("delta:user.456:").await.unwrap(), 2); assert_eq!(engine.count_prefix("base:").await.unwrap(), 2);
let in_cache = engine.scan_prefix("delta:user.123:", 100).await.unwrap();
assert!(in_cache.is_empty(), "Deleted items should not be in cache");
for i in 0..20 {
let item = SyncItem::from_json(
format!("delta:bulk:op{:03}", i),
json!({"op": "test"})
).with_state("delta");
engine.submit(item).await.expect("Submit failed");
}
tokio::time::sleep(Duration::from_millis(200)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let limited = engine.scan_prefix("delta:bulk:", 5).await.unwrap();
assert_eq!(limited.len(), 5, "Should respect limit");
let all_bulk = engine.scan_prefix("delta:bulk:", 100).await.unwrap();
assert_eq!(all_bulk.len(), 20, "Should get all with larger limit");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_search_with_redisearch() {
use sync_engine::search::{SearchIndex, Query};
use sync_engine::coordinator::SearchTier;
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("search");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
batch_flush_ms: 50,
batch_flush_count: 5,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
assert!(engine.is_ready());
let index = SearchIndex::new("users", "crdt:users:")
.text_at("name", "$.payload.name")
.text_at("email", "$.payload.email")
.numeric_sortable_at("age", "$.payload.age")
.tag_at("roles", "$.payload.roles[*]");
engine.create_search_index(index).await
.expect("Failed to create search index");
let users = vec![
("alice", "Alice Smith", "alice@example.com", 28, vec!["admin", "developer"]),
("bob", "Bob Jones", "bob@example.com", 35, vec!["developer"]),
("carol", "Carol White", "carol@example.com", 42, vec!["manager"]),
("dave", "Dave Brown", "dave@example.com", 25, vec!["developer", "intern"]),
("eve", "Eve Davis", "eve@example.com", 31, vec!["admin"]),
];
for (id, name, email, age, roles) in &users {
let item = SyncItem::from_json(
format!("crdt:users:{}", id),
json!({
"name": name,
"email": email,
"age": age,
"roles": roles,
}),
);
engine.submit(item).await.expect("Failed to submit");
}
tokio::time::sleep(Duration::from_millis(100)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(500)).await;
let query = Query::field_eq("name", "Alice Smith");
let results = engine.search_with_options("users", &query, SearchTier::RedisOnly, 100).await
.expect("Search failed");
assert_eq!(results.items.len(), 1, "Should find Alice");
assert!(results.items[0].object_id.contains("alice"));
let query = Query::numeric_range("age", Some(30.0), Some(45.0));
let results = engine.search_with_options("users", &query, SearchTier::RedisOnly, 100).await
.expect("Search failed");
assert_eq!(results.items.len(), 3, "Should find Bob, Carol, Eve (age 30-45)");
let query = Query::tags("roles", vec!["admin".to_string()]);
let results = engine.search_with_options("users", &query, SearchTier::RedisOnly, 100).await
.expect("Search failed");
assert_eq!(results.items.len(), 2, "Should find Alice and Eve (admins)");
let query = Query::tags("roles", vec!["developer".to_string()])
.and(Query::numeric_range("age", Some(30.0), None));
let results = engine.search_with_options("users", &query, SearchTier::RedisOnly, 100).await
.expect("Search failed");
assert_eq!(results.items.len(), 1, "Should find Bob (developer, age >= 30)");
let results = engine.search_raw("users", "@roles:{admin}", 10).await
.expect("Raw search failed");
assert_eq!(results.len(), 2, "Raw query should find 2 admins");
engine.drop_search_index("users").await
.expect("Failed to drop index");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn happy_search_cache_invalidation() {
use sync_engine::search::{SearchIndex, Query};
use sync_engine::coordinator::SearchTier;
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("search_cache");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
batch_flush_ms: 50,
batch_flush_count: 5,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let index = SearchIndex::new("items", "crdt:items:")
.text_at("title", "$.payload.title")
.tag_at("status", "$.payload.status");
engine.create_search_index(index).await
.expect("Failed to create index");
let item = SyncItem::from_json(
"crdt:items:1".to_string(),
json!({"title": "First Item", "status": "active"}),
);
engine.submit(item).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(300)).await;
let query = Query::tags("status", vec!["active".to_string()]);
let results1 = engine.search_with_options("items", &query, SearchTier::RedisOnly, 100).await.unwrap();
assert_eq!(results1.items.len(), 1);
let stats = engine.search_cache_stats();
assert!(stats.is_some());
let item2 = SyncItem::from_json(
"crdt:items:2".to_string(),
json!({"title": "Second Item", "status": "active"}),
);
engine.submit(item2).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(300)).await;
let results2 = engine.search_with_options("items", &query, SearchTier::RedisOnly, 100).await.unwrap();
assert_eq!(results2.items.len(), 2, "Should find both active items after cache invalidation");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn cdc_stream_put_entries() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("cdc_put");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
enable_cdc_stream: true, cdc_stream_maxlen: 1000,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let item1 = SyncItem::from_json("cdc.test.1".to_string(), json!({"name": "Alice"}));
let item2 = SyncItem::from_json("cdc.test.2".to_string(), json!({"name": "Bob"}));
engine.submit(item1).await.expect("Submit failed");
engine.submit(item2).await.expect("Submit failed");
tokio::time::sleep(Duration::from_millis(50)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let client = redis::Client::open(format!("redis://127.0.0.1:{}", redis_port)).unwrap();
let mut conn = redis::aio::ConnectionManager::new(client).await.unwrap();
let entries: Vec<redis::Value> = redis::cmd("XRANGE")
.arg("cdc")
.arg("-")
.arg("+")
.arg("COUNT")
.arg(10)
.query_async(&mut conn)
.await
.expect("XRANGE failed");
assert_eq!(entries.len(), 2, "Should have 2 CDC entries");
let mut found_keys: Vec<String> = Vec::new();
for entry in &entries {
if let redis::Value::Array(entry_arr) = entry {
if let redis::Value::Array(fields) = &entry_arr[1] {
for i in (0..fields.len()).step_by(2) {
if let (redis::Value::BulkString(field), redis::Value::BulkString(value)) = (&fields[i], &fields[i+1]) {
let field_str = String::from_utf8_lossy(field);
if field_str == "key" {
found_keys.push(String::from_utf8_lossy(value).to_string());
}
}
}
}
}
}
assert!(found_keys.contains(&"cdc.test.1".to_string()), "CDC should contain cdc.test.1");
assert!(found_keys.contains(&"cdc.test.2".to_string()), "CDC should contain cdc.test.2");
if let redis::Value::Array(entry) = &entries[0] {
if let redis::Value::Array(fields) = &entry[1] {
let mut op_found = false;
for i in (0..fields.len()).step_by(2) {
if let (redis::Value::BulkString(field), redis::Value::BulkString(value)) = (&fields[i], &fields[i+1]) {
let field_str = String::from_utf8_lossy(field);
let value_str = String::from_utf8_lossy(value);
if field_str == "op" {
assert_eq!(value_str, "PUT");
op_found = true;
}
}
}
assert!(op_found, "CDC entry should have 'op' field");
}
}
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn cdc_stream_delete_entries() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("cdc_del");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None, l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
enable_cdc_stream: true,
cdc_stream_maxlen: 1000,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let item = SyncItem::from_json("cdc.delete.test".to_string(), json!({"name": "ToDelete"}));
engine.submit(item).await.expect("Submit failed");
tokio::time::sleep(Duration::from_millis(50)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let deleted = engine.delete("cdc.delete.test").await.expect("Delete failed");
assert!(deleted, "Item should have been deleted");
let client = redis::Client::open(format!("redis://127.0.0.1:{}", redis_port)).unwrap();
let mut conn = redis::aio::ConnectionManager::new(client).await.unwrap();
let entries: Vec<redis::Value> = redis::cmd("XRANGE")
.arg("cdc")
.arg("-")
.arg("+")
.arg("COUNT")
.arg(10)
.query_async(&mut conn)
.await
.expect("XRANGE failed");
assert!(entries.len() >= 2, "Should have at least PUT and DEL entries");
let mut found_del = false;
for entry_val in &entries {
if let redis::Value::Array(entry) = entry_val {
if let redis::Value::Array(fields) = &entry[1] {
for i in (0..fields.len()).step_by(2) {
if let (redis::Value::BulkString(field), redis::Value::BulkString(value)) = (&fields[i], &fields[i+1]) {
if String::from_utf8_lossy(field) == "op" && String::from_utf8_lossy(value) == "DEL" {
found_del = true;
}
}
}
}
}
}
assert!(found_del, "Should have DEL entry in CDC stream");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn cdc_stream_disabled_no_entries() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("cdc_disabled");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
enable_cdc_stream: false, ..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let item = SyncItem::from_json("cdc.disabled.test".to_string(), json!({"name": "NoStream"}));
engine.submit(item).await.expect("Submit failed");
tokio::time::sleep(Duration::from_millis(50)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let client = redis::Client::open(format!("redis://127.0.0.1:{}", redis_port)).unwrap();
let mut conn = redis::aio::ConnectionManager::new(client).await.unwrap();
let exists: bool = redis::cmd("EXISTS")
.arg(":cdc")
.query_async(&mut conn)
.await
.expect("EXISTS failed");
assert!(!exists, "CDC stream should not exist when disabled");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn cdc_stream_respects_prefix() {
let docker = Cli::default();
let redis = redis_container(&docker);
let redis_port = redis.get_host_port_ipv4(6379);
let wal_path = unique_wal_path("cdc_prefix");
let config = SyncEngineConfig {
redis_url: Some(format!("redis://127.0.0.1:{}", redis_port)),
redis_prefix: Some("myapp:".to_string()),
sql_url: None,
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
enable_cdc_stream: true,
cdc_stream_maxlen: 1000,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
let item = SyncItem::from_json("cdc.prefix.test".to_string(), json!({"name": "Prefixed"}));
engine.submit(item).await.expect("Submit failed");
tokio::time::sleep(Duration::from_millis(50)).await;
engine.force_flush().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let client = redis::Client::open(format!("redis://127.0.0.1:{}", redis_port)).unwrap();
let mut conn = redis::aio::ConnectionManager::new(client).await.unwrap();
let prefixed_exists: bool = redis::cmd("EXISTS")
.arg("myapp:cdc")
.query_async(&mut conn)
.await
.expect("EXISTS failed");
assert!(prefixed_exists, "Prefixed CDC stream should exist");
let unprefixed_exists: bool = redis::cmd("EXISTS")
.arg(":cdc")
.query_async(&mut conn)
.await
.expect("EXISTS failed");
assert!(!unprefixed_exists, "Unprefixed CDC stream should not exist");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn schema_routing_creates_tables() {
let docker = Cli::default();
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("schema_routing");
let config = SyncEngineConfig {
redis_url: None,
sql_url: Some(format!(
"mysql://test:test@127.0.0.1:{}/test", mysql_port
)),
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
engine.register_schema("users", "view:users:").await
.expect("Failed to register schema");
engine.register_schema("users", "crdt:users:").await
.expect("Failed to register second prefix");
assert_eq!(engine.table_for_key("view:users:alice"), "users_items");
assert_eq!(engine.table_for_key("crdt:users:bob"), "users_items");
assert_eq!(engine.table_for_key("unknown:key"), "sync_items");
let tables = engine.registered_tables();
assert!(tables.contains(&"users_items".to_string()));
let prefixes = engine.prefixes_for_table("users_items");
assert_eq!(prefixes.len(), 2);
assert!(prefixes.contains(&"view:users:".to_string()));
assert!(prefixes.contains(&"crdt:users:".to_string()));
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn schema_routing_writes_to_correct_table() {
let docker = Cli::default();
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("schema_writes");
let config = SyncEngineConfig {
redis_url: None,
sql_url: Some(format!(
"mysql://test:test@127.0.0.1:{}/test", mysql_port
)),
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
batch_flush_count: 5, batch_flush_ms: 10,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
engine.register_schema("users", "view:users:").await
.expect("Failed to register schema");
let user_item = SyncItem::from_json(
"view:users:alice".into(),
json!({"name": "Alice"})
);
let default_item = SyncItem::from_json(
"other:thing:123".into(),
json!({"type": "other"})
);
engine.submit(user_item).await.expect("Submit user failed");
engine.submit(default_item).await.expect("Submit default failed");
engine.force_flush().await;
let retrieved_user = engine.get("view:users:alice").await
.expect("Get user failed");
assert!(retrieved_user.is_some());
assert_eq!(retrieved_user.unwrap().object_id, "view:users:alice");
let retrieved_default = engine.get("other:thing:123").await
.expect("Get default failed");
assert!(retrieved_default.is_some());
assert_eq!(retrieved_default.unwrap().object_id, "other:thing:123");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}
#[tokio::test]
#[ignore] async fn schema_routing_batch_spans_tables() {
let docker = Cli::default();
let mysql = mysql_container(&docker);
let mysql_port = mysql.get_host_port_ipv4(3306);
tokio::time::sleep(Duration::from_secs(5)).await;
let wal_path = unique_wal_path("schema_batch");
let config = SyncEngineConfig {
redis_url: None,
sql_url: Some(format!(
"mysql://test:test@127.0.0.1:{}/test", mysql_port
)),
l1_max_bytes: 10 * 1024 * 1024,
wal_path: Some(wal_path.clone()),
batch_flush_count: 100, batch_flush_ms: 10,
..Default::default()
};
let (_tx, rx) = watch::channel(config.clone());
let mut engine = SyncEngine::new(config, rx);
engine.start().await.expect("Failed to start engine");
engine.register_schema("users", "view:users:").await.unwrap();
engine.register_schema("orders", "view:orders:").await.unwrap();
let items = vec![
SyncItem::from_json("view:users:alice".into(), json!({"name": "Alice"})),
SyncItem::from_json("view:users:bob".into(), json!({"name": "Bob"})),
SyncItem::from_json("view:orders:001".into(), json!({"total": 100})),
SyncItem::from_json("view:orders:002".into(), json!({"total": 200})),
SyncItem::from_json("misc:item:xyz".into(), json!({"misc": true})),
];
let result = engine.submit_many(items).await.expect("Batch submit failed");
assert_eq!(result.succeeded, 5);
engine.force_flush().await;
assert!(engine.get("view:users:alice").await.unwrap().is_some());
assert!(engine.get("view:users:bob").await.unwrap().is_some());
assert!(engine.get("view:orders:001").await.unwrap().is_some());
assert!(engine.get("view:orders:002").await.unwrap().is_some());
assert!(engine.get("misc:item:xyz").await.unwrap().is_some());
assert_eq!(engine.table_for_key("view:users:alice"), "users_items");
assert_eq!(engine.table_for_key("view:orders:001"), "orders_items");
assert_eq!(engine.table_for_key("misc:item:xyz"), "sync_items");
engine.shutdown().await;
cleanup_wal_files(&wal_path);
}