mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
update
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
/// Fuzz-style tests: randomized operation sequences, corruption injection,
|
||||
/// and crash-recovery stress testing.
|
||||
use bichon_blob::{Codec, Config, Engine};
|
||||
use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Number of iterations for each randomized test.
|
||||
const FUZZ_OPS: usize = 2000;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_key(i: u64) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
key[0..8].copy_from_slice(&i.to_le_bytes());
|
||||
key
|
||||
}
|
||||
|
||||
fn make_value(rng: &mut impl RngExt) -> Vec<u8> {
|
||||
let size = match rng.random_range(0..100) {
|
||||
0..=4 => rng.random_range(0..64), // tiny
|
||||
5..=9 => 0, // empty
|
||||
10..=79 => rng.random_range(64..4096), // small
|
||||
80..=89 => rng.random_range(4096..65536), // medium
|
||||
90..=94 => rng.random_range(65536..500_000), // large
|
||||
_ => rng.random_range(500_000..2_000_000), // xl (near max)
|
||||
};
|
||||
let mut v = vec![0u8; size];
|
||||
rng.fill(&mut v[..]);
|
||||
v
|
||||
}
|
||||
|
||||
fn random_codec(rng: &mut impl RngExt) -> Codec {
|
||||
match rng.random_range(0..4) {
|
||||
0 => Codec::None,
|
||||
1 => Codec::Zstd,
|
||||
_ => Codec::Lz4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz: random operation sequence ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzz_random_ops() {
|
||||
let mut rng = rand::rng();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.flush_interval_secs = 0; // manual flush only
|
||||
config.gc_interval_secs = 0;
|
||||
|
||||
let engine = Engine::open(dir.path(), config).unwrap();
|
||||
|
||||
// Oracle: track expected values in memory
|
||||
let mut oracle: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
|
||||
let mut next_key = 0u64;
|
||||
|
||||
for _ in 0..FUZZ_OPS {
|
||||
match rng.random_range(0..100) {
|
||||
// 45%: put new key
|
||||
0..=44 => {
|
||||
let key = make_key(next_key);
|
||||
next_key += 1;
|
||||
let value = make_value(&mut rng);
|
||||
let codec = random_codec(&mut rng);
|
||||
engine.put(key, &value, codec).unwrap();
|
||||
oracle.insert(key, value);
|
||||
}
|
||||
// 20%: put overwrite existing key
|
||||
45..=64 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
let value = make_value(&mut rng);
|
||||
let codec = random_codec(&mut rng);
|
||||
engine.put(key, &value, codec).unwrap();
|
||||
oracle.insert(key, value);
|
||||
}
|
||||
// 15%: read and verify
|
||||
65..=79 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
let expected = oracle.get(&key).unwrap();
|
||||
let got = engine.get(&key).unwrap();
|
||||
assert_eq!(got.as_ref(), Some(expected), "key mismatch on read");
|
||||
}
|
||||
// 10%: delete
|
||||
80..=89 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
engine.delete(&key).unwrap();
|
||||
oracle.remove(&key);
|
||||
let got = engine.get(&key).unwrap();
|
||||
assert_eq!(got, None, "deleted key should return None");
|
||||
}
|
||||
// 5%: read non-existent key
|
||||
90..=94 => {
|
||||
let key = make_key(next_key + rng.random_range(1000u64..10000));
|
||||
let got = engine.get(&key).unwrap();
|
||||
assert_eq!(got, None, "non-existent key should return None");
|
||||
}
|
||||
// 5%: flush
|
||||
_ => {
|
||||
engine.flush().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final verification: all oracle entries must match
|
||||
for (key, expected) in &oracle {
|
||||
let got = engine.get(key).unwrap();
|
||||
assert_eq!(got.as_ref(), Some(expected), "final verification: key mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz: crash + reopen cycle ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzz_crash_reopen_cycles() {
|
||||
let mut rng = rand::rng();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let dir_path = dir.path().to_path_buf();
|
||||
|
||||
let mut oracle: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
|
||||
let mut next_key = 0u64;
|
||||
let cycles = 20;
|
||||
|
||||
for _cycle in 0..cycles {
|
||||
// Open database
|
||||
let mut config = Config::default();
|
||||
config.flush_interval_secs = 0;
|
||||
config.gc_interval_secs = 0;
|
||||
let engine = Engine::open(&dir_path, config).unwrap();
|
||||
|
||||
// Do some work
|
||||
let ops = rng.random_range(50..200);
|
||||
for _ in 0..ops {
|
||||
match rng.random_range(0..100) {
|
||||
0..=50 => {
|
||||
let key = make_key(next_key);
|
||||
next_key += 1;
|
||||
let value = make_value(&mut rng);
|
||||
if engine.put(key, &value, Codec::Zstd).is_ok() {
|
||||
oracle.insert(key, value);
|
||||
}
|
||||
}
|
||||
51..=65 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
engine.delete(&key).unwrap();
|
||||
oracle.remove(&key);
|
||||
}
|
||||
66..=85 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
let expected = oracle.get(&key).unwrap();
|
||||
if let Ok(Some(got)) = engine.get(&key) {
|
||||
assert_eq!(&got, expected, "pre-crash read mismatch");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = engine.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate crash: drop without shutdown
|
||||
drop(engine);
|
||||
}
|
||||
|
||||
// Final reopen: all oracle entries must be intact
|
||||
let config = Config::default();
|
||||
let engine = Engine::open(&dir_path, config).unwrap();
|
||||
for (key, expected) in &oracle {
|
||||
let got = engine.get(key).unwrap();
|
||||
assert_eq!(got.as_ref(), Some(expected), "after {} crash cycles", cycles);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz: batch operations ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzz_batch_ops() {
|
||||
let mut rng = rand::rng();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let config = Config::default();
|
||||
let engine = Engine::open(dir.path(), config).unwrap();
|
||||
|
||||
let mut oracle: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
|
||||
let mut next_key = 0u64;
|
||||
|
||||
for _ in 0..200 {
|
||||
match rng.random_range(0..100) {
|
||||
// 50%: batch write
|
||||
0..=49 => {
|
||||
let batch_size = rng.random_range(1..30);
|
||||
let entries: Vec<_> = (0..batch_size)
|
||||
.map(|_| {
|
||||
let key = make_key(next_key);
|
||||
next_key += 1;
|
||||
let value = make_value(&mut rng);
|
||||
oracle.insert(key, value.clone());
|
||||
(key, value, Codec::Zstd)
|
||||
})
|
||||
.collect();
|
||||
engine.put_batch(&entries).unwrap();
|
||||
}
|
||||
// 30%: verify random subset
|
||||
50..=79 => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let n = rng.random_range(1..=20.min(oracle.len()));
|
||||
for _ in 0..n {
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let (key, expected) = oracle.iter().nth(idx).unwrap();
|
||||
let got = engine.get(key).unwrap();
|
||||
assert_eq!(got.as_ref(), Some(expected));
|
||||
}
|
||||
}
|
||||
// 20%: batch delete
|
||||
_ => {
|
||||
if oracle.is_empty() { continue; }
|
||||
let n = rng.random_range(1..=20.min(oracle.len()));
|
||||
let keys: Vec<[u8; 32]> = (0..n)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..oracle.len());
|
||||
let key = *oracle.iter().nth(idx).unwrap().0;
|
||||
oracle.remove(&key);
|
||||
key
|
||||
})
|
||||
.collect();
|
||||
engine.delete_batch(&keys).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (key, expected) in &oracle {
|
||||
let got = engine.get(key).unwrap();
|
||||
assert_eq!(got.as_ref(), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz: GC stress ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzz_gc_stress() {
|
||||
let mut rng = rand::rng();
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
config.gc_deleted_ratio = 0.1; // aggressive GC trigger
|
||||
let engine = Engine::open(dir.path(), config).unwrap();
|
||||
|
||||
let mut oracle: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
|
||||
let mut next_key = 0u64;
|
||||
|
||||
for round in 0..10 {
|
||||
// Write a batch of keys
|
||||
let n = rng.random_range(50..150);
|
||||
let mut round_keys: Vec<[u8; 32]> = Vec::new();
|
||||
let value_size = rng.random_range(100..10000);
|
||||
let value: Vec<u8> = (0..value_size).map(|_| rng.random::<u8>()).collect();
|
||||
|
||||
for _ in 0..n {
|
||||
let key = make_key(next_key);
|
||||
next_key += 1;
|
||||
engine.put(key, &value, Codec::None).unwrap();
|
||||
oracle.insert(key, value.clone());
|
||||
round_keys.push(key);
|
||||
}
|
||||
|
||||
// Delete some fraction
|
||||
let delete_frac: f64 = rng.random_range(0.2..0.8);
|
||||
let delete_count = (round_keys.len() as f64 * delete_frac) as usize;
|
||||
for _ in 0..delete_count {
|
||||
let idx = rng.random_range(0..round_keys.len());
|
||||
let key = round_keys.swap_remove(idx);
|
||||
engine.delete(&key).unwrap();
|
||||
oracle.remove(&key);
|
||||
}
|
||||
|
||||
// Seal and run GC
|
||||
engine.seal_active_segment().unwrap();
|
||||
let _ = engine.gc().unwrap();
|
||||
|
||||
// Verify all remaining oracle entries
|
||||
for (key, expected) in &oracle {
|
||||
let got = engine.get(key).unwrap();
|
||||
assert_eq!(
|
||||
got.as_ref(),
|
||||
Some(expected),
|
||||
"GC round {}: key mismatch",
|
||||
round
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz: bitflip corruption resilience ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fuzz_corruption_resilience() {
|
||||
let mut rng = rand::rng();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let dir_path = dir.path().to_path_buf();
|
||||
|
||||
let config = Config::default();
|
||||
let engine = Engine::open(&dir_path, config).unwrap();
|
||||
|
||||
// Write known data
|
||||
let mut good_keys: Vec<[u8; 32]> = Vec::new();
|
||||
let n = 100u64;
|
||||
for i in 0..n {
|
||||
let key = make_key(i);
|
||||
let value = vec![i as u8; 2048];
|
||||
engine.put(key, &value, Codec::None).unwrap();
|
||||
good_keys.push(key);
|
||||
}
|
||||
engine.flush().unwrap();
|
||||
|
||||
// Seal so segment file is durable on disk
|
||||
engine.seal_active_segment().unwrap();
|
||||
engine.shutdown().unwrap();
|
||||
drop(engine);
|
||||
|
||||
// Find segment files and corrupt random bytes
|
||||
let seg_dir = dir_path.join("segments");
|
||||
let mut seg_files: Vec<_> = std::fs::read_dir(&seg_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".seg")
|
||||
})
|
||||
.collect();
|
||||
seg_files.sort_by_key(|e| e.file_name());
|
||||
|
||||
// Corrupt 3 random bytes in the first segment
|
||||
if let Some(seg) = seg_files.first() {
|
||||
let path = seg.path();
|
||||
let mut data = std::fs::read(&path).unwrap();
|
||||
if data.len() > 100 {
|
||||
for _ in 0..3 {
|
||||
let pos = rng.random_range(50..data.len());
|
||||
data[pos] ^= 0xFF; // flip all bits
|
||||
}
|
||||
std::fs::write(&path, &data).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Reopen: recovery must succeed (not panic), even if some keys are lost
|
||||
let config = Config::default();
|
||||
let engine = Engine::open(&dir_path, config).unwrap();
|
||||
|
||||
// At least some uncorrupted keys should still be readable
|
||||
let mut readable = 0;
|
||||
let mut corrupted = 0;
|
||||
for key in &good_keys {
|
||||
match engine.get(key) {
|
||||
Ok(Some(_)) => readable += 1,
|
||||
Ok(None) => { /* key might be lost due to corruption */ }
|
||||
Err(_) => corrupted += 1,
|
||||
}
|
||||
}
|
||||
// The vast majority should still be ok (corruption only hit 3 bytes in one segment)
|
||||
assert!(
|
||||
readable + corrupted > 0,
|
||||
"at least some outcomes should be observable"
|
||||
);
|
||||
assert!(
|
||||
readable > n as usize / 2,
|
||||
"majority of keys should survive localized corruption ({} of {})",
|
||||
readable,
|
||||
n
|
||||
);
|
||||
}
|
||||
@@ -229,7 +229,7 @@ fn test_batch_write_persistence() {
|
||||
fn test_invalid_config_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.compact_threshold = 0;
|
||||
config.compression_level = -1;
|
||||
assert!(Engine::open(dir.path(), config).is_err());
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -358,3 +358,88 @@ fn test_meta_bin_durability() {
|
||||
let read = engine.get(&[0x42u8; 32]).unwrap();
|
||||
assert_eq!(read, Some(b"durable".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_deletes_empty_segment() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let engine = Engine::open(dir.path(), Config::default()).unwrap();
|
||||
|
||||
// Write data, seal, then delete everything so the sealed segment
|
||||
// becomes 100% garbage. GC should remove the segment file entirely.
|
||||
let n = 50u64;
|
||||
for i in 0..n {
|
||||
let mut key = [0u8; 32];
|
||||
key[0..8].copy_from_slice(&i.to_le_bytes());
|
||||
engine.put(key, &vec![b'X'; 4096], Codec::None).unwrap();
|
||||
}
|
||||
|
||||
// Force seal so the segment becomes a GC candidate.
|
||||
let sealed_id = engine.seal_active_segment().unwrap();
|
||||
|
||||
// Delete all keys — the sealed segment is now entirely garbage.
|
||||
let keys: Vec<[u8; 32]> = (0..n)
|
||||
.map(|i| {
|
||||
let mut key = [0u8; 32];
|
||||
key[0..8].copy_from_slice(&i.to_le_bytes());
|
||||
key
|
||||
})
|
||||
.collect();
|
||||
engine.delete_batch(&keys).unwrap();
|
||||
|
||||
// Run GC — this should empty and then delete the sealed segment.
|
||||
let result = engine.gc().unwrap();
|
||||
assert!(result.is_some(), "GC should have found a candidate");
|
||||
let stats = result.unwrap();
|
||||
assert_eq!(stats.segment_id, sealed_id);
|
||||
assert_eq!(stats.bytes_after, 0);
|
||||
|
||||
// Segment file must be deleted.
|
||||
let seg_path = dir
|
||||
.path()
|
||||
.join("segments")
|
||||
.join(format!("{:08}.seg", sealed_id));
|
||||
assert!(
|
||||
!seg_path.exists(),
|
||||
"emptied segment file should have been deleted, but {:?} exists",
|
||||
seg_path
|
||||
);
|
||||
|
||||
// Subsequent reads / writes must still work (no corruption).
|
||||
let new_key = [0x99u8; 32];
|
||||
engine
|
||||
.put(new_key, b"post-gc data", Codec::None)
|
||||
.unwrap();
|
||||
let result = engine.get(&new_key).unwrap();
|
||||
assert_eq!(result, Some(b"post-gc data".to_vec()));
|
||||
|
||||
// Engine stats should be consistent.
|
||||
let stats = engine.stats().unwrap();
|
||||
assert_eq!(stats.total_keys, 1);
|
||||
|
||||
// Shutdown and reopen — persistence must be intact.
|
||||
drop(engine);
|
||||
let engine = Engine::open(dir.path(), Config::default()).unwrap();
|
||||
let result = engine.get(&new_key).unwrap();
|
||||
assert_eq!(result, Some(b"post-gc data".to_vec()));
|
||||
let result = engine.get(&keys[0]).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_lock_prevents_concurrent_open() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _engine1 = Engine::open(dir.path(), Config::default()).unwrap();
|
||||
let result = Engine::open(dir.path(), Config::default());
|
||||
assert!(result.is_err(), "second open on same directory must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_lock_released_after_close() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
{
|
||||
let _engine = Engine::open(dir.path(), Config::default()).unwrap();
|
||||
}
|
||||
// Lock should be released after engine is dropped
|
||||
let engine = Engine::open(dir.path(), Config::default());
|
||||
assert!(engine.is_ok(), "reopen after close must succeed");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user