Files
clawhdf5/crates/clawhdf5-agent/src/wal.rs
T
Omar SobhandClaude Sonnet 4.6 aa3e12f3ae perf: WAL group commit — batch serialize + deferred header updates
Implements WAL group commit optimizations (arXiv:2507.13062):

1. Serialize each WAL entry to a local Vec<u8> before writing, reducing
   write() syscalls per entry from ~8 to 1.

2. Defer header entry_count updates to every GROUP_COMMIT_SIZE (8) entries
   instead of per-entry, eliminating 3 lseek() + 1 write() per entry.

3. Fix read_entries() to read until EOF instead of looping entry_count
   times — the header count is now a pre-allocation hint only. This is
   strictly more robust: tolerates stale counts from deferred updates AND
   truncated files from crashes mid-write.

Benchmark results:
- wal_flush_100_entries: -7.8% latency improvement (469 µs)
- save_with_wal_single: -1.7% (18.2 µs)
- save_without_wal_single: -2.3% (67 µs, full HDF5 write)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:33:35 +00:00

771 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Write-Ahead Log (WAL) for edgehdf5 agent memory.
//!
//! Binary WAL format alongside the main .h5 file enables fast append-only
//! writes without rewriting the entire HDF5 file on every save.
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::MemoryError;
const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; // "EHWL"
const WAL_VERSION: u8 = 1;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalEntryType {
Save = 0x01,
Tombstone = 0x02,
ActivationUpdate = 0x03,
}
impl WalEntryType {
fn from_u8(v: u8) -> Option<Self> {
match v {
0x01 => Some(Self::Save),
0x02 => Some(Self::Tombstone),
0x03 => Some(Self::ActivationUpdate),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct WalEntry {
pub entry_type: WalEntryType,
pub timestamp: f64,
pub chunk: String,
pub embedding: Vec<f32>,
pub source_channel: String,
pub session_id: String,
pub tags: String,
/// For tombstone entries: the index of the entry to delete.
pub tombstone_index: Option<usize>,
}
/// How many entries to accumulate before updating the header entry_count.
///
/// The header count is only needed for replay; `read_entries` already handles
/// stale counts by reading until EOF. Updating every N entries rather than
/// every entry eliminates 3 lseek() + 1 write() per entry — see arXiv:2507.13062.
const GROUP_COMMIT_SIZE: u32 = 8;
#[derive(Debug)]
pub struct WalFile {
path: PathBuf,
file: Option<File>,
entry_count: u32,
/// Entries written since the last header count update.
pending_header_sync: u32,
}
impl WalFile {
/// Open or create a WAL file. If it exists, read the header and entry count.
pub fn open(path: &Path) -> Result<Self, MemoryError> {
if path.exists() {
// Read existing header
let mut f = OpenOptions::new()
.read(true)
.write(true)
.append(false)
.open(path)?;
let mut magic = [0u8; 4];
f.read_exact(&mut magic)?;
if magic != WAL_MAGIC {
return Err(MemoryError::Schema("invalid WAL magic bytes".into()));
}
let mut ver = [0u8; 1];
f.read_exact(&mut ver)?;
if ver[0] != WAL_VERSION {
return Err(MemoryError::Schema(format!(
"unsupported WAL version {}",
ver[0]
)));
}
let mut count_buf = [0u8; 4];
f.read_exact(&mut count_buf)?;
let entry_count = u32::from_le_bytes(count_buf);
// Seek to end for appending
f.seek(SeekFrom::End(0))?;
Ok(Self {
path: path.to_path_buf(),
file: Some(f),
entry_count,
pending_header_sync: 0,
})
} else {
// Create new WAL
let mut f = File::create(path)?;
f.write_all(&WAL_MAGIC)?;
f.write_all(&[WAL_VERSION])?;
f.write_all(&0u32.to_le_bytes())?;
f.flush()?;
Ok(Self {
path: path.to_path_buf(),
file: Some(f),
entry_count: 0,
pending_header_sync: 0,
})
}
}
/// Append a save entry to the WAL.
///
/// Serializes the entry into a single buffer before writing to minimize
/// syscall count (1 write() vs ~8 previously). The header entry_count is
/// updated every GROUP_COMMIT_SIZE entries rather than on every write,
/// eliminating 3 lseek() + 1 write() per entry (arXiv:2507.13062).
///
/// Crash safety: `read_entries` reads until EOF and handles stale header
/// counts, so deferred header updates do not compromise recovery.
pub fn append_save(&mut self, entry: &WalEntry) -> Result<(), MemoryError> {
let emb_len = entry.embedding.len();
let mut buf = Vec::with_capacity(
1 + 8 + // type + timestamp
4 + entry.chunk.len() +
4 + emb_len * 4 +
4 + entry.source_channel.len() +
4 + entry.session_id.len() +
4 + entry.tags.len(),
);
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
serialize_str(&mut buf, &entry.chunk);
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
for &val in &entry.embedding {
buf.extend_from_slice(&val.to_le_bytes());
}
serialize_str(&mut buf, &entry.source_channel);
serialize_str(&mut buf, &entry.session_id);
serialize_str(&mut buf, &entry.tags);
let f = self
.file
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
self.write_entry_count()?;
}
Ok(())
}
/// Append a tombstone entry (deletion).
pub fn append_tombstone(&mut self, index: usize, timestamp: f64) -> Result<(), MemoryError> {
let mut buf = [0u8; 1 + 8 + 4]; // type + timestamp + index
buf[0] = WalEntryType::Tombstone as u8;
buf[1..9].copy_from_slice(&timestamp.to_le_bytes());
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
let f = self
.file
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
self.write_entry_count()?;
}
Ok(())
}
/// Read all entries from the WAL (for replay on open).
///
/// Reads until EOF — the header `entry_count` is used only for pre-allocation
/// (and may be stale if written with deferred group-commit updates). This
/// tolerates both truncated files (crash mid-write) and stale header counts
/// (crash before the next group-commit header sync).
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
if !path.exists() {
return Ok(Vec::new());
}
let mut f = File::open(path)?;
// Read header
let mut header = [0u8; 9];
f.read_exact(&mut header)?;
if header[0..4] != WAL_MAGIC {
return Err(MemoryError::Schema("invalid WAL magic bytes".into()));
}
if header[4] != WAL_VERSION {
return Err(MemoryError::Schema(format!(
"unsupported WAL version {}",
header[4]
)));
}
// entry_count is a pre-allocation hint only — we read until EOF.
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
let mut entries = Vec::with_capacity(entry_count_hint as usize);
loop {
// Read entry type — EOF here is normal end-of-log, not an error
let mut type_buf = [0u8; 1];
if f.read_exact(&mut type_buf).is_err() {
break;
}
let entry_type = match WalEntryType::from_u8(type_buf[0]) {
Some(et) => et,
None => break,
};
let mut ts_buf = [0u8; 8];
if f.read_exact(&mut ts_buf).is_err() {
break;
}
let timestamp = f64::from_le_bytes(ts_buf);
match entry_type {
WalEntryType::Save => {
let Ok(chunk) = read_len_prefixed_str(&mut f) else {
break;
};
let Ok(embedding) = read_embedding(&mut f) else {
break;
};
let Ok(source_channel) = read_len_prefixed_str(&mut f) else {
break;
};
let Ok(session_id) = read_len_prefixed_str(&mut f) else {
break;
};
let Ok(tags) = read_len_prefixed_str(&mut f) else {
break;
};
entries.push(WalEntry {
entry_type,
timestamp,
chunk,
embedding,
source_channel,
session_id,
tags,
tombstone_index: None,
});
}
WalEntryType::Tombstone => {
let mut idx_buf = [0u8; 4];
if f.read_exact(&mut idx_buf).is_err() {
break;
}
let idx = u32::from_le_bytes(idx_buf) as usize;
entries.push(WalEntry {
entry_type,
timestamp,
chunk: String::new(),
embedding: Vec::new(),
source_channel: String::new(),
session_id: String::new(),
tags: String::new(),
tombstone_index: Some(idx),
});
}
WalEntryType::ActivationUpdate => {
// Reserved for future use
}
}
}
Ok(entries)
}
/// Truncate the WAL (after merge into .h5).
pub fn truncate(&mut self) -> Result<(), MemoryError> {
// Close existing handle and recreate
self.file = None;
let mut f = File::create(&self.path)?;
f.write_all(&WAL_MAGIC)?;
f.write_all(&[WAL_VERSION])?;
f.write_all(&0u32.to_le_bytes())?;
f.flush()?;
self.file = Some(f);
self.entry_count = 0;
self.pending_header_sync = 0;
Ok(())
}
/// Number of pending entries.
pub fn pending_count(&self) -> u32 {
self.entry_count
}
/// Is the WAL empty?
pub fn is_empty(&self) -> bool {
self.entry_count == 0
}
/// Update the entry_count in the header (seek to offset 5, write u32 LE).
fn write_entry_count(&mut self) -> Result<(), MemoryError> {
let f = self
.file
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
let pos = f.stream_position()?;
f.seek(SeekFrom::Start(5))?;
f.write_all(&self.entry_count.to_le_bytes())?;
f.seek(SeekFrom::Start(pos))?;
self.pending_header_sync = 0;
Ok(())
}
}
/// Replay WAL entries into a MemoryCache.
pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryCache) {
for entry in entries {
match entry.entry_type {
WalEntryType::Save => {
cache.push(
entry.chunk.clone(),
entry.embedding.clone(),
entry.source_channel.clone(),
entry.timestamp,
entry.session_id.clone(),
entry.tags.clone(),
);
}
WalEntryType::Tombstone => {
if let Some(idx) = entry.tombstone_index {
cache.mark_deleted(idx);
}
}
WalEntryType::ActivationUpdate => {}
}
}
}
// --- Binary helpers ---
/// Serialize a length-prefixed string into an in-memory buffer (zero syscalls).
fn serialize_str(buf: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
let mut len_buf = [0u8; 4];
f.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
f.read_exact(&mut buf)?;
String::from_utf8(buf).map_err(|e| MemoryError::Schema(format!("invalid UTF-8 in WAL: {e}")))
}
fn read_embedding(f: &mut File) -> Result<Vec<f32>, MemoryError> {
let mut len_buf = [0u8; 4];
f.read_exact(&mut len_buf)?;
let count = u32::from_le_bytes(len_buf) as usize;
let mut vals = Vec::with_capacity(count);
for _ in 0..count {
let mut val_buf = [0u8; 4];
f.read_exact(&mut val_buf)?;
vals.push(f32::from_le_bytes(val_buf));
}
Ok(vals)
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_wal_entry(chunk: &str, embedding: &[f32]) -> WalEntry {
WalEntry {
entry_type: WalEntryType::Save,
timestamp: 1234567.89,
chunk: chunk.to_string(),
embedding: embedding.to_vec(),
source_channel: "test-channel".to_string(),
session_id: "sess-001".to_string(),
tags: "tag1,tag2".to_string(),
tombstone_index: None,
}
}
#[test]
fn test_wal_create_and_header() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let wal = WalFile::open(&wal_path).unwrap();
assert_eq!(wal.pending_count(), 0);
assert!(wal.is_empty());
drop(wal);
// Verify raw bytes on disk
let bytes = std::fs::read(&wal_path).unwrap();
assert_eq!(&bytes[0..4], &WAL_MAGIC);
assert_eq!(bytes[4], WAL_VERSION);
assert_eq!(&bytes[5..9], &0u32.to_le_bytes());
}
#[test]
fn test_wal_append_and_read() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
{
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
.unwrap();
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
.unwrap();
wal.append_save(&make_wal_entry("third", &[5.0, 6.0]))
.unwrap();
assert_eq!(wal.pending_count(), 3);
}
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].chunk, "first");
assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
assert_eq!(entries[1].chunk, "second");
assert_eq!(entries[2].chunk, "third");
assert_eq!(entries[2].embedding, vec![5.0, 6.0]);
}
#[test]
fn test_wal_truncate() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
for i in 0..5 {
wal.append_save(&make_wal_entry(&format!("entry {i}"), &[i as f32]))
.unwrap();
}
assert_eq!(wal.pending_count(), 5);
wal.truncate().unwrap();
assert_eq!(wal.pending_count(), 0);
assert!(wal.is_empty());
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_wal_append_tombstone() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
{
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_tombstone(42, 9999.0).unwrap();
assert_eq!(wal.pending_count(), 1);
}
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].entry_type, WalEntryType::Tombstone);
assert_eq!(entries[0].tombstone_index, Some(42));
assert!((entries[0].timestamp - 9999.0).abs() < 1e-6);
}
#[test]
fn test_wal_binary_roundtrip() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
{
let mut wal = WalFile::open(&wal_path).unwrap();
let entry = WalEntry {
entry_type: WalEntryType::Save,
timestamp: std::f64::consts::PI,
chunk: unicode_chunk.to_string(),
embedding: embedding.clone(),
source_channel: "channel/with/slashes".to_string(),
session_id: "sess-öö-123".to_string(),
tags: "α,β,γ".to_string(),
tombstone_index: None,
};
wal.append_save(&entry).unwrap();
}
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.entry_type, WalEntryType::Save);
assert!((e.timestamp - std::f64::consts::PI).abs() < 1e-15);
assert_eq!(e.chunk, unicode_chunk);
assert_eq!(e.embedding, embedding);
assert_eq!(e.source_channel, "channel/with/slashes");
assert_eq!(e.session_id, "sess-öö-123");
assert_eq!(e.tags, "α,β,γ");
}
#[test]
fn test_wal_empty_on_create() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let wal = WalFile::open(&wal_path).unwrap();
assert_eq!(wal.pending_count(), 0);
assert!(wal.is_empty());
}
// --- Integration tests (WAL + HDF5Memory) ---
use crate::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
fn make_config(dir: &TempDir) -> MemoryConfig {
let mut config = MemoryConfig::new(dir.path().join("test.h5"), "agent-test", 4);
config.wal_enabled = true;
config
}
fn make_entry(chunk: &str, embedding: &[f32]) -> MemoryEntry {
MemoryEntry {
chunk: chunk.to_string(),
embedding: embedding.to_vec(),
source_channel: "test".to_string(),
timestamp: 1000000.0,
session_id: "session-1".to_string(),
tags: "tag1,tag2".to_string(),
}
}
#[test]
fn test_save_with_wal() {
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
// Get initial .h5 size (empty file)
let initial_size = std::fs::metadata(&h5_path).unwrap().len();
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
mem.save(make_entry("b", &[0.0, 1.0, 0.0, 0.0])).unwrap();
mem.save(make_entry("c", &[0.0, 0.0, 1.0, 0.0])).unwrap();
// Cache has 3 entries
assert_eq!(mem.count(), 3);
// .h5 file should NOT have been updated (still initial size)
let after_size = std::fs::metadata(&h5_path).unwrap().len();
assert_eq!(
initial_size, after_size,
".h5 should not grow with WAL enabled"
);
// .wal file should exist
let wal_path = h5_path.with_extension("h5.wal");
assert!(wal_path.exists(), ".wal file should exist");
assert_eq!(mem.wal_pending_count(), 3);
}
#[test]
fn test_wal_auto_merge() {
let dir = TempDir::new().unwrap();
let mut config = make_config(&dir);
config.wal_max_entries = 5;
let h5_path = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
// Save 5 entries (at threshold but not over)
for i in 0..5 {
mem.save(make_entry(
&format!("entry {i}"),
&[i as f32, 0.0, 0.0, 0.0],
))
.unwrap();
}
// WAL should still have 5 pending (not yet merged, threshold is >=)
assert_eq!(mem.wal_pending_count(), 5);
// 6th entry triggers auto-merge (pending > wal_max_entries)
mem.save(make_entry("entry 5", &[5.0, 0.0, 0.0, 0.0]))
.unwrap();
// After auto-merge: WAL should be empty, cache still has all entries
assert_eq!(mem.wal_pending_count(), 0);
assert_eq!(mem.count(), 6);
// WAL file should be truncated (only header)
let wal_path = h5_path.with_extension("h5.wal");
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(entries.is_empty(), "WAL should be empty after auto-merge");
}
#[test]
fn test_wal_flush_explicit() {
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
mem.save(make_entry("b", &[0.0, 1.0, 0.0, 0.0])).unwrap();
mem.save(make_entry("c", &[0.0, 0.0, 1.0, 0.0])).unwrap();
assert_eq!(mem.wal_pending_count(), 3);
mem.flush_wal().unwrap();
// WAL should be empty after explicit flush
assert_eq!(mem.wal_pending_count(), 0);
// Cache should still have 3
assert_eq!(mem.count(), 3);
// WAL file on disk should be empty
let wal_path = h5_path.with_extension("h5.wal");
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_wal_replay_on_open() {
// Test WAL replay using read_entries + replay_into_cache directly,
// since the HDF5 read path is independent of WAL functionality.
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
{
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("replay-a", &[1.0, 0.0, 0.0, 0.0]))
.unwrap();
mem.save(make_entry("replay-b", &[0.0, 1.0, 0.0, 0.0]))
.unwrap();
mem.save(make_entry("replay-c", &[0.0, 0.0, 1.0, 0.0]))
.unwrap();
assert_eq!(mem.wal_pending_count(), 3);
// Drop without flushing — WAL has 3 entries
}
// Verify WAL file has the entries
let wal_path = h5_path.with_extension("h5.wal");
assert!(wal_path.exists());
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].chunk, "replay-a");
assert_eq!(entries[1].chunk, "replay-b");
assert_eq!(entries[2].chunk, "replay-c");
// Replay into a fresh cache (simulates what open() does)
let mut cache = crate::cache::MemoryCache::new(4);
super::replay_into_cache(&entries, &mut cache);
assert_eq!(cache.len(), 3);
assert_eq!(cache.chunks[0], "replay-a");
assert_eq!(cache.chunks[1], "replay-b");
assert_eq!(cache.chunks[2], "replay-c");
assert_eq!(cache.count_active(), 3);
}
#[test]
fn test_tick_session_merges_wal() {
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("tick-a", &[1.0, 0.0, 0.0, 0.0]))
.unwrap();
mem.save(make_entry("tick-b", &[0.0, 1.0, 0.0, 0.0]))
.unwrap();
mem.save(make_entry("tick-c", &[0.0, 0.0, 1.0, 0.0]))
.unwrap();
assert_eq!(mem.wal_pending_count(), 3);
mem.tick_session().unwrap();
// WAL should be empty after tick_session merges
assert_eq!(mem.wal_pending_count(), 0);
// Cache should still have 3 entries
assert_eq!(mem.count(), 3);
// WAL file on disk should be empty
let wal_path = h5_path.with_extension("h5.wal");
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_wal_header_only_with_nonzero_count() {
// Simulate crash: header says 5 entries but file is only 9 bytes (header only).
// This is the exact scenario from the bug report — process exits before WAL
// flushes, leaving a stale entry_count in the header.
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("corrupted.h5.wal");
{
let mut f = File::create(&wal_path).unwrap();
f.write_all(&WAL_MAGIC).unwrap();
f.write_all(&[WAL_VERSION]).unwrap();
f.write_all(&5u32.to_le_bytes()).unwrap(); // claims 5 entries
f.flush().unwrap();
}
// Should NOT error — should return empty vec
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_wal_partial_truncation() {
// Write 2 valid entries, then corrupt the header to claim 5.
// read_entries should return the 2 valid entries, not error.
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("partial.h5.wal");
{
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
.unwrap();
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
.unwrap();
assert_eq!(wal.pending_count(), 2);
}
// Corrupt the header: overwrite entry_count to 5
{
let mut f = OpenOptions::new().write(true).open(&wal_path).unwrap();
f.seek(SeekFrom::Start(5)).unwrap();
f.write_all(&5u32.to_le_bytes()).unwrap();
f.flush().unwrap();
}
// Should recover the 2 valid entries, not fail
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].chunk, "first");
assert_eq!(entries[1].chunk, "second");
}
#[test]
fn test_wal_invalid_version_rejected() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("badversion.h5.wal");
{
let mut f = File::create(&wal_path).unwrap();
f.write_all(&WAL_MAGIC).unwrap();
f.write_all(&[0xFF]).unwrap(); // bad version
f.write_all(&0u32.to_le_bytes()).unwrap();
f.flush().unwrap();
}
let result = WalFile::read_entries(&wal_path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unsupported WAL version"), "got: {err}");
}
#[test]
fn test_wal_disabled() {
let dir = TempDir::new().unwrap();
let mut config = make_config(&dir);
config.wal_enabled = false;
let h5_path = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("no-wal", &[1.0, 0.0, 0.0, 0.0]))
.unwrap();
// With WAL disabled, save goes through flush() directly (old behavior)
assert_eq!(mem.count(), 1);
// No WAL file should exist
let wal_path = h5_path.with_extension("h5.wal");
assert!(!wal_path.exists(), "no .wal file when WAL disabled");
assert_eq!(mem.wal_pending_count(), 0);
}
}