Files
tranquil-pds/crates/tranquil-store/tests/backup.rs
T
2026-07-25 08:27:39 +03:00

1022 lines
33 KiB
Rust

mod common;
use common::test_rev;
use std::path::Path;
use std::sync::Arc;
use tranquil_store::backup::{
BackupCoordinator, BackupKind, read_manifest, recover_to_sequence, restore_from_backup,
restore_from_incremental, verify_backup,
};
use tranquil_store::blockstore::{
BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore,
};
use tranquil_store::eventlog::{EventLog, EventLogConfig};
use tranquil_store::metastore::{Metastore, MetastoreConfig};
use tranquil_store::{RealIO, SystemClock};
fn test_cid(seed: u16) -> CidBytes {
let mut cid = [0u8; 36];
cid[0] = 0x01;
cid[1] = 0x71;
cid[2] = 0x12;
cid[3] = 0x20;
cid[4..6].copy_from_slice(&seed.to_le_bytes());
(6..36).for_each(|i| cid[i] = (seed as u8).wrapping_add(i as u8));
cid
}
fn block_data(seed: u16) -> Vec<u8> {
let tag = seed.to_le_bytes();
let mut data = vec![0u8; 128];
data[..2].copy_from_slice(&tag);
data
}
struct TestStore {
_dir: tempfile::TempDir,
blockstore: TranquilBlockStore<RealIO, SystemClock>,
eventlog: Arc<EventLog<RealIO>>,
metastore: Metastore,
}
fn open_test_store() -> TestStore {
open_test_store_with_max_file_size(DEFAULT_MAX_FILE_SIZE)
}
fn open_test_store_with_max_file_size(max_file_size: u64) -> TestStore {
let dir = tempfile::TempDir::new().unwrap();
let bs_data = dir.path().join("blockstore/data");
let bs_index = dir.path().join("blockstore/index");
let segments_dir = dir.path().join("eventlog/segments");
let metastore_dir = dir.path().join("metastore");
[&bs_data, &bs_index, &segments_dir, &metastore_dir]
.iter()
.for_each(|d| std::fs::create_dir_all(d).unwrap());
let blockstore = TranquilBlockStore::open(BlockStoreConfig {
data_dir: bs_data,
index_dir: bs_index,
max_file_size,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
})
.unwrap();
let eventlog = Arc::new(
EventLog::open(
EventLogConfig {
segments_dir,
..EventLogConfig::default()
},
RealIO::new(),
)
.unwrap(),
);
let metastore = Metastore::open(
&metastore_dir,
MetastoreConfig {
cache_size_bytes: 64 * 1024 * 1024,
},
)
.unwrap();
TestStore {
_dir: dir,
blockstore,
eventlog,
metastore,
}
}
fn seed_blocks(store: &TestStore, range: std::ops::Range<u16>) {
let blocks: Vec<(CidBytes, Vec<u8>)> = range.map(|i| (test_cid(i), block_data(i))).collect();
store.blockstore.put_blocks_blocking(blocks).unwrap();
}
fn seed_repo(store: &TestStore, name: &str, seed: u8) {
let did = tranquil_types::Did::new(format!("did:plc:{name}")).expect("seed DID is valid");
let handle =
tranquil_types::Handle::new(format!("{name}.oyster.cafe")).expect("seed handle is valid");
let digest: [u8; 32] = std::array::from_fn(|i| seed.wrapping_add(i as u8));
let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap();
let cid = cid::Cid::new_v1(0x71, mh);
let cid_link = tranquil_types::CidLink::from_cid(&cid);
store
.metastore
.repo_ops()
.create_repo(
store.metastore.database(),
uuid::Uuid::new_v4(),
&did,
&handle,
&cid_link,
&test_rev(0, seed as u64),
)
.unwrap();
}
fn seed_events(store: &TestStore, count: u16) {
let did = tranquil_types::Did::new("did:plc:anemone").expect("did:plc:anemone is a valid DID");
(0..count).for_each(|i| {
let event = tranquil_db_traits::SequencedEvent {
seq: tranquil_db_traits::SequenceNumber::from_raw(i as i64 + 1),
did: did.clone(),
created_at: chrono::Utc::now(),
event_type: tranquil_db_traits::RepoEventType::Commit,
commit_cid: None,
prev_cid: None,
prev_data_cid: None,
ops: None,
blobs: None,
blocks: None,
handle: None,
active: None,
status: None,
rev: Some(common::test_rev(i as u64, 0)),
};
store
.eventlog
.append_event(&did, tranquil_db_traits::RepoEventType::Commit, &event)
.unwrap();
});
store.eventlog.sync().unwrap();
}
fn archive_segments(store: &TestStore, archive_dir: &Path) {
std::fs::read_dir(store.eventlog.segments_dir())
.unwrap()
.filter_map(|e| e.ok())
.for_each(|entry| {
let dest = archive_dir.join(entry.file_name());
std::fs::copy(entry.path(), dest).unwrap();
});
}
fn coordinator(store: &TestStore) -> BackupCoordinator<'_, RealIO> {
BackupCoordinator::new(&store.blockstore, &store.eventlog, &store.metastore)
}
fn with_runtime<F: FnOnce()>(f: F) {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
f();
}
fn verify_blocks_readable(
store: &TranquilBlockStore<RealIO, SystemClock>,
range: std::ops::Range<u16>,
) {
range.for_each(|i| {
let cid = test_cid(i);
let data = store.get_block_sync(&cid).unwrap();
assert!(data.is_some(), "block seed={i} must be readable");
assert_eq!(
&data.unwrap()[..2],
&i.to_le_bytes(),
"data mismatch for block seed={i}"
);
});
}
fn verify_restored_blocks(restored_dir: &Path, range: std::ops::Range<u16>) {
let bs = TranquilBlockStore::open(BlockStoreConfig {
data_dir: restored_dir.join("blocks"),
index_dir: restored_dir.join("block_index"),
max_file_size: DEFAULT_MAX_FILE_SIZE,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
})
.unwrap();
verify_blocks_readable(&bs, range);
}
fn verify_restored_events(restored_dir: &Path, expected_max_seq: u64) {
let el = EventLog::open(
EventLogConfig {
segments_dir: restored_dir.join("events"),
..EventLogConfig::default()
},
RealIO::new(),
)
.unwrap();
assert_eq!(
el.max_seq().raw(),
expected_max_seq,
"restored eventlog max_seq mismatch"
);
let _ = el.shutdown();
}
fn verify_restored_metastore(restored_dir: &Path, repo_names: &[&str]) {
let ms = Metastore::open(
&restored_dir.join("metastore"),
MetastoreConfig {
cache_size_bytes: 64 * 1024 * 1024,
},
)
.unwrap();
let ops = ms.repo_ops();
repo_names.iter().for_each(|name| {
let did = tranquil_types::Did::new(format!("did:plc:{name}")).expect("seed DID is valid");
let root = ops.get_repo_root_by_did(&did).unwrap();
assert!(
root.is_some(),
"repo {name} must exist in restored metastore"
);
});
}
#[test]
fn verify_backup_detects_corrupted_block_file() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_events(&store, 5);
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let result = verify_backup(backup_dir.path()).unwrap();
assert!(result.is_healthy(), "fresh backup must be healthy");
assert!(result.total_blocks >= 20);
assert_eq!(result.corrupted_blocks, 0);
let blocks_dir = backup_dir.path().join("blocks");
let entries: Vec<_> = std::fs::read_dir(&blocks_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "tqb")
})
.collect();
assert!(!entries.is_empty(), "must have at least one block file");
let target = &entries[0].path();
let mut corrupted = std::fs::read(target).unwrap();
corrupted
.iter_mut()
.skip(64)
.take(32)
.for_each(|b| *b ^= 0xFF);
std::fs::write(target, &corrupted).unwrap();
let result = verify_backup(backup_dir.path()).unwrap();
assert!(
result.corrupted_blocks > 0 || !result.file_failures.is_empty(),
"verify must detect corruption: corrupted_blocks={}, file_failures={}",
result.corrupted_blocks,
result.file_failures.len()
);
});
}
#[test]
fn verify_backup_detects_corrupted_event_file() {
with_runtime(|| {
let store = open_test_store();
seed_events(&store, 50);
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let result = verify_backup(backup_dir.path()).unwrap();
assert!(result.is_healthy());
assert!(result.total_events >= 50);
let events_dir = backup_dir.path().join("events");
let entries: Vec<_> = std::fs::read_dir(&events_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "tqe")
})
.collect();
assert!(!entries.is_empty());
let target = &entries[0].path();
let mut corrupted = std::fs::read(target).unwrap();
corrupted
.iter_mut()
.skip(64)
.take(32)
.for_each(|b| *b ^= 0xFF);
std::fs::write(target, &corrupted).unwrap();
let result = verify_backup(backup_dir.path()).unwrap();
assert!(
result.corrupted_events > 0 || !result.file_failures.is_empty(),
"verify must detect event corruption: corrupted_events={}, file_failures={}",
result.corrupted_events,
result.file_failures.len()
);
});
}
#[test]
fn verify_backup_detects_checksum_mismatch() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..5);
seed_events(&store, 3);
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let manifest = read_manifest(backup_dir.path()).unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(8).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
let result = verify_backup(backup_dir.path()).unwrap();
assert!(
!result.file_failures.is_empty(),
"checksum mismatch must be detected"
);
});
}
#[test]
fn full_backup_and_restore_cycle() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..50);
seed_repo(&store, "olaren", 1);
seed_repo(&store, "teq", 2);
seed_events(&store, 20);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
assert_eq!(manifest.kind, BackupKind::Full);
assert_eq!(manifest.version, 1);
assert!(manifest.eventlog.max_seq.raw() >= 20);
seed_blocks(&store, 50..100);
seed_repo(&store, "nel", 3);
seed_events(&store, 10);
let restore_dir = tempfile::TempDir::new().unwrap();
let result = restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap();
assert!(result.blocks_files_restored > 0);
assert!(result.event_segments_restored > 0);
assert!(result.metastore_files_restored > 0);
verify_restored_blocks(restore_dir.path(), 0..50);
verify_restored_events(restore_dir.path(), manifest.eventlog.max_seq.raw());
verify_restored_metastore(restore_dir.path(), &["olaren", "teq"]);
let ms = Metastore::open(
&restore_dir.path().join("metastore"),
MetastoreConfig {
cache_size_bytes: 64 * 1024 * 1024,
},
)
.unwrap();
let nel_did = tranquil_types::Did::new("did:plc:nel").expect("did:plc:nel is a valid DID");
assert!(
ms.repo_ops()
.get_repo_root_by_did(&nel_did)
.unwrap()
.is_none(),
"nel was added after backup and must not exist in restored data"
);
let verify = verify_backup(backup_dir.path()).unwrap();
assert!(verify.is_healthy());
});
}
#[test]
fn full_backup_and_restore_preserves_block_content() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..100);
seed_events(&store, 10);
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let restore_dir = tempfile::TempDir::new().unwrap();
restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap();
let restored_bs = TranquilBlockStore::open(BlockStoreConfig {
data_dir: restore_dir.path().join("blocks"),
index_dir: restore_dir.path().join("block_index"),
max_file_size: DEFAULT_MAX_FILE_SIZE,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
})
.unwrap();
(0u16..100).for_each(|i| {
let cid = test_cid(i);
let original = store.blockstore.get_block_sync(&cid).unwrap().unwrap();
let restored = restored_bs.get_block_sync(&cid).unwrap().unwrap();
assert_eq!(original, restored, "block content mismatch for seed={i}");
});
});
}
#[test]
fn incremental_backup_and_restore() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..30);
seed_repo(&store, "olaren", 1);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let base_dir = tempfile::TempDir::new().unwrap();
let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap();
assert_eq!(base_manifest.kind, BackupKind::Full);
seed_blocks(&store, 30..60);
seed_repo(&store, "teq", 2);
seed_events(&store, 15);
store.metastore.persist().unwrap();
let incr_dir = tempfile::TempDir::new().unwrap();
let incr_manifest = coordinator(&store)
.create_incremental_backup(&base_manifest, incr_dir.path())
.unwrap();
assert_eq!(incr_manifest.kind, BackupKind::Incremental);
assert!(incr_manifest.base_blockstore.is_some());
assert!(incr_manifest.base_eventlog.is_some());
assert!(incr_manifest.eventlog.max_seq >= base_manifest.eventlog.max_seq);
let restore_dir = tempfile::TempDir::new().unwrap();
let result =
restore_from_incremental(base_dir.path(), incr_dir.path(), restore_dir.path()).unwrap();
assert!(result.blocks_files_restored > 0);
assert!(result.event_segments_restored > 0);
verify_restored_blocks(restore_dir.path(), 0..60);
verify_restored_events(restore_dir.path(), incr_manifest.eventlog.max_seq.raw());
verify_restored_metastore(restore_dir.path(), &["olaren", "teq"]);
});
}
#[test]
fn incremental_is_smaller_than_full() {
with_runtime(|| {
let store = open_test_store_with_max_file_size(2048);
(0u16..100).step_by(10).for_each(|start| {
seed_blocks(&store, start..start + 10);
});
seed_events(&store, 50);
store.metastore.persist().unwrap();
let base_dir = tempfile::TempDir::new().unwrap();
let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap();
seed_blocks(&store, 100..120);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let incr_dir = tempfile::TempDir::new().unwrap();
let incr_manifest = coordinator(&store)
.create_incremental_backup(&base_manifest, incr_dir.path())
.unwrap();
let full2_dir = tempfile::TempDir::new().unwrap();
coordinator(&store).create_backup(full2_dir.path()).unwrap();
let incr_block_bytes: u64 = incr_manifest
.files
.iter()
.filter(|f| f.path.starts_with("blocks"))
.map(|f| f.size)
.sum();
let full2_manifest = read_manifest(full2_dir.path()).unwrap();
let full_block_bytes: u64 = full2_manifest
.files
.iter()
.filter(|f| f.path.starts_with("blocks"))
.map(|f| f.size)
.sum();
assert!(
incr_block_bytes < full_block_bytes,
"incremental block data ({incr_block_bytes}) should be smaller than full ({full_block_bytes})"
);
});
}
#[test]
fn point_in_time_recovery_to_exact_sequence() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_repo(&store, "limpet", 1);
seed_events(&store, 30);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let backup_max = manifest.eventlog.max_seq;
seed_events(&store, 20);
store.eventlog.sync().unwrap();
let final_max = store.eventlog.max_seq();
assert!(final_max > backup_max);
let archive_dir = tempfile::TempDir::new().unwrap();
archive_segments(&store, archive_dir.path());
let target_seq = tranquil_store::eventlog::EventSequence::new(
backup_max.raw() + (final_max.raw() - backup_max.raw()) / 2,
);
let restore_dir = tempfile::TempDir::new().unwrap();
let pitr_result = recover_to_sequence(
backup_dir.path(),
archive_dir.path(),
target_seq,
restore_dir.path(),
)
.unwrap();
assert_eq!(pitr_result.target_seq, target_seq);
assert!(pitr_result.events_replayed > 0);
verify_restored_blocks(restore_dir.path(), 0..20);
verify_restored_metastore(restore_dir.path(), &["limpet"]);
let restored_el = EventLog::open(
EventLogConfig {
segments_dir: restore_dir.path().join("events"),
..EventLogConfig::default()
},
RealIO::new(),
)
.unwrap();
assert!(restored_el.max_seq() >= target_seq);
let _ = restored_el.shutdown();
});
}
#[test]
fn pitr_at_backup_sequence_replays_zero_events() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..10);
seed_events(&store, 15);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let archive_dir = tempfile::TempDir::new().unwrap();
archive_segments(&store, archive_dir.path());
let restore_dir = tempfile::TempDir::new().unwrap();
let pitr_result = recover_to_sequence(
backup_dir.path(),
archive_dir.path(),
manifest.eventlog.max_seq,
restore_dir.path(),
)
.unwrap();
assert_eq!(pitr_result.events_replayed, 0);
});
}
#[test]
fn crash_during_backup_does_not_corrupt_live_store() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..50);
seed_repo(&store, "whelk", 1);
seed_events(&store, 20);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
verify_blocks_readable(&store.blockstore, 0..50);
let extra_blocks: Vec<(CidBytes, Vec<u8>)> =
(50u16..80).map(|i| (test_cid(i), block_data(i))).collect();
store.blockstore.put_blocks_blocking(extra_blocks).unwrap();
verify_blocks_readable(&store.blockstore, 0..80);
seed_events(&store, 10);
assert!(store.eventlog.max_seq().raw() >= 30);
seed_repo(&store, "scallop", 2);
store.metastore.persist().unwrap();
let did =
tranquil_types::Did::new("did:plc:scallop").expect("did:plc:scallop is a valid DID");
assert!(
store
.metastore
.repo_ops()
.get_repo_root_by_did(&did)
.unwrap()
.is_some(),
"post-backup repo must exist in live store"
);
});
}
#[test]
fn backup_during_concurrent_writes_produces_consistent_snapshot() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let writer_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let bs_clone = store.blockstore.clone();
let flag_clone = Arc::clone(&writer_flag);
let writer_handle = std::thread::spawn(move || {
let mut seed = 1000u16;
while flag_clone.load(std::sync::atomic::Ordering::Relaxed) {
let batch: Vec<(CidBytes, Vec<u8>)> = (seed..seed.saturating_add(5))
.map(|i| (test_cid(i), block_data(i)))
.collect();
let _ = bs_clone.put_blocks_blocking(batch);
seed = seed.saturating_add(5);
std::thread::sleep(std::time::Duration::from_millis(1));
}
});
let el_clone = Arc::clone(&store.eventlog);
let flag_clone2 = Arc::clone(&writer_flag);
let event_handle = std::thread::spawn(move || {
let did = tranquil_types::Did::new("did:plc:conch").expect("did:plc:conch is a valid DID");
let mut i = 0u32;
while flag_clone2.load(std::sync::atomic::Ordering::Relaxed) {
let event = tranquil_db_traits::SequencedEvent {
seq: tranquil_db_traits::SequenceNumber::from_raw(i as i64 + 1),
did: did.clone(),
created_at: chrono::Utc::now(),
event_type: tranquil_db_traits::RepoEventType::Commit,
commit_cid: None,
prev_cid: None,
prev_data_cid: None,
ops: None,
blobs: None,
blocks: None,
handle: None,
active: None,
status: None,
rev: Some(common::test_rev(i as u64, 0)),
};
let _ = el_clone.append_event(&did, tranquil_db_traits::RepoEventType::Commit, &event);
let _ = el_clone.sync();
i = i.saturating_add(1);
std::thread::sleep(std::time::Duration::from_millis(2));
}
});
std::thread::sleep(std::time::Duration::from_millis(50));
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
writer_flag.store(false, std::sync::atomic::Ordering::Relaxed);
writer_handle.join().unwrap();
event_handle.join().unwrap();
assert_eq!(manifest.kind, BackupKind::Full);
assert!(!manifest.files.is_empty());
let verify = verify_backup(backup_dir.path()).unwrap();
assert!(
verify.is_healthy(),
"backup taken during concurrent writes must be healthy: \
corrupted_blocks={}, corrupted_events={}, file_failures={}",
verify.corrupted_blocks,
verify.corrupted_events,
verify.file_failures.len()
);
let restore_dir = tempfile::TempDir::new().unwrap();
let result = restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap();
assert!(result.blocks_files_restored > 0);
verify_restored_blocks(restore_dir.path(), 0..20);
let restored_el = EventLog::open(
EventLogConfig {
segments_dir: restore_dir.path().join("events"),
..EventLogConfig::default()
},
RealIO::new(),
)
.unwrap();
assert_eq!(
restored_el.max_seq().raw(),
manifest.eventlog.max_seq.raw(),
"restored eventlog must match manifest"
);
let _ = restored_el.shutdown();
}
#[test]
fn restore_rejects_nonempty_target() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..5);
seed_events(&store, 3);
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let target = tempfile::TempDir::new().unwrap();
std::fs::write(target.path().join("garbage"), b"leftover").unwrap();
let err = restore_from_backup(backup_dir.path(), target.path());
assert!(err.is_err(), "restore into non-empty dir must fail");
});
}
#[test]
fn multiple_backups_restore_independently() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_repo(&store, "snap1", 1);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let backup1_dir = tempfile::TempDir::new().unwrap();
let manifest1 = coordinator(&store)
.create_backup(backup1_dir.path())
.unwrap();
seed_blocks(&store, 20..40);
seed_repo(&store, "snap2", 2);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let backup2_dir = tempfile::TempDir::new().unwrap();
let manifest2 = coordinator(&store)
.create_backup(backup2_dir.path())
.unwrap();
let restore1 = tempfile::TempDir::new().unwrap();
restore_from_backup(backup1_dir.path(), restore1.path()).unwrap();
verify_restored_blocks(restore1.path(), 0..20);
verify_restored_events(restore1.path(), manifest1.eventlog.max_seq.raw());
verify_restored_metastore(restore1.path(), &["snap1"]);
let restore2 = tempfile::TempDir::new().unwrap();
restore_from_backup(backup2_dir.path(), restore2.path()).unwrap();
verify_restored_blocks(restore2.path(), 0..40);
verify_restored_events(restore2.path(), manifest2.eventlog.max_seq.raw());
verify_restored_metastore(restore2.path(), &["snap1", "snap2"]);
});
}
#[test]
fn pitr_rejects_target_before_backup() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..10);
seed_events(&store, 20);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let archive_dir = tempfile::TempDir::new().unwrap();
archive_segments(&store, archive_dir.path());
let earlier =
tranquil_store::eventlog::EventSequence::new(manifest.eventlog.max_seq.raw() - 1);
let restore_dir = tempfile::TempDir::new().unwrap();
let err = recover_to_sequence(
backup_dir.path(),
archive_dir.path(),
earlier,
restore_dir.path(),
);
assert!(
err.is_err(),
"PITR to a sequence before the backup must fail"
);
});
}
#[test]
fn pitr_rejects_target_beyond_available() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..10);
seed_events(&store, 20);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
seed_events(&store, 5);
store.eventlog.sync().unwrap();
let archive_dir = tempfile::TempDir::new().unwrap();
archive_segments(&store, archive_dir.path());
let beyond =
tranquil_store::eventlog::EventSequence::new(store.eventlog.max_seq().raw() + 1000);
let restore_dir = tempfile::TempDir::new().unwrap();
let err = recover_to_sequence(
backup_dir.path(),
archive_dir.path(),
beyond,
restore_dir.path(),
);
assert!(err.is_err(), "PITR beyond available eventlog must fail");
});
}
#[test]
fn restore_fails_cleanly_on_corrupted_backup() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(16).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
let restore_dir = tempfile::TempDir::new().unwrap();
let err = restore_from_backup(backup_dir.path(), restore_dir.path());
assert!(err.is_err(), "restore from corrupted backup must fail");
assert!(
!restore_dir.path().exists()
|| std::fs::read_dir(restore_dir.path())
.map(|mut entries| entries.next().is_none())
.unwrap_or(true),
"failed restore must not leave partial state at target"
);
});
}
#[test]
fn empty_store_backup_and_restore() {
with_runtime(|| {
let store = open_test_store();
store.metastore.persist().unwrap();
let backup_dir = tempfile::TempDir::new().unwrap();
let manifest = coordinator(&store)
.create_backup(backup_dir.path())
.unwrap();
assert_eq!(manifest.kind, BackupKind::Full);
let verify = verify_backup(backup_dir.path()).unwrap();
assert!(verify.is_healthy());
let restore_dir = tempfile::TempDir::new().unwrap();
restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap();
let restored_ms = Metastore::open(
&restore_dir.path().join("metastore"),
MetastoreConfig {
cache_size_bytes: 64 * 1024 * 1024,
},
);
assert!(
restored_ms.is_ok(),
"restored metastore from empty backup must open"
);
});
}
#[test]
fn incremental_restore_rejects_nonempty_target() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..10);
seed_events(&store, 5);
store.metastore.persist().unwrap();
let base_dir = tempfile::TempDir::new().unwrap();
let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap();
seed_blocks(&store, 10..20);
seed_events(&store, 5);
store.metastore.persist().unwrap();
let incr_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_incremental_backup(&base_manifest, incr_dir.path())
.unwrap();
let target = tempfile::TempDir::new().unwrap();
std::fs::write(target.path().join("garbage"), b"leftover").unwrap();
let err = restore_from_incremental(base_dir.path(), incr_dir.path(), target.path());
assert!(
err.is_err(),
"incremental restore into non-empty dir must fail"
);
});
}
#[test]
fn incremental_restore_rejects_mismatched_base() {
with_runtime(|| {
let store = open_test_store();
seed_blocks(&store, 0..20);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let base1_dir = tempfile::TempDir::new().unwrap();
let base1_manifest = coordinator(&store).create_backup(base1_dir.path()).unwrap();
seed_blocks(&store, 20..40);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let incr_dir = tempfile::TempDir::new().unwrap();
coordinator(&store)
.create_incremental_backup(&base1_manifest, incr_dir.path())
.unwrap();
seed_blocks(&store, 40..60);
seed_events(&store, 10);
store.metastore.persist().unwrap();
let base2_dir = tempfile::TempDir::new().unwrap();
coordinator(&store).create_backup(base2_dir.path()).unwrap();
let restore_dir = tempfile::TempDir::new().unwrap();
let err = restore_from_incremental(base2_dir.path(), incr_dir.path(), restore_dir.path());
assert!(
err.is_err(),
"incremental restore with wrong base must fail"
);
});
}