use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use holt::{Tree, TreeConfig};
fn wal_path(dir: &Path) -> PathBuf {
dir.join("journal.wal")
}
fn durable_cfg(dir: &std::path::Path) -> TreeConfig {
let mut cfg = TreeConfig::new(dir);
cfg.wal_sync_on_commit = true;
cfg
}
#[test]
fn persistent_put_then_reopen_via_wal_replay() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..50u32 {
let k = format!("k{i:03}");
let v = format!("v-{i}");
tree.put(k.as_bytes(), v.as_bytes()).unwrap();
}
}
let wal_size_after_drop = fs::metadata(wal_path(dir.path())).unwrap().len();
assert!(wal_size_after_drop > 32, "WAL should hold records");
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..50u32 {
let k = format!("k{i:03}");
let v = format!("v-{i}");
assert_eq!(
tree.get(k.as_bytes()).unwrap().as_deref(),
Some(v.as_bytes()),
"WAL replay should have restored key {k}",
);
}
}
}
#[test]
fn checkpoint_truncates_wal_and_keys_survive_reopen() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..20u32 {
tree.put(format!("k{i:02}").as_bytes(), format!("v{i}").as_bytes())
.unwrap();
}
let wal_size_before = fs::metadata(wal_path(dir.path())).unwrap().len();
assert!(wal_size_before > 32);
tree.checkpoint().unwrap();
let wal_size_after = fs::metadata(wal_path(dir.path())).unwrap().len();
assert_eq!(
wal_size_after, 32,
"checkpoint should truncate WAL to header-only",
);
}
{
let tree = Tree::open(cfg).unwrap();
for i in 0..20u32 {
let k = format!("k{i:02}");
let v = format!("v{i}");
assert_eq!(
tree.get(k.as_bytes()).unwrap().as_deref(),
Some(v.as_bytes()),
);
}
}
}
#[test]
fn delete_through_wal_replays_correctly() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..10u32 {
tree.put(format!("k{i}").as_bytes(), format!("v{i}").as_bytes())
.unwrap();
}
for i in 0..10u32 {
if i % 2 == 0 {
let prev = tree.delete(format!("k{i}").as_bytes()).unwrap();
assert!(prev.is_some());
}
}
}
let tree = Tree::open(cfg).unwrap();
for i in 0..10u32 {
let got = tree.get(format!("k{i}").as_bytes()).unwrap();
if i % 2 == 0 {
assert_eq!(got, None, "k{i} should have been deleted");
} else {
assert_eq!(got.as_deref(), Some(format!("v{i}").as_bytes()));
}
}
}
#[test]
fn rename_through_wal_replays_correctly() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
tree.put(b"a", b"v-a").unwrap();
tree.put(b"b", b"v-b").unwrap();
tree.rename(b"a", b"a2", false).unwrap();
}
let tree = Tree::open(cfg).unwrap();
assert_eq!(tree.get(b"a").unwrap(), None);
assert_eq!(tree.get(b"a2").unwrap().as_deref(), Some(&b"v-a"[..]));
assert_eq!(tree.get(b"b").unwrap().as_deref(), Some(&b"v-b"[..]));
}
#[test]
fn default_mode_loses_writes_without_checkpoint_or_fsync() {
let dir = tempdir().unwrap();
let cfg = TreeConfig::new(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..50u32 {
tree.put(
format!("transient{i}").as_bytes(),
format!("v{i}").as_bytes(),
)
.unwrap();
}
}
let wal_size = fs::metadata(wal_path(dir.path())).unwrap().len();
assert_eq!(wal_size, 32);
let tree = Tree::open(cfg).unwrap();
for i in 0..50u32 {
assert_eq!(
tree.get(format!("transient{i}").as_bytes()).unwrap(),
None,
"transient{i} should have been lost",
);
}
}
#[test]
fn batched_mode_loses_writes_without_checkpoint() {
let dir = tempdir().unwrap();
let mut cfg = TreeConfig::new(dir.path());
cfg.flush_on_write = false;
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..10u32 {
tree.put(
format!("transient{i}").as_bytes(),
format!("v{i}").as_bytes(),
)
.unwrap();
}
}
let tree = Tree::open(cfg).unwrap();
for i in 0..10u32 {
assert_eq!(
tree.get(format!("transient{i}").as_bytes()).unwrap(),
None,
"transient{i} should have been lost",
);
}
}
#[test]
fn batched_mode_with_checkpoint_persists_everything() {
let dir = tempdir().unwrap();
let mut cfg = TreeConfig::new(dir.path());
cfg.flush_on_write = false;
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..30u32 {
tree.put(
format!("batch{i:02}").as_bytes(),
format!("v{i}").as_bytes(),
)
.unwrap();
}
tree.checkpoint().unwrap();
}
let tree = Tree::open(cfg).unwrap();
for i in 0..30u32 {
let v = tree
.get(format!("batch{i:02}").as_bytes())
.unwrap()
.expect("batch key survives via blob image");
assert_eq!(v, format!("v{i}").into_bytes());
}
}
#[test]
fn next_seq_resumes_past_replayed_records() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..5u32 {
tree.put(format!("k{i}").as_bytes(), b"v").unwrap();
}
}
{
let tree = Tree::open(cfg).unwrap();
tree.put(b"after-replay", b"v").unwrap();
assert_eq!(
tree.get(b"after-replay").unwrap().as_deref(),
Some(&b"v"[..])
);
for i in 0..5u32 {
assert_eq!(
tree.get(format!("k{i}").as_bytes()).unwrap().as_deref(),
Some(&b"v"[..]),
);
}
}
}
#[test]
fn open_with_backend_attaches_no_wal() {
use holt::{Backend, MemoryBackend, TreeBuilder};
use std::sync::Arc;
let dir = tempdir().unwrap();
let backend: Arc<dyn Backend> = Arc::new(MemoryBackend::new());
{
let tree = TreeBuilder::new(dir.path())
.open_with_backend(backend.clone())
.unwrap();
tree.put(b"k", b"v").unwrap();
}
assert!(!wal_path(dir.path()).exists());
}
#[test]
fn many_round_trips_through_checkpoint_boundaries() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
for i in 0..20u32 {
tree.put(format!("a{i:02}").as_bytes(), b"A").unwrap();
}
tree.checkpoint().unwrap();
for i in 0..20u32 {
tree.put(format!("b{i:02}").as_bytes(), b"B").unwrap();
}
tree.checkpoint().unwrap();
for i in 0..20u32 {
tree.put(format!("c{i:02}").as_bytes(), b"C").unwrap();
}
}
let tree = Tree::open(cfg).unwrap();
for i in 0..20u32 {
assert_eq!(
tree.get(format!("a{i:02}").as_bytes()).unwrap().as_deref(),
Some(&b"A"[..]),
);
assert_eq!(
tree.get(format!("b{i:02}").as_bytes()).unwrap().as_deref(),
Some(&b"B"[..]),
);
assert_eq!(
tree.get(format!("c{i:02}").as_bytes()).unwrap().as_deref(),
Some(&b"C"[..]),
);
}
}
#[test]
fn batch_persists_through_crash_and_replay() {
let dir = tempdir().unwrap();
let cfg = durable_cfg(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
tree.put(b"seed", b"S").unwrap();
tree.txn(|b| {
b.put(b"batch-a", b"A");
b.put(b"batch-b", b"B");
b.delete(b"seed");
b.rename(b"batch-a", b"batch-aa", false);
})
.unwrap();
}
let tree = Tree::open(cfg).unwrap();
assert!(tree.get(b"seed").unwrap().is_none());
assert!(tree.get(b"batch-a").unwrap().is_none());
assert_eq!(tree.get(b"batch-aa").unwrap().as_deref(), Some(&b"A"[..]));
assert_eq!(tree.get(b"batch-b").unwrap().as_deref(), Some(&b"B"[..]));
}
#[test]
fn batch_crash_before_flush_loses_whole_batch() {
let dir = tempdir().unwrap();
let cfg = TreeConfig::new(dir.path());
{
let tree = Tree::open(cfg.clone()).unwrap();
tree.put(b"durable", b"D").unwrap();
tree.checkpoint().unwrap();
tree.txn(|b| {
b.put(b"vanish-a", b"VA");
b.put(b"vanish-b", b"VB");
})
.unwrap();
}
let tree = Tree::open(cfg).unwrap();
assert_eq!(tree.get(b"durable").unwrap().as_deref(), Some(&b"D"[..]));
assert!(tree.get(b"vanish-a").unwrap().is_none());
assert!(tree.get(b"vanish-b").unwrap().is_none());
}