use horon::{Horon, HoronConfig, HoronReader, RefreshOutcome};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
fn temp_path() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"horon_readers_{}_{}.htt",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
p
}
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn cfg() -> HoronConfig {
HoronConfig {
auto_compact_threshold: 0, ..Default::default()
}
}
#[test]
fn many_readers_never_observe_a_malformed_view_under_sustained_writes() {
const READERS: usize = 8;
const WRITES: usize = 4_000;
let path = temp_path();
let gf = Horon::open_with_config(&path, cfg()).unwrap();
gf.put("/seed", b"seed").unwrap();
let stop = Arc::new(AtomicBool::new(false));
let observations = Arc::new(AtomicUsize::new(0));
let readers: Vec<_> = (0..READERS)
.map(|_| {
let path = path.clone();
let stop = Arc::clone(&stop);
let observations = Arc::clone(&observations);
std::thread::spawn(move || {
let mut rounds = 0usize;
let mut last_seq = 0u32;
while !stop.load(Ordering::Relaxed) {
let r = match HoronReader::open(&path) {
Ok(r) => r,
Err(e) => panic!("reader open failed mid-write: {e}"),
};
assert_eq!(r.get("/seed").unwrap(), b"seed", "seed lost");
for k in r.list("/").unwrap() {
if let Some(n) = k.strip_prefix("/n/") {
let want = format!("v{n}");
let got = r.get(&k).unwrap();
assert_eq!(
got,
want.as_bytes(),
"payload/key mismatch at {k} — misattributed WAL entry"
);
}
}
let seq = r.next_seq();
assert!(
seq >= last_seq,
"seq went backwards: {last_seq} -> {seq}"
);
last_seq = seq;
rounds += 1;
observations.fetch_add(1, Ordering::Relaxed);
std::thread::sleep(std::time::Duration::from_millis(1));
}
rounds
})
})
.collect();
for i in 0..WRITES {
gf.put(&format!("/n/{i}"), format!("v{i}").as_bytes()).unwrap();
}
stop.store(true, Ordering::Relaxed);
let total: usize = readers.into_iter().map(|h| h.join().unwrap()).sum();
assert!(
total >= READERS,
"readers did not complete a round each ({total})"
);
assert!(
observations.load(Ordering::Relaxed) > 0,
"no reader observation was made"
);
drop(gf);
let r = HoronReader::open(&path).unwrap();
for i in 0..WRITES {
assert_eq!(r.get(&format!("/n/{i}")).unwrap(), format!("v{i}").as_bytes());
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn readers_survive_a_compaction_underneath_them() {
const READERS: usize = 4;
let path = temp_path();
let gf = Horon::open_with_config(&path, cfg()).unwrap();
for i in 0..500 {
gf.put(&format!("/pre/{i}"), format!("p{i}").as_bytes()).unwrap();
}
let stop = Arc::new(AtomicBool::new(false));
let readers: Vec<_> = (0..READERS)
.map(|_| {
let path = path.clone();
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let held = HoronReader::open(&path).unwrap();
let held_len = held.len();
while !stop.load(Ordering::Relaxed) {
assert_eq!(held.len(), held_len, "pinned view mutated");
for i in (0..500).step_by(97) {
assert_eq!(
held.get(&format!("/pre/{i}")).unwrap(),
format!("p{i}").as_bytes(),
"pinned view lost data across compaction"
);
}
let fresh = HoronReader::open(&path).unwrap();
assert!(
fresh.get("/pre/0").is_ok(),
"fresh open during compaction lost data"
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
held_len
})
})
.collect();
for round in 0..5 {
for i in 0..100 {
gf.put(&format!("/post/{round}/{i}"), b"x").unwrap();
}
gf.compact().unwrap();
}
stop.store(true, Ordering::Relaxed);
for h in readers {
h.join().unwrap();
}
drop(gf);
let r = HoronReader::open(&path).unwrap();
for i in 0..500 {
assert_eq!(r.get(&format!("/pre/{i}")).unwrap(), format!("p{i}").as_bytes());
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn reader_is_correct_when_the_wal_header_is_deliberately_wrong() {
let path = temp_path();
{
let gf = Horon::open_with_config(&path, cfg()).unwrap();
for i in 0..200 {
gf.put(&format!("/k/{i}"), format!("v{i}").as_bytes()).unwrap();
}
}
let truth = {
let r = HoronReader::open(&path).unwrap();
(r.len(), r.list("/").unwrap().len())
};
let bytes = std::fs::read(&path).unwrap();
let off = {
let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
let version = bytes[4];
let flags = bytes[5];
let semantic_dims = bytes[7] as usize;
let mut o = 32usize; if flags & (1 << 6) != 0 {
o += semantic_dims.saturating_sub(16) * 16;
}
let snap_byte_len = u32_at(o) as usize;
let node_count = u32_at(o + 4);
o += 8;
let compressed = flags & 1 != 0;
if node_count > 0 && compressed {
let comp_len = u32_at(o) as usize;
o += 4 + comp_len;
} else {
o += snap_byte_len;
}
if version >= 2 {
o += 4; }
o
};
let live_count = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
assert!(
live_count > 0,
"computed WAL header offset {off} holds count 0 — layout walk is wrong"
);
for (count, base) in [
(0u32, 1u32),
(9999, 1),
(live_count, 0),
(live_count, 9999),
(0, 0),
(u32::MAX, u32::MAX),
] {
let mut corrupted = bytes.clone();
corrupted[off..off + 4].copy_from_slice(&count.to_le_bytes());
corrupted[off + 4..off + 8].copy_from_slice(&base.to_le_bytes());
let tampered = temp_path();
std::fs::write(&tampered, &corrupted).unwrap();
let r = HoronReader::open(&tampered).unwrap();
assert_eq!(
(r.len(), r.list("/").unwrap().len()),
truth,
"reader result changed with WAL header ({count}, {base}) — \
the header is being trusted, not derived"
);
for i in 0..200 {
assert_eq!(
r.get(&format!("/k/{i}")).unwrap(),
format!("v{i}").as_bytes(),
"payload wrong under tampered header ({count}, {base})"
);
}
let _ = std::fs::remove_file(&tampered);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn refresh_applies_new_entries_and_reloads_past_a_compaction() {
let path = temp_path();
let gf = Horon::open_with_config(&path, cfg()).unwrap();
gf.put("/a", b"1").unwrap();
let mut r = HoronReader::open(&path).unwrap();
assert_eq!(r.get("/a").unwrap(), b"1");
assert!(r.get("/b").is_err(), "reader saw a write that did not exist yet");
assert_eq!(r.refresh().unwrap(), RefreshOutcome::UpToDate);
gf.put("/b", b"2").unwrap();
gf.put("/c", b"3").unwrap();
match r.refresh().unwrap() {
RefreshOutcome::Applied(n) => assert!(n >= 2, "expected >=2 entries, got {n}"),
other => panic!("expected Applied, got {other:?}"),
}
assert_eq!(r.get("/b").unwrap(), b"2");
assert_eq!(r.get("/c").unwrap(), b"3");
gf.put("/d", b"4").unwrap();
gf.compact().unwrap();
gf.put("/e", b"5").unwrap();
let outcome = r.refresh().unwrap();
assert_eq!(r.get("/d").unwrap(), b"4", "lost an entry folded into the snapshot");
assert_eq!(r.get("/e").unwrap(), b"5", "lost a post-compaction entry");
assert!(
matches!(outcome, RefreshOutcome::Reloaded | RefreshOutcome::Applied(_)),
"unexpected outcome {outcome:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_reader_does_not_block_a_writer_and_a_writer_does_not_block_a_reader() {
let path = temp_path();
let gf = Horon::open_with_config(&path, cfg()).unwrap();
gf.put("/x", b"1").unwrap();
let r1 = HoronReader::open(&path).unwrap();
let r2 = HoronReader::open(&path).unwrap();
assert_eq!(r1.get("/x").unwrap(), b"1");
assert_eq!(r2.get("/x").unwrap(), b"1");
gf.put("/y", b"2").unwrap();
assert_eq!(gf.get("/y").unwrap(), b"2");
assert!(
Horon::open_with_config(&path, cfg()).is_err(),
"exclusive write lock was weakened"
);
let _ = std::fs::remove_file(&path);
}