use std::collections::BTreeMap;
use citadel::{Argon2Profile, Database, DatabaseBuilder};
use citadel_sync::{merkle_diff, LocalTreeReader};
fn fast_builder(path: &std::path::Path) -> DatabaseBuilder {
DatabaseBuilder::new(path)
.passphrase(b"diff-torture")
.argon2_profile(Argon2Profile::Iot)
}
fn diff(db1: &Database, db2: &Database) -> citadel_sync::DiffResult {
let r1 = LocalTreeReader::new(db1.manager());
let r2 = LocalTreeReader::new(db2.manager());
merkle_diff(&r1, &r2).unwrap()
}
fn collect_all(db: &Database) -> BTreeMap<Vec<u8>, Vec<u8>> {
let mut data = BTreeMap::new();
let mut rtx = db.begin_read();
rtx.for_each(|k, v| {
data.insert(k.to_vec(), v.to_vec());
Ok(())
})
.unwrap();
data
}
fn apply(db: &Database, result: &citadel_sync::DiffResult) {
let mut wtx = db.begin_write().unwrap();
for e in &result.entries {
wtx.insert(&e.key, &e.value).unwrap();
}
wtx.commit().unwrap();
}
#[test]
fn random_500_entries_diff_correctness() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let mut wtx1 = db1.begin_write().unwrap();
let mut wtx2 = db2.begin_write().unwrap();
for i in 0..500u32 {
let val = i.to_le_bytes();
wtx1.insert(&i.to_be_bytes(), &val).unwrap();
wtx2.insert(&i.to_be_bytes(), &val).unwrap();
}
wtx1.commit().unwrap();
wtx2.commit().unwrap();
let changed: Vec<u32> = (0..50).map(|i| i * 10).collect();
let mut wtx = db1.begin_write().unwrap();
for &k in &changed {
wtx.insert(&k.to_be_bytes(), b"MUTATED").unwrap();
}
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
for &k in &changed {
let found = result.entries.iter().any(|e| e.key == k.to_be_bytes());
assert!(found, "changed key {k} missing from diff");
}
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn random_mutations_100_rounds_incremental() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..200u32 {
wtx.insert(&i.to_be_bytes(), &i.to_le_bytes()).unwrap();
}
wtx.commit().unwrap();
}
for round in 0..100u32 {
let key = (round % 200).to_be_bytes();
let val = format!("round-{round}");
let mut wtx = db1.begin_write().unwrap();
wtx.insert(&key, val.as_bytes()).unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty(), "round {round}: must find change");
let found = result.entries.iter().any(|e| e.key == key);
assert!(found, "round {round}: changed key must appear in diff");
apply(&db2, &result);
let post_diff = diff(&db1, &db2);
assert!(
post_diff.is_empty(),
"round {round}: must be in sync after apply"
);
}
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn many_small_changes_efficiency() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let val = [0xFF_u8; 64];
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..1000u32 {
wtx.insert(&i.to_be_bytes(), &val).unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
wtx.insert(&100u32.to_be_bytes(), b"changed-a").unwrap();
wtx.insert(&500u32.to_be_bytes(), b"changed-b").unwrap();
wtx.insert(&900u32.to_be_bytes(), b"changed-c").unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(result.subtrees_skipped > 0, "must skip matching subtrees");
assert!(
result.len() < 500,
"expected efficient diff, got {} entries out of 1000",
result.len()
);
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn single_bit_value_change_detected() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let val = [0x00_u8; 256];
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..100u32 {
wtx.insert(&i.to_be_bytes(), &val).unwrap();
}
wtx.commit().unwrap();
}
let mut modified_val = val;
modified_val[128] = 0x01;
let mut wtx = db1.begin_write().unwrap();
wtx.insert(&50u32.to_be_bytes(), &modified_val).unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty(), "single-bit change must be detected");
assert!(
result.entries.iter().any(|e| e.key == 50u32.to_be_bytes()),
"changed key must be in diff"
);
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn delete_and_reinsert_convergence() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..50u32 {
wtx.insert(&i.to_be_bytes(), b"original").unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
for i in 20..30u32 {
wtx.delete(&i.to_be_bytes()).unwrap();
}
wtx.commit().unwrap();
let mut wtx = db1.begin_write().unwrap();
for i in 22..28u32 {
wtx.insert(&i.to_be_bytes(), b"reinserted").unwrap();
}
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
for i in 22..28u32 {
let found = result.entries.iter().any(|e| e.key == i.to_be_bytes());
assert!(found, "reinserted key {i} must be in diff");
}
apply(&db2, &result);
let db2_data = collect_all(&db2);
for i in 22..28u32 {
assert_eq!(db2_data[&i.to_be_bytes().to_vec()], b"reinserted");
}
for i in [20u32, 21, 28, 29] {
assert_eq!(db2_data[&i.to_be_bytes().to_vec()], b"original");
}
}
#[test]
fn concurrent_reader_during_diff() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..100u32 {
wtx.insert(&i.to_be_bytes(), &i.to_le_bytes()).unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
wtx.insert(b"extra-key", b"extra-value").unwrap();
wtx.commit().unwrap();
let mut rtx = db2.begin_read();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
assert!(rtx.get(&0u32.to_be_bytes()).unwrap().is_some());
drop(rtx);
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn all_entries_changed_full_diff() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..100u32 {
wtx.insert(&i.to_be_bytes(), b"original").unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
for i in 0..100u32 {
wtx.insert(&i.to_be_bytes(), b"ALL-CHANGED").unwrap();
}
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
assert!(result.len() >= 100, "all entries must be in diff");
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn alternating_insert_delete_stress() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let mut expected = BTreeMap::new();
for round in 0..20u32 {
let mut wtx = db1.begin_write().unwrap();
for j in 0..10u32 {
let key = (round * 100 + j).to_be_bytes();
let val = format!("r{round}j{j}");
wtx.insert(&key, val.as_bytes()).unwrap();
expected.insert(key.to_vec(), val.into_bytes());
}
if round > 0 {
for j in 0..3u32 {
let key = ((round - 1) * 100 + j).to_be_bytes();
wtx.delete(&key).unwrap();
expected.remove(key.as_slice());
}
}
wtx.commit().unwrap();
}
let result = diff(&db1, &db2);
assert!(!result.is_empty());
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
assert_eq!(collect_all(&db1), expected);
}
#[test]
fn diff_symmetry_both_directions() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..100u32 {
wtx.insert(&i.to_be_bytes(), b"same").unwrap();
}
wtx.commit().unwrap();
}
let mut wtx1 = db1.begin_write().unwrap();
wtx1.insert(&10u32.to_be_bytes(), b"from-db1").unwrap();
wtx1.commit().unwrap();
let mut wtx2 = db2.begin_write().unwrap();
wtx2.insert(&20u32.to_be_bytes(), b"from-db2").unwrap();
wtx2.commit().unwrap();
let forward = diff(&db1, &db2);
assert!(!forward.is_empty());
assert!(forward.entries.iter().any(|e| e.key == 10u32.to_be_bytes()));
let reverse = diff(&db2, &db1);
assert!(!reverse.is_empty());
assert!(reverse.entries.iter().any(|e| e.key == 20u32.to_be_bytes()));
apply(&db2, &forward);
let db2_data = collect_all(&db2);
assert_eq!(db2_data[&10u32.to_be_bytes().to_vec()], b"from-db1");
let post = diff(&db1, &db2);
assert!(
post.is_empty(),
"after forward apply, db1->db2 diff must be empty"
);
}
#[test]
fn large_values_multi_page_diff() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let big = vec![0xAA_u8; 1024];
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
for i in 0..50u32 {
wtx.insert(&i.to_be_bytes(), &big).unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
wtx.insert(&5u32.to_be_bytes(), &vec![0xBB_u8; 1024])
.unwrap();
wtx.insert(&25u32.to_be_bytes(), &vec![0xCC_u8; 1024])
.unwrap();
wtx.insert(&45u32.to_be_bytes(), &vec![0xDD_u8; 1024])
.unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
assert!(result.entries.iter().any(|e| e.key == 5u32.to_be_bytes()));
assert!(result.entries.iter().any(|e| e.key == 25u32.to_be_bytes()));
assert!(result.entries.iter().any(|e| e.key == 45u32.to_be_bytes()));
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn rapid_sync_cycles_50_rounds() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for round in 0..50u32 {
let mut wtx = db1.begin_write().unwrap();
let key = format!("key-{round}");
let val = format!("val-{round}");
wtx.insert(key.as_bytes(), val.as_bytes()).unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty(), "round {round}: should have changes");
apply(&db2, &result);
let verify = diff(&db1, &db2);
assert!(verify.is_empty(), "round {round}: should be in sync");
}
assert_eq!(collect_all(&db1), collect_all(&db2));
}
#[test]
fn disjoint_keys_no_overlap() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let mut wtx1 = db1.begin_write().unwrap();
for i in (0..100u32).step_by(2) {
wtx1.insert(&i.to_be_bytes(), b"even").unwrap();
}
wtx1.commit().unwrap();
let mut wtx2 = db2.begin_write().unwrap();
for i in (1..100u32).step_by(2) {
wtx2.insert(&i.to_be_bytes(), b"odd").unwrap();
}
wtx2.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
apply(&db2, &result);
let all = collect_all(&db2);
assert_eq!(all.len(), 100);
for i in (0..100u32).step_by(2) {
assert_eq!(all[&i.to_be_bytes().to_vec()], b"even");
}
for i in (1..100u32).step_by(2) {
assert_eq!(all[&i.to_be_bytes().to_vec()], b"odd");
}
}
#[test]
fn diff_empty_after_full_overwrite() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
let mut wtx1 = db1.begin_write().unwrap();
for i in 0..100u32 {
wtx1.insert(&i.to_be_bytes(), b"db1-original").unwrap();
}
wtx1.commit().unwrap();
let mut wtx2 = db2.begin_write().unwrap();
for i in 0..100u32 {
wtx2.insert(&i.to_be_bytes(), b"db2-original").unwrap();
}
wtx2.commit().unwrap();
let result = diff(&db1, &db2);
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
let result2 = diff(&db1, &db2);
assert!(
result2.is_empty(),
"second diff must be empty after full sync"
);
}
#[test]
fn mixed_key_sizes() {
let dir = tempfile::tempdir().unwrap();
let db1 = fast_builder(&dir.path().join("a.db")).create().unwrap();
let db2 = fast_builder(&dir.path().join("b.db")).create().unwrap();
for db in [&db1, &db2] {
let mut wtx = db.begin_write().unwrap();
wtx.insert(b"a", b"short-key").unwrap();
wtx.insert(b"medium-length-key", b"medium").unwrap();
let long_key = vec![0x42_u8; 200];
wtx.insert(&long_key, b"long-key").unwrap();
for i in 0..50u32 {
wtx.insert(&i.to_be_bytes(), &i.to_le_bytes()).unwrap();
}
wtx.commit().unwrap();
}
let mut wtx = db1.begin_write().unwrap();
wtx.insert(b"a", b"SHORT-CHANGED").unwrap();
wtx.insert(b"medium-length-key", b"MEDIUM-CHANGED").unwrap();
let long_key = vec![0x42_u8; 200];
wtx.insert(&long_key, b"LONG-CHANGED").unwrap();
wtx.commit().unwrap();
let result = diff(&db1, &db2);
assert!(!result.is_empty());
apply(&db2, &result);
assert_eq!(collect_all(&db1), collect_all(&db2));
}