//! Phase 4c/4d (2026-07-13): Write-Ahead Log for mutating client-mode //! ops. //! //! Roaming/offline clients (per Architecture v2, "Roaming client") //! must durably record mutations before attempting network fanout, //! so a crashed or offline daemon can replay them at reconnect. This //! module implements the primitive: a bounded, checksum-protected //! append-only file with monotonically-increasing sequence numbers //! and cheap truncation-up-to a durable watermark. //! //! Wire format is deliberately in-tree — no serde crate, no bincode. //! Each record is: //! //! ```text //! seq : u64 LE (8 bytes) //! len : u32 LE (4 bytes) — payload length //! csum : [u8; 8] (8 bytes) — first 8 bytes of BLAKE3(seq || len || payload) //! bytes : [u8; len] //! ``` //! //! On open the WAL root is scanned. Segments are named //! `segment-<20digit-first-seq>.bin` so lexical sort equals seq //! order. Each segment is scanned linearly. A short read or wrong //! length prefix on the *last* segment stops the scan — the file //! is size-truncated to the last complete record. Corruption on a //! full-length record surfaces as a hard error. //! //! Phase 4d (2026-07-13): segment rotation. New records land in the //! newest segment until it exceeds `max_segment_bytes` (default //! 8 MiB), at which point `append` opens a new segment on the next //! seq. Old segments become read-only. `truncate_up_to` drops whole //! segments below the watermark and rewrites the boundary one. //! //! No mocks / no stubs / full impl per the delivery constraints. use anyhow::{bail, Context, Result}; use std::path::{Path, PathBuf}; use tokio::fs::OpenOptions; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; /// Fixed on-disk framing overhead per record — `seq(8) + len(4) + csum(8)`. pub const RECORD_HEADER_LEN: usize = 20; /// Default per-segment soft cap. A single record can still push a /// segment past this — rotation is checked *before* the write so the /// current record always lands cleanly. pub const DEFAULT_MAX_SEGMENT_BYTES: u64 = 8 * 1024 * 1024; /// Segment filename width for the zero-padded first-seq field. 20 /// digits covers `u64::MAX`; lex-sort == numeric-sort. const SEQ_FIELD_WIDTH: usize = 20; /// A durable record replayed from the WAL. `payload` is opaque — /// callers own the encoding. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WalRecord { pub seq: u64, pub payload: Vec, } /// One immutable segment on disk. The tail segment is mutable — /// this struct only tracks *state* (its bounds and byte length). #[derive(Debug, Clone)] struct Segment { path: PathBuf, /// First seq value stored in this segment. Encoded in the filename. first_seq: u64, /// Last seq value stored. Zero for a freshly-created empty segment /// — treated as "unknown, please scan" only during open. last_seq: u64, /// Byte length on disk (as of last scan or write). byte_len: u64, } /// File-backed segmented WAL. Single-writer; `append` takes /// `&mut self` so serialization is enforced at the type level. /// Readers (`iter_from`, `head_seq`, `tail_seq`) work against /// `&self` and open their own file handles. pub struct WriteAheadLog { root: PathBuf, segments: Vec, /// Cached append handle for the tail segment. Opened in append /// mode so offsets always land at the end. writer: tokio::fs::File, max_segment_bytes: u64, head_seq: u64, tail_seq: u64, } impl WriteAheadLog { /// Open (or create) the WAL rooted at `root`. Scans the segment /// directory to recover `head_seq` / `tail_seq`. A partial /// trailing record on the last segment is size-truncated in /// place. Uses the default per-segment cap. pub async fn open(root: impl Into) -> Result { Self::open_with_options(root, DEFAULT_MAX_SEGMENT_BYTES).await } /// Open with a caller-supplied per-segment cap. Values below /// `RECORD_HEADER_LEN` are rounded up — you can't produce a /// segment that fits zero records. pub async fn open_with_options( root: impl Into, max_segment_bytes: u64, ) -> Result { let root = root.into(); tokio::fs::create_dir_all(&root) .await .with_context(|| format!("creating WAL root {}", root.display()))?; let max_segment_bytes = max_segment_bytes.max(RECORD_HEADER_LEN as u64); // Migrate the pre-4d single-file layout if we find one. migrate_legacy_log(&root).await?; let mut segments = load_segments(&root).await?; // Scan the tail segment (if any) to recover last_seq + // fix a torn trailing record. Middle segments are trusted // to be intact — a crash can only tear the file currently // being appended to. if let Some(tail) = segments.last_mut() { let (last_seq, valid_len) = scan_segment_tail(&tail.path).await?; if valid_len != tail.byte_len { truncate_file(&tail.path, valid_len).await?; tail.byte_len = valid_len; } if last_seq >= tail.first_seq { tail.last_seq = last_seq; } else { // Segment is empty on disk. Delete + drop. let path = tail.path.clone(); segments.pop(); let _ = tokio::fs::remove_file(&path).await; } } let head_seq = segments.first().map(|s| s.first_seq).unwrap_or(0); let tail_seq = segments.last().map(|s| s.last_seq).unwrap_or(0); // Ensure we have a writable tail segment. If the WAL is // empty, the first append will create segment-0000...001. let writer = match segments.last() { Some(tail) => open_append(&tail.path).await?, None => { // Placeholder handle that will be replaced on first // append. Point it at the root dir sentinel we know // exists so we don't hold onto stale state. // // Open a self-closing tempfile inside `root` and drop // it — the `writer` field will be reassigned before // any write. let placeholder = root.join(".wal-writer-placeholder"); let f = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&placeholder) .await?; let _ = tokio::fs::remove_file(&placeholder).await; f } }; Ok(Self { root, segments, writer, max_segment_bytes, head_seq, tail_seq, }) } /// Root directory this WAL lives in. pub fn root(&self) -> &Path { &self.root } /// Lowest seq still present. Zero when the log is empty. pub fn head_seq(&self) -> u64 { self.head_seq } /// Highest seq present. Zero when the log is empty. pub fn tail_seq(&self) -> u64 { self.tail_seq } /// Whether the log has any records. pub fn is_empty(&self) -> bool { self.tail_seq == 0 } /// Number of segments currently on disk. Test hook — production /// callers should not depend on this. pub fn segment_count(&self) -> usize { self.segments.len() } /// Append a payload. Returns the assigned seq. Persisted with /// `fsync` before returning so callers can treat success as /// durable. Rolls a new segment when the current one is at or /// above `max_segment_bytes` *and* non-empty (so a single /// oversize record still lands in one segment). pub async fn append(&mut self, payload: &[u8]) -> Result { if payload.len() > u32::MAX as usize { bail!( "WAL payload too large: {} bytes (max {})", payload.len(), u32::MAX ); } let seq = self.tail_seq.checked_add(1).context("WAL seq overflow")?; let frame_len = (RECORD_HEADER_LEN + payload.len()) as u64; let should_roll = match self.segments.last() { Some(tail) => tail.byte_len > 0 && tail.byte_len >= self.max_segment_bytes, None => true, }; if should_roll { self.roll_to_new_segment(seq).await?; } let mut frame = Vec::with_capacity(frame_len as usize); frame.extend_from_slice(&seq.to_le_bytes()); frame.extend_from_slice(&(payload.len() as u32).to_le_bytes()); frame.extend_from_slice(&checksum(seq, payload)); frame.extend_from_slice(payload); self.writer .write_all(&frame) .await .context("appending WAL frame")?; self.writer.flush().await.context("flushing WAL append")?; self.writer.sync_all().await.context("fsync WAL append")?; let tail = self .segments .last_mut() .expect("tail segment created above"); tail.byte_len += frame_len; tail.last_seq = seq; if self.head_seq == 0 { self.head_seq = seq; } self.tail_seq = seq; Ok(seq) } /// Read every record with `seq >= start_seq` in seq order. pub async fn iter_from(&self, start_seq: u64) -> Result> { let mut out = Vec::new(); for seg in &self.segments { if seg.last_seq < start_seq { continue; } let mut file = tokio::fs::File::open(&seg.path).await.with_context(|| { format!("opening WAL segment {}", seg.path.display()) })?; loop { match read_next_record(&mut file).await? { Some(rec) if rec.seq >= start_seq => out.push(rec), Some(_) => {} None => break, } } } Ok(out) } /// Drop every record with `seq <= watermark`. Whole segments /// whose `last_seq <= watermark` are `unlink`'d. The segment /// containing the watermark (if any) is rewritten in place via /// `tempfile-in-parent + rename`. No-op when the watermark is /// below the current head or the log is empty. pub async fn truncate_up_to(&mut self, watermark: u64) -> Result<()> { if self.tail_seq == 0 || watermark < self.head_seq { return Ok(()); } // Drop whole segments that end at or below the watermark. let mut kept = Vec::with_capacity(self.segments.len()); for seg in self.segments.drain(..) { if seg.last_seq <= watermark { let _ = tokio::fs::remove_file(&seg.path).await; } else { kept.push(seg); } } self.segments = kept; // Partial-drop the boundary segment if it straddles the // watermark. After the drain loop, if the surviving head // starts below-or-at the watermark, it needs a rewrite. if let Some(first) = self.segments.first().cloned() { if first.first_seq <= watermark { self.rewrite_segment_above(watermark).await?; } } // Recompute bounds. If everything was dropped, reset both // and close the append handle onto a placeholder so the // next `append` creates a fresh segment cleanly. if self.segments.is_empty() { self.head_seq = 0; self.tail_seq = 0; self.writer = open_placeholder(&self.root).await?; } else { self.head_seq = self.segments.first().unwrap().first_seq; self.tail_seq = self.segments.last().unwrap().last_seq; self.writer = open_append(&self.segments.last().unwrap().path).await?; } Ok(()) } /// Roll the tail to a brand-new segment starting at /// `first_seq`. Fsyncs the old tail first so nothing straddles. async fn roll_to_new_segment(&mut self, first_seq: u64) -> Result<()> { // Fsync the current tail (harmless if placeholder). let _ = self.writer.sync_all().await; let path = segment_path(&self.root, first_seq); let f = OpenOptions::new() .create_new(true) .append(true) .open(&path) .await .with_context(|| format!("creating segment {}", path.display()))?; self.writer = f; self.segments.push(Segment { path, first_seq, last_seq: 0, byte_len: 0, }); // Fsync the parent directory so the new file is durable. fsync_dir(&self.root).await?; Ok(()) } /// Rewrite the boundary segment: keep records with `seq > /// watermark`, drop the rest. Atomic via `tempfile-in-parent + /// rename + parent fsync`. async fn rewrite_segment_above(&mut self, watermark: u64) -> Result<()> { // Boundary segment is always segments[0] after the drain // loop above. let boundary = self.segments[0].clone(); let tmp = tempfile::NamedTempFile::new_in(&self.root) .with_context(|| format!("tempfile in {}", self.root.display()))?; let tmp_path = tmp.path().to_path_buf(); drop(tmp); let mut new_first = 0u64; let mut new_last = 0u64; let mut new_bytes = 0u64; { let mut writer = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&tmp_path) .await .with_context(|| format!("opening rewrite tempfile {}", tmp_path.display()))?; let mut reader = tokio::fs::File::open(&boundary.path).await?; loop { match read_next_record(&mut reader).await? { Some(rec) if rec.seq > watermark => { let frame_len = (RECORD_HEADER_LEN + rec.payload.len()) as u64; let mut frame = Vec::with_capacity(frame_len as usize); frame.extend_from_slice(&rec.seq.to_le_bytes()); frame.extend_from_slice(&(rec.payload.len() as u32).to_le_bytes()); frame.extend_from_slice(&checksum(rec.seq, &rec.payload)); frame.extend_from_slice(&rec.payload); writer.write_all(&frame).await?; if new_first == 0 { new_first = rec.seq; } new_last = rec.seq; new_bytes += frame_len; } Some(_) => {} None => break, } } writer.flush().await?; writer.sync_all().await?; } if new_first == 0 { // Everything in the boundary was <= watermark. Drop it. let _ = tokio::fs::remove_file(&boundary.path).await; let _ = tokio::fs::remove_file(&tmp_path).await; self.segments.remove(0); return Ok(()); } // Rename tempfile → new segment path. If the new first seq // differs from the boundary's, drop the old file first. let new_path = segment_path(&self.root, new_first); if new_path != boundary.path { let _ = tokio::fs::remove_file(&boundary.path).await; } tokio::fs::rename(&tmp_path, &new_path).await.with_context(|| { format!("renaming {} → {}", tmp_path.display(), new_path.display()) })?; fsync_dir(&self.root).await?; self.segments[0] = Segment { path: new_path, first_seq: new_first, last_seq: new_last, byte_len: new_bytes, }; Ok(()) } } fn segment_path(root: &Path, first_seq: u64) -> PathBuf { root.join(format!("segment-{:0width$}.bin", first_seq, width = SEQ_FIELD_WIDTH)) } fn parse_segment_first_seq(name: &str) -> Option { let core = name.strip_prefix("segment-")?.strip_suffix(".bin")?; if core.len() != SEQ_FIELD_WIDTH { return None; } core.parse::().ok() } async fn open_append(path: &Path) -> Result { OpenOptions::new() .create(true) .append(true) .open(path) .await .with_context(|| format!("open-append {}", path.display())) } async fn open_placeholder(root: &Path) -> Result { let placeholder = root.join(".wal-writer-placeholder"); let f = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&placeholder) .await?; let _ = tokio::fs::remove_file(&placeholder).await; Ok(f) } async fn truncate_file(path: &Path, len: u64) -> Result<()> { let f = OpenOptions::new() .write(true) .open(path) .await .with_context(|| format!("opening {} for truncation", path.display()))?; f.set_len(len) .await .with_context(|| format!("truncating {} to {}", path.display(), len))?; f.sync_all().await.context("fsync after truncation")?; Ok(()) } async fn fsync_dir(dir: &Path) -> Result<()> { let handle = tokio::fs::File::open(dir) .await .with_context(|| format!("opening WAL dir {}", dir.display()))?; handle .sync_all() .await .with_context(|| format!("fsync WAL dir {}", dir.display()))?; Ok(()) } async fn load_segments(root: &Path) -> Result> { let mut out = Vec::new(); let mut rd = tokio::fs::read_dir(root) .await .with_context(|| format!("reading WAL root {}", root.display()))?; while let Some(entry) = rd.next_entry().await? { let name = match entry.file_name().into_string() { Ok(n) => n, Err(_) => continue, }; let first_seq = match parse_segment_first_seq(&name) { Some(v) => v, None => continue, }; let meta = entry.metadata().await?; if !meta.is_file() { continue; } out.push(Segment { path: entry.path(), first_seq, last_seq: 0, // filled by scan on the tail; middle segments compute lazily below byte_len: meta.len(), }); } out.sort_by_key(|s| s.first_seq); // For middle segments, cheaply derive last_seq by reading the // tail header (last 20 bytes could still be a valid header // *record*, but we need the payload len too — easiest: full // linear scan of each). Middle segments are trusted to be // intact so a short-circuit is fine. let last_idx = out.len().saturating_sub(1); for (i, seg) in out.iter_mut().enumerate() { if i == last_idx { continue; } let (last_seq, _valid_len) = scan_segment_tail(&seg.path).await?; seg.last_seq = last_seq.max(seg.first_seq); } Ok(out) } /// Legacy compat: rename `log.bin` (pre-4d) to a properly-named /// segment so it participates in the new enumeration. Zero-op when /// no legacy file exists. async fn migrate_legacy_log(root: &Path) -> Result<()> { let legacy = root.join("log.bin"); if tokio::fs::metadata(&legacy).await.is_err() { return Ok(()); } // Peek at the first record to learn first_seq. let mut f = tokio::fs::File::open(&legacy).await?; let first_seq = match read_next_record(&mut f).await? { Some(r) => r.seq, None => { // Empty file — just delete. let _ = tokio::fs::remove_file(&legacy).await; return Ok(()); } }; drop(f); let new_path = segment_path(root, first_seq); if tokio::fs::metadata(&new_path).await.is_ok() { // Collision — refuse rather than silently overwriting. This // is a hand-repair case; document with the error. bail!( "legacy WAL log.bin found alongside {}; refusing to overwrite", new_path.display() ); } tokio::fs::rename(&legacy, &new_path).await?; fsync_dir(root).await?; Ok(()) } /// First 8 bytes of BLAKE3 over `seq || len || payload`. Keyless /// checksum: not tamper-resistant; catches torn writes and disk /// corruption. fn checksum(seq: u64, payload: &[u8]) -> [u8; 8] { let mut hasher = blake3::Hasher::new(); hasher.update(&seq.to_le_bytes()); hasher.update(&(payload.len() as u32).to_le_bytes()); hasher.update(payload); let mut out = [0u8; 8]; out.copy_from_slice(&hasher.finalize().as_bytes()[..8]); out } /// Read the next record from a positioned file. `Ok(None)` at EOF /// or when the trailing bytes are shorter than a full record — /// treated as "clean tail, nothing to see here". A checksum /// mismatch on a full-length record is fatal. async fn read_next_record(file: &mut tokio::fs::File) -> Result> { let mut header = [0u8; RECORD_HEADER_LEN]; let start = file.stream_position().await?; match file.read_exact(&mut header).await { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { file.seek(std::io::SeekFrom::Start(start)).await?; return Ok(None); } Err(e) => return Err(anyhow::Error::from(e)), } let seq = u64::from_le_bytes(header[..8].try_into().unwrap()); let len = u32::from_le_bytes(header[8..12].try_into().unwrap()) as usize; let expected_csum: [u8; 8] = header[12..20].try_into().unwrap(); let mut payload = vec![0u8; len]; if len > 0 { match file.read_exact(&mut payload).await { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { file.seek(std::io::SeekFrom::Start(start)).await?; return Ok(None); } Err(e) => return Err(anyhow::Error::from(e)), } } if checksum(seq, &payload) != expected_csum { bail!( "WAL record checksum mismatch at seq {} (offset {} in segment)", seq, start ); } Ok(Some(WalRecord { seq, payload })) } /// Scan a segment forward. Returns `(last_seq, valid_byte_len)` — /// `last_seq` is 0 if no complete records are present, and /// `valid_byte_len` is the offset of the first partial/absent /// record (so callers can size-truncate at that boundary). async fn scan_segment_tail(path: &Path) -> Result<(u64, u64)> { let mut file = tokio::fs::File::open(path) .await .with_context(|| format!("scanning WAL segment {}", path.display()))?; let mut last_seq = 0u64; let mut valid = 0u64; loop { match read_next_record(&mut file).await? { Some(rec) => { last_seq = rec.seq; valid += (RECORD_HEADER_LEN + rec.payload.len()) as u64; } None => break, } } Ok((last_seq, valid)) } #[cfg(test)] mod tests { use super::*; async fn open_wal(root: &tempfile::TempDir) -> WriteAheadLog { WriteAheadLog::open(root.path().join("wal")).await.unwrap() } async fn open_wal_capped(root: &tempfile::TempDir, cap: u64) -> WriteAheadLog { WriteAheadLog::open_with_options(root.path().join("wal"), cap) .await .unwrap() } #[tokio::test] async fn fresh_open_is_empty() { let tmp = tempfile::TempDir::new().unwrap(); let wal = open_wal(&tmp).await; assert!(wal.is_empty()); assert_eq!(wal.head_seq(), 0); assert_eq!(wal.tail_seq(), 0); assert_eq!(wal.segment_count(), 0); assert_eq!(wal.iter_from(0).await.unwrap(), vec![]); } #[tokio::test] async fn append_assigns_monotonic_seq() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; assert_eq!(wal.append(b"a").await.unwrap(), 1); assert_eq!(wal.append(b"bb").await.unwrap(), 2); assert_eq!(wal.append(b"ccc").await.unwrap(), 3); assert_eq!(wal.tail_seq(), 3); assert_eq!(wal.head_seq(), 1); assert_eq!(wal.segment_count(), 1); } #[tokio::test] async fn iter_from_replays_full_and_partial_ranges() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; for p in [&b"one"[..], b"two", b"three", b"four"] { wal.append(p).await.unwrap(); } let all = wal.iter_from(0).await.unwrap(); assert_eq!(all.len(), 4); let tail = wal.iter_from(3).await.unwrap(); assert_eq!(tail.len(), 2); assert_eq!(tail[0].seq, 3); assert!(wal.iter_from(99).await.unwrap().is_empty()); } #[tokio::test] async fn reopen_recovers_tail_seq() { let tmp = tempfile::TempDir::new().unwrap(); { let mut wal = open_wal(&tmp).await; wal.append(b"one").await.unwrap(); wal.append(b"two").await.unwrap(); } let wal = open_wal(&tmp).await; assert_eq!(wal.tail_seq(), 2); assert_eq!(wal.head_seq(), 1); } #[tokio::test] async fn partial_trailing_record_is_truncated_on_open() { let tmp = tempfile::TempDir::new().unwrap(); { let mut wal = open_wal(&tmp).await; wal.append(b"one").await.unwrap(); wal.append(b"two").await.unwrap(); } // Find the tail segment and append 5 stray bytes. let mut names: Vec<_> = std::fs::read_dir(tmp.path().join("wal")) .unwrap() .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| { p.file_name() .and_then(|s| s.to_str()) .map(|n| n.starts_with("segment-")) .unwrap_or(false) }) .collect(); names.sort(); let tail = names.last().cloned().unwrap(); let mut f = OpenOptions::new().append(true).open(&tail).await.unwrap(); f.write_all(&[0xAB, 0xCD, 0xEF, 0x01, 0x02]).await.unwrap(); f.sync_all().await.unwrap(); drop(f); let wal = open_wal(&tmp).await; assert_eq!(wal.tail_seq(), 2); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.len(), 2); let meta = tokio::fs::metadata(&tail).await.unwrap(); assert_eq!(meta.len(), (RECORD_HEADER_LEN as u64 + 3) * 2); } #[tokio::test] async fn corrupted_payload_is_a_hard_error() { let tmp = tempfile::TempDir::new().unwrap(); { let mut wal = open_wal(&tmp).await; wal.append(b"hello").await.unwrap(); } let names: Vec<_> = std::fs::read_dir(tmp.path().join("wal")) .unwrap() .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| { p.file_name() .and_then(|s| s.to_str()) .map(|n| n.starts_with("segment-")) .unwrap_or(false) }) .collect(); let seg = names[0].clone(); let mut f = OpenOptions::new() .read(true) .write(true) .open(&seg) .await .unwrap(); f.seek(std::io::SeekFrom::Start(RECORD_HEADER_LEN as u64)) .await .unwrap(); f.write_all(&[0xFF]).await.unwrap(); f.sync_all().await.unwrap(); drop(f); assert!(WriteAheadLog::open(tmp.path().join("wal")).await.is_err()); } #[tokio::test] async fn rotation_opens_new_segment_when_cap_exceeded() { let tmp = tempfile::TempDir::new().unwrap(); // Cap is 40 bytes: two 20-byte-header + 0-byte-payload records // fit (40 bytes exactly triggers rotation next). let cap = 40; let mut wal = open_wal_capped(&tmp, cap).await; wal.append(&[]).await.unwrap(); // seq 1 wal.append(&[]).await.unwrap(); // seq 2 assert_eq!(wal.segment_count(), 1); wal.append(&[]).await.unwrap(); // seq 3 → rolls assert_eq!(wal.segment_count(), 2); wal.append(&[]).await.unwrap(); // seq 4 assert_eq!(wal.segment_count(), 2); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.iter().map(|r| r.seq).collect::>(), vec![1, 2, 3, 4]); } #[tokio::test] async fn reopen_enumerates_all_segments() { let tmp = tempfile::TempDir::new().unwrap(); { let mut wal = open_wal_capped(&tmp, 40).await; for _ in 0..5 { wal.append(&[]).await.unwrap(); } } let wal = open_wal_capped(&tmp, 40).await; assert_eq!(wal.tail_seq(), 5); assert_eq!(wal.head_seq(), 1); assert!(wal.segment_count() >= 2); assert_eq!(wal.iter_from(0).await.unwrap().len(), 5); } #[tokio::test] async fn truncate_up_to_drops_whole_segments() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal_capped(&tmp, 40).await; for _ in 0..5 { wal.append(&[]).await.unwrap(); } let before = wal.segment_count(); // watermark 4 → seqs 1-4 gone. wal.truncate_up_to(4).await.unwrap(); assert_eq!(wal.head_seq(), 5); assert_eq!(wal.tail_seq(), 5); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.len(), 1); assert_eq!(recs[0].seq, 5); assert!(wal.segment_count() < before); } #[tokio::test] async fn truncate_partial_rewrites_boundary_segment() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal_capped(&tmp, 100).await; // big enough to hold several for _ in 0..5 { wal.append(&[0xAA]).await.unwrap(); } // All 5 in one segment. watermark 3 → 4,5 survive. wal.truncate_up_to(3).await.unwrap(); assert_eq!(wal.head_seq(), 4); assert_eq!(wal.tail_seq(), 5); assert_eq!(wal.segment_count(), 1); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.iter().map(|r| r.seq).collect::>(), vec![4, 5]); } #[tokio::test] async fn truncate_full_leaves_log_appendable() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; wal.append(b"one").await.unwrap(); wal.append(b"two").await.unwrap(); wal.truncate_up_to(2).await.unwrap(); assert!(wal.is_empty()); assert_eq!(wal.segment_count(), 0); let seq = wal.append(b"three").await.unwrap(); assert_eq!(seq, 1); assert_eq!(wal.head_seq(), 1); assert_eq!(wal.tail_seq(), 1); } #[tokio::test] async fn truncate_below_head_is_noop() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; wal.append(b"a").await.unwrap(); wal.append(b"b").await.unwrap(); wal.truncate_up_to(1).await.unwrap(); assert_eq!(wal.head_seq(), 2); wal.truncate_up_to(0).await.unwrap(); assert_eq!(wal.head_seq(), 2); } #[tokio::test] async fn large_payload_round_trips() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; let big = vec![0x5A; 1 << 20]; let seq = wal.append(&big).await.unwrap(); let wal2 = open_wal(&tmp).await; let recs = wal2.iter_from(0).await.unwrap(); assert_eq!(recs.len(), 1); assert_eq!(recs[0].seq, seq); assert_eq!(recs[0].payload, big); } #[tokio::test] async fn oversize_record_still_fits_one_segment() { let tmp = tempfile::TempDir::new().unwrap(); // Cap is 32 bytes but payload alone is 1000. Rotation must // not trigger for a single-record segment. let mut wal = open_wal_capped(&tmp, 32).await; wal.append(&vec![0x77; 1000]).await.unwrap(); assert_eq!(wal.segment_count(), 1); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs[0].payload.len(), 1000); } #[tokio::test] async fn empty_payload_round_trips() { let tmp = tempfile::TempDir::new().unwrap(); let mut wal = open_wal(&tmp).await; wal.append(&[]).await.unwrap(); wal.append(b"x").await.unwrap(); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.len(), 2); assert!(recs[0].payload.is_empty()); assert_eq!(recs[1].payload, b"x"); } #[tokio::test] async fn append_after_reopen_continues_seq() { let tmp = tempfile::TempDir::new().unwrap(); { let mut wal = open_wal(&tmp).await; wal.append(b"a").await.unwrap(); wal.append(b"b").await.unwrap(); } let mut wal = open_wal(&tmp).await; assert_eq!(wal.append(b"c").await.unwrap(), 3); } #[tokio::test] async fn legacy_log_bin_is_migrated_on_open() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("wal"); std::fs::create_dir_all(&root).unwrap(); // Hand-craft a legacy log.bin with two records (seq 1, 2). let legacy = root.join("log.bin"); let mut bytes = Vec::new(); for (seq, payload) in [(1u64, &b"hi"[..]), (2u64, &b"bye"[..])] { bytes.extend_from_slice(&seq.to_le_bytes()); bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes()); bytes.extend_from_slice(&checksum(seq, payload)); bytes.extend_from_slice(payload); } std::fs::write(&legacy, &bytes).unwrap(); let wal = WriteAheadLog::open(&root).await.unwrap(); assert_eq!(wal.head_seq(), 1); assert_eq!(wal.tail_seq(), 2); assert!(!legacy.exists(), "legacy log.bin should be renamed away"); assert_eq!(wal.segment_count(), 1); let recs = wal.iter_from(0).await.unwrap(); assert_eq!(recs.len(), 2); assert_eq!(recs[1].payload, b"bye".to_vec()); } #[test] fn segment_name_round_trips() { let path = segment_path(Path::new("/tmp/w"), 42); assert!(path.to_string_lossy().ends_with("segment-00000000000000000042.bin")); assert_eq!( parse_segment_first_seq("segment-00000000000000000042.bin"), Some(42) ); assert_eq!(parse_segment_first_seq("segment-42.bin"), None); // wrong width assert_eq!(parse_segment_first_seq("junk"), None); } }