Files
tranquil-pds/crates/tranquil-store/tests/sim_blockstore.rs
T
Lewis 4015217a2e feat(store): Clock trait for DST
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 23:46:22 +03:00

750 lines
26 KiB
Rust

mod common;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use rayon::prelude::*;
use tranquil_store::blockstore::hash_index::{BlockIndex, CheckpointPositions};
use tranquil_store::blockstore::{
BLOCK_RECORD_OVERHEAD, BlockLocation, BlockOffset, BlockStoreConfig, BlockStoreReader,
CidBytes, CommitEpoch, DEFAULT_MAX_FILE_SIZE, DataFileId, DataFileManager, DataFileWriter,
GroupCommitConfig, HINT_RECORD_SIZE, HintFileWriter, HintOffset, TranquilBlockStore,
WallClockMs, WriteCursor, hint_file_path,
};
use tranquil_store::{
FaultConfig, OpenOptions, SimClock, SimulatedIO, StorageIO, SyncReorderWindow, sim_seed_range,
};
use common::{Rng, advance_epoch, block_data, test_cid, with_runtime};
struct SimHarness {
sim: Arc<SimulatedIO>,
data_dir: &'static Path,
}
impl SimHarness {
fn pristine(seed: u64) -> Self {
let sim = Arc::new(SimulatedIO::pristine(seed));
let data_dir = Path::new("/data");
sim.mkdir(data_dir).unwrap();
sim.sync_dir(data_dir).unwrap();
Self { sim, data_dir }
}
fn ensure_data_file(&self, file_id: DataFileId) -> BlockOffset {
let manager = DataFileManager::with_default_max_size(
Arc::clone(&self.sim),
self.data_dir.to_path_buf(),
);
let handle = manager.open_for_append(file_id).unwrap();
let fd = handle.fd();
let file_size = self.sim.file_size(fd).unwrap();
match file_size {
0 => {
let w = DataFileWriter::new(&*self.sim, fd, file_id).unwrap();
w.sync().unwrap();
self.sim.sync_dir(self.data_dir).unwrap();
w.position()
}
n => BlockOffset::new(n),
}
}
fn write_blocks_with_hints(
&self,
file_id: DataFileId,
start_pos: BlockOffset,
seeds: std::ops::Range<u16>,
data_size: usize,
sync: bool,
) -> (BlockOffset, Vec<(CidBytes, BlockLocation)>) {
let path = self.data_dir.join(format!("{file_id}.tqb"));
let fd = self.sim.open(&path, OpenOptions::read_write()).unwrap();
let mut writer = DataFileWriter::resume(&*self.sim, fd, file_id, start_pos);
let hint_path = hint_file_path(self.data_dir, file_id);
let hint_fd = self
.sim
.open(&hint_path, OpenOptions::read_write())
.unwrap();
let hint_size = self.sim.file_size(hint_fd).unwrap();
let mut hint_writer =
HintFileWriter::resume(&*self.sim, hint_fd, HintOffset::new(hint_size));
let entries: Vec<_> = seeds
.map(|seed| {
let cid = test_cid(seed as u32);
let data = vec![seed as u8; data_size];
let loc = writer.append_block(&cid, &data).unwrap();
hint_writer.append_hint(&cid, &loc).unwrap();
(cid, loc)
})
.collect();
if sync {
writer.sync().unwrap();
hint_writer.sync().unwrap();
self.sim.sync_dir(self.data_dir).unwrap();
}
let pos = writer.position();
let _ = self.sim.close(hint_fd);
let _ = self.sim.close(fd);
(pos, entries)
}
fn index_entries(
index: &BlockIndex,
entries: &[(CidBytes, BlockLocation)],
cursor: WriteCursor,
) {
index
.batch_put(
entries,
&[],
cursor,
CommitEpoch::zero(),
WallClockMs::new(0),
)
.unwrap();
let positions = CheckpointPositions::single(
cursor.file_id,
HintOffset::new(entries.len() as u64 * HINT_RECORD_SIZE as u64),
);
index
.write_checkpoint_with_positions(CommitEpoch::zero(), &positions)
.unwrap();
}
fn make_reader(&self, index: Arc<BlockIndex>) -> BlockStoreReader<Arc<SimulatedIO>> {
let manager = Arc::new(DataFileManager::with_default_max_size(
Arc::clone(&self.sim),
self.data_dir.to_path_buf(),
));
BlockStoreReader::new(index, manager)
}
}
#[test]
fn sim_crash_during_data_file_write_before_fsync() {
sim_seed_range().into_par_iter().for_each(|seed| {
let h = SimHarness::pristine(seed);
let file_id = DataFileId::new(0);
let start_pos = h.ensure_data_file(file_id);
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(20) + 5) as u16;
let _ = h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, false);
h.sim.crash();
let index_dir = tempfile::TempDir::new().unwrap();
let rebuilt = BlockIndex::open(index_dir.path()).unwrap();
rebuilt
.rebuild_from_data_files(&*h.sim, h.data_dir)
.unwrap();
(0..block_count).for_each(|i| {
let cid = test_cid(i as u32);
assert!(
rebuilt.get(&cid).is_none(),
"seed={seed} unsynced block {i} must not appear in index after crash before fsync"
);
});
});
}
#[test]
fn sim_crash_after_fsync_before_index_update() {
sim_seed_range().into_par_iter().for_each(|seed| {
let h = SimHarness::pristine(seed);
let file_id = DataFileId::new(0);
let start_pos = h.ensure_data_file(file_id);
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(20) + 5) as u16;
let (_end_pos, entries) =
h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true);
h.sim.crash();
let index_dir = tempfile::TempDir::new().unwrap();
let rebuilt = BlockIndex::open(index_dir.path()).unwrap();
rebuilt.rebuild_from_hints(&*h.sim, h.data_dir).unwrap();
let idx = Arc::new(rebuilt);
let reader = h.make_reader(Arc::clone(&idx));
entries.iter().for_each(|(cid, _)| {
assert!(
idx.get(cid).is_some(),
"seed={seed} synced block must be recoverable from hints after crash before index update"
);
let data = reader.get(cid).unwrap();
assert!(data.is_some(), "seed={seed} synced block must be readable");
});
});
}
#[test]
fn sim_crash_after_index_update() {
sim_seed_range().into_par_iter().for_each(|seed| {
let h = SimHarness::pristine(seed);
let file_id = DataFileId::new(0);
let start_pos = h.ensure_data_file(file_id);
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(20) + 5) as u16;
let (end_pos, entries) =
h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true);
let index_dir = tempfile::TempDir::new().unwrap();
let index = BlockIndex::open(index_dir.path()).unwrap();
SimHarness::index_entries(
&index,
&entries,
WriteCursor {
file_id,
offset: end_pos,
},
);
drop(index);
h.sim.crash();
let reopened = BlockIndex::open(index_dir.path()).unwrap();
let idx = Arc::new(reopened);
let reader = h.make_reader(Arc::clone(&idx));
entries.iter().for_each(|(cid, _)| {
assert!(
idx.get(cid).is_some(),
"seed={seed} fully committed block must survive crash after index update"
);
let data = reader.get(cid).unwrap();
assert!(data.is_some(), "seed={seed} block must be readable");
});
});
}
#[test]
fn sim_partial_index_crash_recovers_via_cursor() {
sim_seed_range().into_par_iter().for_each(|seed| {
let h = SimHarness::pristine(seed);
let file_id = DataFileId::new(0);
let start_pos = h.ensure_data_file(file_id);
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(20) + 5) as u16;
let indexed_count = (rng.range_u32(block_count as u32 - 1) + 1) as u16;
let (_end_pos, entries) =
h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true);
let indexed = &entries[..indexed_count as usize];
let cursor_end = indexed
.last()
.map(|(_, loc)| loc.offset.advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64()))
.unwrap_or(start_pos);
let index_dir = tempfile::TempDir::new().unwrap();
let index = BlockIndex::open(index_dir.path()).unwrap();
SimHarness::index_entries(
&index,
indexed,
WriteCursor {
file_id,
offset: cursor_end,
},
);
drop(index);
h.sim.crash();
let rebuilt_dir = tempfile::TempDir::new().unwrap();
let rebuilt = BlockIndex::open(rebuilt_dir.path()).unwrap();
rebuilt.rebuild_from_hints(&*h.sim, h.data_dir).unwrap();
let idx = Arc::new(rebuilt);
let reader = h.make_reader(Arc::clone(&idx));
(0..block_count).for_each(|i| {
let cid = test_cid(i as u32);
assert!(
idx.get(&cid).is_some(),
"seed={seed} synced block {i} must be recovered (indexed={indexed_count}, total={block_count})"
);
let data = reader.get(&cid).unwrap();
assert!(data.is_some(), "seed={seed} block {i} must be readable");
assert_eq!(
data.unwrap()[0],
i as u8,
"seed={seed} block {i} content mismatch"
);
});
});
}
#[test]
fn sim_no_acknowledged_block_lost() {
with_runtime(|| {
sim_seed_range().into_par_iter().for_each(|seed| {
let dir = tempfile::TempDir::new().unwrap();
let config = BlockStoreConfig {
data_dir: dir.path().join("data"),
index_dir: dir.path().join("index"),
max_file_size: DEFAULT_MAX_FILE_SIZE,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let block_count = ((seed % 30) + 5) as u32;
let blocks: Vec<(CidBytes, Vec<u8>)> = (0..block_count)
.map(|i| (test_cid(i), block_data(i)))
.collect();
let acked_cids: Vec<CidBytes> = blocks.iter().map(|(cid, _)| *cid).collect();
{
let store = TranquilBlockStore::open(config.clone()).unwrap();
store.put_blocks_blocking(blocks).unwrap();
}
let store = TranquilBlockStore::open(config).unwrap();
acked_cids.iter().enumerate().for_each(|(idx, cid)| {
let data = store.get_block_sync(cid).unwrap();
assert!(
data.is_some(),
"seed={seed} acknowledged block {idx} must be durable after reopen"
);
let expected = block_data(idx as u32);
assert_eq!(
&data.unwrap()[..],
&expected[..],
"seed={seed} block {idx} content mismatch"
);
});
});
});
}
#[test]
fn sim_hint_rebuild_matches_normal_index() {
sim_seed_range().into_par_iter().for_each(|seed| {
let h = SimHarness::pristine(seed);
let file_id = DataFileId::new(0);
let start_pos = h.ensure_data_file(file_id);
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(40) + 10) as u16;
let (end_pos, entries) =
h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true);
let normal_dir = tempfile::TempDir::new().unwrap();
let normal_index = BlockIndex::open(normal_dir.path()).unwrap();
SimHarness::index_entries(
&normal_index,
&entries,
WriteCursor {
file_id,
offset: end_pos,
},
);
let normal_snapshot: HashMap<CidBytes, BlockLocation> = entries
.iter()
.filter_map(|(cid, _)| normal_index.get(cid).map(|entry| (*cid, entry.location)))
.collect();
drop(normal_index);
let hint_dir = tempfile::TempDir::new().unwrap();
let hint_rebuilt = BlockIndex::open(hint_dir.path()).unwrap();
hint_rebuilt
.rebuild_from_hints(&*h.sim, h.data_dir)
.unwrap();
entries.iter().for_each(|(cid, _)| {
let rebuilt_entry = hint_rebuilt.get(cid);
assert!(
rebuilt_entry.is_some(),
"seed={seed} hint-rebuilt index must contain all blocks"
);
let rebuilt_loc = rebuilt_entry.unwrap().location;
let normal_loc = normal_snapshot.get(cid).unwrap();
assert_eq!(
rebuilt_loc.file_id, normal_loc.file_id,
"seed={seed} file_id mismatch"
);
assert_eq!(
rebuilt_loc.offset, normal_loc.offset,
"seed={seed} offset mismatch"
);
assert_eq!(
rebuilt_loc.length, normal_loc.length,
"seed={seed} length mismatch"
);
});
let data_dir2 = tempfile::TempDir::new().unwrap();
let data_rebuilt = BlockIndex::open(data_dir2.path()).unwrap();
data_rebuilt
.rebuild_from_data_files(&*h.sim, h.data_dir)
.unwrap();
entries.iter().for_each(|(cid, _)| {
let data_entry = data_rebuilt.get(cid);
assert!(
data_entry.is_some(),
"seed={seed} data-file-rebuilt index must contain all blocks"
);
let data_loc = data_entry.unwrap().location;
let normal_loc = normal_snapshot.get(cid).unwrap();
assert_eq!(
data_loc.file_id, normal_loc.file_id,
"seed={seed} data-rebuild file_id mismatch"
);
assert_eq!(
data_loc.offset, normal_loc.offset,
"seed={seed} data-rebuild offset mismatch"
);
assert_eq!(
data_loc.length, normal_loc.length,
"seed={seed} data-rebuild length mismatch"
);
});
});
}
#[test]
fn sim_concurrent_reads_during_compaction() {
with_runtime(|| {
sim_seed_range().into_par_iter().for_each(|seed| {
let dir = tempfile::TempDir::new().unwrap();
let small_file_size = 512u64;
let config = BlockStoreConfig {
data_dir: dir.path().join("data"),
index_dir: dir.path().join("index"),
max_file_size: small_file_size,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let store = TranquilBlockStore::open(config).unwrap();
let initial_count = ((seed % 10) + 5) as u32;
let blocks: Vec<(CidBytes, Vec<u8>)> = (0..initial_count)
.map(|i| (test_cid(i), block_data(i)))
.collect();
store.put_blocks_blocking(blocks).unwrap();
let delete_count =
((seed % initial_count as u64) + 1).min(initial_count as u64 - 1) as u32;
let deleted_cids: Vec<CidBytes> = (0..delete_count).map(test_cid).collect();
store
.apply_commit_blocking(vec![], deleted_cids.clone())
.unwrap();
advance_epoch(&store);
let live_cids: Vec<CidBytes> = (delete_count..initial_count).map(test_cid).collect();
let data_files = store.list_data_files().unwrap();
let sealed_files: Vec<DataFileId> = data_files
.iter()
.copied()
.take(data_files.len().saturating_sub(1))
.collect();
let read_store = store.clone();
let read_cids = live_cids.clone();
let reader_handle = std::thread::spawn(move || {
(0..50).for_each(|_| {
read_cids.iter().for_each(|cid| {
let _ = read_store.get_block_sync(cid);
});
});
});
sealed_files.iter().for_each(|&fid| {
let _ = store.compact_file(fid, 0);
});
reader_handle.join().unwrap();
live_cids.iter().for_each(|cid| {
let data = store.get_block_sync(cid).unwrap();
assert!(
data.is_some(),
"seed={seed} live block must survive compaction"
);
let expected_seed = u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]]);
assert_eq!(
&data.unwrap()[..4],
&expected_seed.to_le_bytes(),
"seed={seed} block content mismatch after compaction"
);
});
});
});
}
#[test]
fn sim_aggressive_faults_data_integrity() {
sim_seed_range().into_par_iter().for_each(|seed| {
let fault_config = FaultConfig::aggressive();
let sim = Arc::new(SimulatedIO::new(seed, fault_config));
let data_dir = Path::new("/data");
let Ok(()) = sim.mkdir(data_dir).and_then(|()| sim.sync_dir(data_dir)) else {
return;
};
let file_id = DataFileId::new(0);
let manager =
DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf());
let Ok(handle) = manager.open_for_append(file_id) else {
return;
};
let fd = handle.fd();
let writer_result = DataFileWriter::new(&*sim, fd, file_id);
let Ok(writer) = writer_result else { return };
let Ok(()) = writer.sync() else { return };
let Ok(()) = sim.sync_dir(data_dir) else {
return;
};
let start_pos = writer.position();
let _ = writer;
let _ = handle;
let mut rng = Rng::new(seed);
let block_count = (rng.range_u32(15) + 5) as u16;
let sync_at = (rng.range_u32(block_count as u32) + 1) as u16;
let _ = (|| -> Option<()> {
let path = data_dir.join(format!("{file_id}.tqb"));
let fd = sim.open(&path, OpenOptions::read_write()).ok()?;
let mut writer = DataFileWriter::resume(&*sim, fd, file_id, start_pos);
let hint_path = hint_file_path(data_dir, file_id);
let hint_fd = sim.open(&hint_path, OpenOptions::read_write()).ok()?;
let hint_size = sim.file_size(hint_fd).ok()?;
let mut hint_writer =
HintFileWriter::resume(&*sim, hint_fd, HintOffset::new(hint_size));
(0..sync_at).try_for_each(|i| {
let cid = test_cid(i as u32);
let data = vec![i as u8; 64];
let loc = writer.append_block(&cid, &data).ok()?;
hint_writer.append_hint(&cid, &loc).ok()?;
Some(())
})?;
writer.sync().ok()?;
hint_writer.sync().ok()?;
sim.sync_dir(data_dir).ok()?;
let _ = (sync_at..block_count).try_for_each(|i| {
let cid = test_cid(i as u32);
let data = vec![i as u8; 64];
let _ = writer.append_block(&cid, &data).ok()?;
Some(())
});
let _ = sim.close(hint_fd);
let _ = sim.close(fd);
Some(())
})();
sim.crash();
let index_dir = tempfile::TempDir::new().unwrap();
let Ok(rebuilt) = BlockIndex::open(index_dir.path()) else {
return;
};
let all_data_files =
tranquil_store::blockstore::list_files_by_extension(&*sim, data_dir, "tqb");
if let Ok(files) = all_data_files {
files.iter().for_each(|&fid| {
let path = data_dir.join(format!("{fid}.tqb"));
let Ok(fd) = sim.open(&path, OpenOptions::read_only_existing()) else {
return;
};
let file_size = sim.file_size(fd).unwrap_or(0);
let mut offset =
BlockOffset::new(tranquil_store::blockstore::BLOCK_HEADER_SIZE as u64);
let mut entries = Vec::new();
while let Ok(Some(tranquil_store::blockstore::ReadBlockRecord::Valid {
offset: blk_off,
cid_bytes,
data,
})) =
tranquil_store::blockstore::decode_block_record(&*sim, fd, offset, file_size)
{
let length = tranquil_store::blockstore::BlockLength::new(
u32::try_from(data.len()).unwrap(),
);
let loc = BlockLocation {
file_id: fid,
offset: blk_off,
length,
};
entries.push((cid_bytes, loc));
offset = blk_off.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64());
}
if !entries.is_empty() {
let cursor = WriteCursor {
file_id: fid,
offset: entries
.last()
.map(|(_, loc)| {
loc.offset
.advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64())
})
.unwrap_or(offset),
};
rebuilt
.batch_put(
&entries,
&[],
cursor,
CommitEpoch::zero(),
WallClockMs::new(0),
)
.ok();
}
let _ = sim.close(fd);
});
}
let idx = Arc::new(rebuilt);
let reader_manager = Arc::new(DataFileManager::with_default_max_size(
Arc::clone(&sim),
data_dir.to_path_buf(),
));
let reader = BlockStoreReader::new(Arc::clone(&idx), reader_manager);
(sync_at..block_count).for_each(|i| {
let cid = test_cid(i as u32);
if idx.get(&cid).is_some()
&& let Ok(Some(data)) = reader.get(&cid)
{
assert_eq!(
data[0], i as u8,
"seed={seed} unsynced-but-recovered block {i} data mismatch"
);
}
});
});
}
#[test]
fn sim_multi_file_rotation_crash_recovery() {
with_runtime(|| {
sim_seed_range().into_par_iter().for_each(|seed| {
let dir = tempfile::TempDir::new().unwrap();
let small_file_size = 512u64;
let config = BlockStoreConfig {
data_dir: dir.path().join("data"),
index_dir: dir.path().join("index"),
max_file_size: small_file_size,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let block_count = ((seed % 25) + 10) as u32;
let all_cids: Vec<CidBytes> = (0..block_count).map(test_cid).collect();
{
let store = TranquilBlockStore::open(config.clone()).unwrap();
(0..block_count)
.try_for_each(|i| store.put_blocks_blocking(vec![(test_cid(i), block_data(i))]))
.unwrap();
let files = store.list_data_files().unwrap();
assert!(
files.len() > 1,
"seed={seed} expected multiple data files from rotation, got {}",
files.len()
);
}
let store = TranquilBlockStore::open(config).unwrap();
all_cids.iter().enumerate().for_each(|(idx, cid)| {
let data = store.get_block_sync(cid).unwrap();
assert!(
data.is_some(),
"seed={seed} block {idx} must survive reopen across rotated files"
);
let expected = block_data(idx as u32);
assert_eq!(
&data.unwrap()[..],
&expected[..],
"seed={seed} block {idx} content mismatch after rotation recovery"
);
});
});
});
}
#[test]
fn sim_sync_reorder_loses_first_commit_durability() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let config = BlockStoreConfig {
data_dir: dir.path().join("data"),
index_dir: dir.path().join("index"),
max_file_size: DEFAULT_MAX_FILE_SIZE,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
let fault = FaultConfig {
sync_reorder_window: SyncReorderWindow(4),
..FaultConfig::none()
};
let sim: Arc<SimulatedIO> = Arc::new(SimulatedIO::new(706, fault));
let cid = test_cid(0);
let data = block_data(0);
{
let s = Arc::clone(&sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>, SimClock>::open_with_io(
config.clone(),
move || Arc::clone(&s),
sim.clock(),
)
.unwrap();
store
.put_blocks_blocking(vec![(cid, data.clone())])
.unwrap();
}
sim.crash();
let s = Arc::clone(&sim);
let store = TranquilBlockStore::<Arc<SimulatedIO>, SimClock>::open_with_io(
config,
move || Arc::clone(&s),
sim.clock(),
)
.unwrap();
match store.get_block_sync(&cid) {
Ok(Some(d)) => assert_eq!(&d[..], &data[..], "block content mismatch after crash"),
Ok(None) => panic!(
"durability bug: put_blocks_blocking returned Ok but block missing after crash"
),
Err(e) => panic!("durability bug: block read failed after crash: {e}"),
}
});
}