mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-20 08:16:04 +00:00
466 lines
16 KiB
Rust
466 lines
16 KiB
Rust
mod common;
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
|
|
use tranquil_store::backup::{BackupCoordinator, restore_from_backup, verify_backup};
|
|
use tranquil_store::blockstore::CidBytes;
|
|
use tranquil_store::sim_single_seed;
|
|
|
|
use common::{
|
|
Rng, TestStores, block_data, compact_all_sealed, open_test_stores, test_cid, test_cid_link,
|
|
test_did, test_handle, test_rev, test_uuid,
|
|
};
|
|
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
|
|
|
|
const CACHE_SIZE: u64 = 32 * 1024 * 1024;
|
|
|
|
fn open_soak_stores(base: &std::path::Path) -> TestStores {
|
|
open_test_stores(base, 4096, CACHE_SIZE)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SoakOracle {
|
|
live_blocks: HashMap<u32, u32>,
|
|
repos: HashSet<u64>,
|
|
event_count: u64,
|
|
}
|
|
|
|
impl SoakOracle {
|
|
fn new() -> Self {
|
|
Self {
|
|
live_blocks: HashMap::new(),
|
|
repos: HashSet::new(),
|
|
event_count: 0,
|
|
}
|
|
}
|
|
|
|
fn put_block(&mut self, seed: u32) {
|
|
*self.live_blocks.entry(seed).or_insert(0) += 1;
|
|
}
|
|
|
|
fn delete_block(&mut self, seed: u32) -> bool {
|
|
match self.live_blocks.get_mut(&seed) {
|
|
Some(rc) if *rc > 0 => {
|
|
*rc -= 1;
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn add_repo(&mut self, idx: u64) {
|
|
self.repos.insert(idx);
|
|
}
|
|
|
|
fn add_event(&mut self) {
|
|
self.event_count += 1;
|
|
}
|
|
|
|
fn live_block_seeds(&self) -> Vec<u32> {
|
|
self.live_blocks
|
|
.iter()
|
|
.filter(|&(_, rc)| *rc > 0)
|
|
.map(|(&s, _)| s)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum SoakOp {
|
|
PutBlocks { count: u32 },
|
|
DeleteBlocks { count: u32 },
|
|
ReadBlocks { count: u32 },
|
|
CreateRepo,
|
|
AppendEvent,
|
|
CompactGc,
|
|
Backup,
|
|
CrashRecover,
|
|
}
|
|
|
|
fn generate_ops(rng: &mut Rng, total: usize) -> Vec<SoakOp> {
|
|
(0..total)
|
|
.map(|_| {
|
|
let roll = rng.range_u32(100);
|
|
match roll {
|
|
0..30 => SoakOp::PutBlocks {
|
|
count: rng.range_u32(10) + 1,
|
|
},
|
|
30..45 => SoakOp::DeleteBlocks {
|
|
count: rng.range_u32(5) + 1,
|
|
},
|
|
45..65 => SoakOp::ReadBlocks {
|
|
count: rng.range_u32(10) + 1,
|
|
},
|
|
65..75 => SoakOp::CreateRepo,
|
|
75..85 => SoakOp::AppendEvent,
|
|
85..92 => SoakOp::CompactGc,
|
|
92..97 => SoakOp::Backup,
|
|
_ => SoakOp::CrashRecover,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn verify_integrity(stores: &TestStores, oracle: &SoakOracle) {
|
|
oracle.live_block_seeds().iter().for_each(|&seed| {
|
|
let data = stores.blockstore.get_block_sync(&test_cid(seed)).unwrap();
|
|
assert!(
|
|
data.is_some(),
|
|
"soak: live block seed={seed} must be readable"
|
|
);
|
|
assert_eq!(
|
|
&data.unwrap()[..4],
|
|
&seed.to_le_bytes(),
|
|
"soak: live block seed={seed} data mismatch"
|
|
);
|
|
});
|
|
|
|
oracle.repos.iter().for_each(|&idx| {
|
|
let uid = test_uuid(idx);
|
|
let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap();
|
|
assert!(meta.is_some(), "soak: persisted repo idx={idx} must exist");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "wall-clock soak, 30min runtime. Reenable once we mock time for deterministic soak"]
|
|
fn sim_soak_continuous_operations_with_crash_recovery() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let _guard = rt.enter();
|
|
|
|
let seed = sim_single_seed().unwrap_or(42);
|
|
let mut rng = Rng::new(seed);
|
|
|
|
let op_count = match std::env::var("TRANQUIL_SOAK_OPS") {
|
|
Ok(v) => v.parse::<usize>().unwrap_or(10_000),
|
|
Err(_) => 10_000,
|
|
};
|
|
|
|
let ops = generate_ops(&mut rng, op_count);
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let mut stores = Some(open_soak_stores(dir.path()));
|
|
let mut oracle = SoakOracle::new();
|
|
let mut block_counter: u32 = 0;
|
|
let mut repo_counter: u64 = 0;
|
|
let mut crash_count: u32 = 0;
|
|
let mut backup_count: u32 = 0;
|
|
let mut last_persist_repos: HashSet<u64> = HashSet::new();
|
|
|
|
ops.iter().enumerate().for_each(|(op_idx, op)| {
|
|
if matches!(op, SoakOp::CrashRecover) {
|
|
{
|
|
let s = stores.as_ref().unwrap();
|
|
s.metastore.persist().unwrap();
|
|
s.eventlog.sync().unwrap();
|
|
}
|
|
last_persist_repos = oracle.repos.clone();
|
|
|
|
stores.take();
|
|
stores = Some(open_soak_stores(dir.path()));
|
|
crash_count += 1;
|
|
|
|
let fresh = stores.as_ref().unwrap();
|
|
oracle.live_block_seeds().iter().for_each(|&bseed| {
|
|
let data = fresh.blockstore.get_block_sync(&test_cid(bseed)).unwrap();
|
|
assert!(
|
|
data.is_some(),
|
|
"soak: after crash #{crash_count} (op={op_idx}), live block seed={bseed} must survive"
|
|
);
|
|
});
|
|
|
|
last_persist_repos.iter().for_each(|&idx| {
|
|
let uid = test_uuid(idx);
|
|
let meta = fresh.metastore.repo_ops().get_repo_meta(uid).unwrap();
|
|
assert!(
|
|
meta.is_some(),
|
|
"soak: after crash #{crash_count} (op={op_idx}), persisted repo idx={idx} must survive"
|
|
);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
let s = stores.as_ref().unwrap();
|
|
match op {
|
|
SoakOp::PutBlocks { count } => {
|
|
let base = block_counter;
|
|
let blocks: Vec<(CidBytes, Vec<u8>)> = (0..*count)
|
|
.map(|j| {
|
|
let bseed = base + j;
|
|
(test_cid(bseed), block_data(bseed))
|
|
})
|
|
.collect();
|
|
if s.blockstore.put_blocks_blocking(blocks).is_ok() {
|
|
(base..base + count).for_each(|bseed| oracle.put_block(bseed));
|
|
}
|
|
block_counter = base + count;
|
|
}
|
|
SoakOp::DeleteBlocks { count } => {
|
|
let candidates: Vec<u32> = oracle.live_block_seeds();
|
|
let to_delete: Vec<u32> =
|
|
candidates.iter().take(*count as usize).copied().collect();
|
|
let cids: Vec<CidBytes> = to_delete.iter().map(|&bseed| test_cid(bseed)).collect();
|
|
if !cids.is_empty()
|
|
&& s.blockstore.apply_commit_blocking(vec![], cids).is_ok()
|
|
{
|
|
to_delete.iter().for_each(|&bseed| {
|
|
let _ = oracle.delete_block(bseed);
|
|
});
|
|
}
|
|
}
|
|
SoakOp::ReadBlocks { count } => {
|
|
let live = oracle.live_block_seeds();
|
|
live.iter().take(*count as usize).for_each(|&bseed| {
|
|
let data = s.blockstore.get_block_sync(&test_cid(bseed)).unwrap();
|
|
assert!(
|
|
data.is_some(),
|
|
"soak op={op_idx}: live block seed={bseed} must be readable"
|
|
);
|
|
});
|
|
}
|
|
SoakOp::CreateRepo => {
|
|
let idx = repo_counter;
|
|
repo_counter += 1;
|
|
let uid = test_uuid(idx);
|
|
let did = test_did(idx);
|
|
let handle = test_handle(idx);
|
|
let cid_link = test_cid_link((idx & 0xFF) as u8);
|
|
s.metastore
|
|
.repo_ops()
|
|
.create_repo(
|
|
s.metastore.database(),
|
|
uid,
|
|
&did,
|
|
&handle,
|
|
&cid_link,
|
|
&test_rev(idx, 0),
|
|
)
|
|
.unwrap();
|
|
oracle.add_repo(idx);
|
|
}
|
|
SoakOp::AppendEvent => {
|
|
let event_idx = oracle.event_count;
|
|
let did = test_did(event_idx);
|
|
let event = SequencedEvent {
|
|
seq: SequenceNumber::from_raw(0),
|
|
did: did.clone(),
|
|
created_at: chrono::Utc::now(),
|
|
event_type: 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(test_rev(event_idx, 0)),
|
|
};
|
|
s.eventlog
|
|
.append_event(&did, RepoEventType::Commit, &event)
|
|
.unwrap();
|
|
oracle.add_event();
|
|
}
|
|
SoakOp::CompactGc => {
|
|
let _ = s.blockstore.apply_commit_blocking(vec![], vec![]);
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
compact_all_sealed(&s.blockstore);
|
|
}
|
|
SoakOp::Backup => {
|
|
s.metastore.persist().unwrap();
|
|
s.eventlog.sync().unwrap();
|
|
|
|
let backup_path = dir.path().join("_backup");
|
|
let restore_path = dir.path().join("_restore");
|
|
let _ = std::fs::remove_dir_all(&backup_path);
|
|
let _ = std::fs::remove_dir_all(&restore_path);
|
|
std::fs::create_dir_all(&backup_path).unwrap();
|
|
|
|
let coordinator =
|
|
BackupCoordinator::new(&s.blockstore, &s.eventlog, &s.metastore);
|
|
match coordinator.create_backup(&backup_path) {
|
|
Ok(manifest) => {
|
|
let verify_result = verify_backup(&backup_path).unwrap();
|
|
assert!(
|
|
verify_result.is_healthy(),
|
|
"soak op={op_idx}: backup must be healthy after {backup_count} backups"
|
|
);
|
|
|
|
let _ = restore_from_backup(&backup_path, &restore_path);
|
|
|
|
let _ = std::fs::remove_dir_all(&backup_path);
|
|
let _ = std::fs::remove_dir_all(&restore_path);
|
|
|
|
backup_count += 1;
|
|
let _ = manifest;
|
|
}
|
|
Err(_) => {
|
|
let _ = std::fs::remove_dir_all(&backup_path);
|
|
}
|
|
}
|
|
}
|
|
SoakOp::CrashRecover => unreachable!(),
|
|
}
|
|
});
|
|
|
|
let s = stores.as_ref().unwrap();
|
|
s.metastore.persist().unwrap();
|
|
s.eventlog.sync().unwrap();
|
|
|
|
verify_integrity(s, &oracle);
|
|
|
|
let live = oracle.live_block_seeds();
|
|
let total_blocks = block_counter;
|
|
let total_repos = repo_counter;
|
|
let total_events = oracle.event_count;
|
|
|
|
assert!(total_blocks > 0, "soak test must have written blocks");
|
|
assert!(
|
|
!live.is_empty(),
|
|
"soak test must have live blocks at the end"
|
|
);
|
|
|
|
eprintln!(
|
|
"soak test complete: seed={seed} ops={op_count} blocks_written={total_blocks} \
|
|
live_blocks={} repos={total_repos} events={total_events} crashes={crash_count} \
|
|
backups={backup_count}",
|
|
live.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sim_soak_concurrent_writers_with_gc_and_reads() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let _guard = rt.enter();
|
|
|
|
let seed = sim_single_seed().unwrap_or(99);
|
|
let duration_ms: u64 = match std::env::var("TRANQUIL_SOAK_DURATION_MS") {
|
|
Ok(v) => v.parse().unwrap_or(5_000),
|
|
Err(_) => 5_000,
|
|
};
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let stores = open_soak_stores(dir.path());
|
|
let stop = AtomicBool::new(false);
|
|
let block_counter = AtomicU64::new(0);
|
|
let ops_counter = AtomicU64::new(0);
|
|
|
|
let initial_blocks: Vec<(CidBytes, Vec<u8>)> =
|
|
(0u32..100).map(|i| (test_cid(i), block_data(i))).collect();
|
|
stores
|
|
.blockstore
|
|
.put_blocks_blocking(initial_blocks)
|
|
.unwrap();
|
|
block_counter.store(100, Ordering::SeqCst);
|
|
|
|
std::thread::scope(|s| {
|
|
let writer = s.spawn(|| {
|
|
std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).for_each(|()| {
|
|
let base = block_counter.fetch_add(5, Ordering::SeqCst) as u32;
|
|
let batch: Vec<(CidBytes, Vec<u8>)> = (base..base + 5)
|
|
.map(|i| (test_cid(i), block_data(i)))
|
|
.collect();
|
|
let _ = stores.blockstore.put_blocks_blocking(batch);
|
|
ops_counter.fetch_add(5, Ordering::Relaxed);
|
|
std::thread::sleep(std::time::Duration::from_micros(100));
|
|
});
|
|
});
|
|
|
|
let deleter = s.spawn(|| {
|
|
let mut rng_d = Rng::new(seed + 1);
|
|
std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).for_each(|()| {
|
|
let target = rng_d.range_u32(50);
|
|
let _ = stores
|
|
.blockstore
|
|
.apply_commit_blocking(vec![], vec![test_cid(target)]);
|
|
ops_counter.fetch_add(1, Ordering::Relaxed);
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
});
|
|
});
|
|
|
|
let reader = s.spawn(|| {
|
|
let mut rng_r = Rng::new(seed + 2);
|
|
std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).fold(
|
|
0u64,
|
|
|read_failures, ()| {
|
|
let target = rng_r.range_u32(80);
|
|
let inc = match stores.blockstore.get_block_sync(&test_cid(target)) {
|
|
Ok(Some(data)) => {
|
|
assert_eq!(
|
|
&data[..4],
|
|
&target.to_le_bytes(),
|
|
"reader: block {target} data mismatch during concurrent ops"
|
|
);
|
|
0
|
|
}
|
|
Ok(None) | Err(_) => 1,
|
|
};
|
|
ops_counter.fetch_add(1, Ordering::Relaxed);
|
|
std::thread::sleep(std::time::Duration::from_micros(50));
|
|
read_failures + inc
|
|
},
|
|
)
|
|
});
|
|
|
|
let gc_thread = s.spawn(|| {
|
|
std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).fold(
|
|
0u32,
|
|
|gc_rounds, ()| {
|
|
let _ = stores.blockstore.apply_commit_blocking(vec![], vec![]);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
if let Ok(files) = stores.blockstore.list_data_files() {
|
|
files
|
|
.iter()
|
|
.copied()
|
|
.take(files.len().saturating_sub(1))
|
|
.for_each(|fid| {
|
|
let _ = stores.blockstore.compact_file(fid, 0);
|
|
});
|
|
}
|
|
ops_counter.fetch_add(1, Ordering::Relaxed);
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
gc_rounds.saturating_add(1)
|
|
},
|
|
)
|
|
});
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(duration_ms));
|
|
stop.store(true, Ordering::Relaxed);
|
|
|
|
writer.join().unwrap();
|
|
deleter.join().unwrap();
|
|
let _read_failures = reader.join().unwrap();
|
|
let gc_rounds = gc_thread.join().unwrap();
|
|
|
|
let total_ops = ops_counter.load(Ordering::SeqCst);
|
|
let final_block_count = block_counter.load(Ordering::SeqCst);
|
|
|
|
assert!(gc_rounds > 0, "soak: gc must have run at least once");
|
|
assert!(
|
|
total_ops > 100,
|
|
"soak: must have executed significant operations"
|
|
);
|
|
|
|
(50u32..80).for_each(|i| {
|
|
let data = stores.blockstore.get_block_sync(&test_cid(i)).unwrap();
|
|
assert!(
|
|
data.is_some(),
|
|
"soak: block {i} (never deleted) must be present after concurrent ops"
|
|
);
|
|
assert_eq!(
|
|
&data.unwrap()[..4],
|
|
&i.to_le_bytes(),
|
|
"soak: block {i} content integrity check"
|
|
);
|
|
});
|
|
|
|
eprintln!(
|
|
"concurrent soak complete: seed={seed} duration={duration_ms}ms total_ops={total_ops} \
|
|
blocks_allocated={final_block_count} gc_rounds={gc_rounds}"
|
|
);
|
|
});
|
|
}
|