Files
bichon/crates/blob/tests/fuzz_test.rs
T
2026-07-13 00:04:53 +08:00

381 lines
13 KiB
Rust

/// 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
);
}