Phase 4c: Write-Ahead Log primitives #48
@@ -25,6 +25,7 @@ pub mod rpc;
|
||||
pub mod services;
|
||||
pub mod tags;
|
||||
pub mod transport;
|
||||
pub mod wal;
|
||||
|
||||
use crate::config::PeerEntry;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Phase 4c (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 file is scanned linearly. A short read, wrong length
|
||||
//! prefix, or checksum mismatch stops the scan — everything up to
|
||||
//! that boundary is kept; the tail is truncated. This models the
|
||||
//! standard "crash mid-append" case: whatever fully-fsynced record
|
||||
//! we last wrote is the last one we replay.
|
||||
//!
|
||||
//! No rotation yet — a single segment file per log root. `truncate`
|
||||
//! rewrites the tail into place atomically via
|
||||
//! `tempfile-in-parent + rename`. That's O(n) in surviving records,
|
||||
//! which is fine for the tens-of-thousands range this WAL is scoped
|
||||
//! for. Segment rotation is Phase-4d territory.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
/// File-backed WAL. Single-writer; `append` takes `&mut self` so
|
||||
/// serialization is enforced at the type level. Readers (`iter_from`,
|
||||
/// `tail_seq`, `head_seq`) can be called against `&self` and open
|
||||
/// their own file handles.
|
||||
pub struct WriteAheadLog {
|
||||
root: PathBuf,
|
||||
log_path: PathBuf,
|
||||
/// Highest seq that has been fsynced. Zero when the log is empty.
|
||||
tail_seq: u64,
|
||||
/// Lowest seq still present on disk. Advances after
|
||||
/// `truncate_up_to`. Zero when the log is empty.
|
||||
head_seq: u64,
|
||||
/// Cached file handle for appends. Opened in append-mode so
|
||||
/// offsets always land at the end.
|
||||
writer: tokio::fs::File,
|
||||
}
|
||||
|
||||
impl WriteAheadLog {
|
||||
/// Open (or create) the WAL rooted at `root`. Scans the segment
|
||||
/// file to recover `head_seq` / `tail_seq`. A partial trailing
|
||||
/// record is truncated in place.
|
||||
pub async fn open(root: impl Into<PathBuf>) -> Result<Self> {
|
||||
let root = root.into();
|
||||
tokio::fs::create_dir_all(&root)
|
||||
.await
|
||||
.with_context(|| format!("creating WAL root {}", root.display()))?;
|
||||
let log_path = root.join("log.bin");
|
||||
|
||||
// Scan (creates an empty file if none exists) to learn head/tail.
|
||||
let (head_seq, tail_seq, valid_len) = scan_segment(&log_path).await?;
|
||||
|
||||
// Truncate any partial trailing record. `set_len` is the
|
||||
// stdlib idiom for exact truncation on both linux and darwin.
|
||||
if let Ok(meta) = tokio::fs::metadata(&log_path).await {
|
||||
if meta.len() != valid_len {
|
||||
let f = OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&log_path)
|
||||
.await
|
||||
.with_context(|| format!("opening {} for truncation", log_path.display()))?;
|
||||
f.set_len(valid_len)
|
||||
.await
|
||||
.with_context(|| format!("truncating {} to {}", log_path.display(), valid_len))?;
|
||||
f.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("fsync after truncation of {}", log_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let writer = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.await
|
||||
.with_context(|| format!("opening WAL {} for append", log_path.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
root,
|
||||
log_path,
|
||||
head_seq,
|
||||
tail_seq,
|
||||
writer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Root directory this WAL lives in. Handy for callers that
|
||||
/// want to place sidecar files (state, index) next to it.
|
||||
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
|
||||
}
|
||||
|
||||
/// Append a payload. Returns the assigned seq (always
|
||||
/// `tail_seq() + 1`). Persisted with `fsync` before returning so
|
||||
/// callers can treat success as durable.
|
||||
pub async fn append(&mut self, payload: &[u8]) -> Result<u64> {
|
||||
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 mut frame = Vec::with_capacity(RECORD_HEADER_LEN + payload.len());
|
||||
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
|
||||
.with_context(|| format!("appending to WAL {}", self.log_path.display()))?;
|
||||
self.writer
|
||||
.flush()
|
||||
.await
|
||||
.context("flushing WAL append buffer")?;
|
||||
self.writer
|
||||
.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("fsync after WAL append {}", self.log_path.display()))?;
|
||||
|
||||
self.tail_seq = seq;
|
||||
if self.head_seq == 0 {
|
||||
self.head_seq = seq;
|
||||
}
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
/// Read every record with `seq >= start_seq`. Streams — the
|
||||
/// whole log never lives in memory at once.
|
||||
pub async fn iter_from(&self, start_seq: u64) -> Result<Vec<WalRecord>> {
|
||||
let mut out = Vec::new();
|
||||
let mut file = match tokio::fs::File::open(&self.log_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(anyhow::Error::from(e)),
|
||||
};
|
||||
loop {
|
||||
match read_next_record(&mut file).await? {
|
||||
Some(rec) if rec.seq >= start_seq => out.push(rec),
|
||||
Some(_) => {} // record older than the requested start.
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop every record with `seq <= watermark`. Rewrites the
|
||||
/// segment atomically via `tempfile-in-parent + rename`. No-op
|
||||
/// when the log is already past the watermark or empty.
|
||||
pub async fn truncate_up_to(&mut self, watermark: u64) -> Result<()> {
|
||||
if self.tail_seq == 0 || watermark < self.head_seq {
|
||||
return Ok(());
|
||||
}
|
||||
if watermark >= self.tail_seq {
|
||||
// Everything is gone; reset head/tail and truncate the file.
|
||||
drop(std::mem::replace(
|
||||
&mut self.writer,
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&self.log_path)
|
||||
.await?,
|
||||
));
|
||||
self.writer
|
||||
.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("fsync after full truncate of {}", self.log_path.display()))?;
|
||||
// Reopen in append mode so `append` continues to land at
|
||||
// the end even on an OS that treats trunc-create as write mode.
|
||||
self.writer = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&self.log_path)
|
||||
.await?;
|
||||
self.head_seq = 0;
|
||||
self.tail_seq = 0;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Otherwise: rewrite records with seq > watermark into a
|
||||
// sibling tempfile, fsync, rename over the original.
|
||||
let dir = self
|
||||
.log_path
|
||||
.parent()
|
||||
.with_context(|| format!("WAL log path has no parent: {}", self.log_path.display()))?;
|
||||
let tmp = tempfile::NamedTempFile::new_in(dir)
|
||||
.with_context(|| format!("creating tempfile in {}", dir.display()))?;
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
// Close the handle immediately — we'll reopen with tokio.
|
||||
drop(tmp);
|
||||
{
|
||||
let mut writer = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("opening WAL tempfile {}", tmp_path.display()))?;
|
||||
let mut reader = tokio::fs::File::open(&self.log_path).await?;
|
||||
let mut new_head = 0u64;
|
||||
loop {
|
||||
match read_next_record(&mut reader).await? {
|
||||
Some(rec) if rec.seq > watermark => {
|
||||
let mut frame =
|
||||
Vec::with_capacity(RECORD_HEADER_LEN + rec.payload.len());
|
||||
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_head == 0 {
|
||||
new_head = rec.seq;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
writer.flush().await?;
|
||||
writer.sync_all().await?;
|
||||
self.head_seq = new_head;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&tmp_path, &self.log_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
self.log_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Fsync the parent so the rename is durable.
|
||||
let dir_handle = tokio::fs::File::open(dir)
|
||||
.await
|
||||
.with_context(|| format!("opening WAL parent dir {}", dir.display()))?;
|
||||
dir_handle
|
||||
.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("fsync WAL parent dir {}", dir.display()))?;
|
||||
|
||||
// Reopen the append handle so writes land at the new EOF.
|
||||
self.writer = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&self.log_path)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// First 8 bytes of BLAKE3 over `seq || len || payload`. Keyless
|
||||
/// checksum: we're not preventing tampering, we're catching 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 — either
|
||||
/// case is 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<Option<WalRecord>> {
|
||||
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 => {
|
||||
// Partial trailing header → rewind to start so the caller
|
||||
// can size-truncate exactly here.
|
||||
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 log)",
|
||||
seq,
|
||||
start
|
||||
);
|
||||
}
|
||||
Ok(Some(WalRecord { seq, payload }))
|
||||
}
|
||||
|
||||
/// Fresh open of the segment file (creating an empty one if
|
||||
/// missing). Returns `(head_seq, tail_seq, valid_len_bytes)`. A
|
||||
/// partial trailing record contributes `0` bytes to `valid_len` —
|
||||
/// caller uses that to size-truncate the file.
|
||||
async fn scan_segment(log_path: &Path) -> Result<(u64, u64, u64)> {
|
||||
let mut file = match tokio::fs::File::open(log_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Create an empty segment so the append handle can open it.
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(log_path)
|
||||
.await
|
||||
.with_context(|| format!("creating empty WAL {}", log_path.display()))?;
|
||||
return Ok((0, 0, 0));
|
||||
}
|
||||
Err(e) => return Err(anyhow::Error::from(e)),
|
||||
};
|
||||
let mut head_seq = 0u64;
|
||||
let mut tail_seq = 0u64;
|
||||
let mut valid_len = 0u64;
|
||||
loop {
|
||||
match read_next_record(&mut file).await? {
|
||||
Some(rec) => {
|
||||
if head_seq == 0 {
|
||||
head_seq = rec.seq;
|
||||
}
|
||||
tail_seq = rec.seq;
|
||||
valid_len += (RECORD_HEADER_LEN + rec.payload.len()) as u64;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok((head_seq, tail_seq, valid_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn open_wal(root: &tempfile::TempDir) -> WriteAheadLog {
|
||||
WriteAheadLog::open(root.path().join("wal")).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.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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(all[0].seq, 1);
|
||||
assert_eq!(all[3].payload, b"four".to_vec());
|
||||
|
||||
let tail = wal.iter_from(3).await.unwrap();
|
||||
assert_eq!(tail.len(), 2);
|
||||
assert_eq!(tail[0].seq, 3);
|
||||
assert_eq!(tail[1].seq, 4);
|
||||
|
||||
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);
|
||||
let recs = wal.iter_from(0).await.unwrap();
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert_eq!(recs[1].payload, b"two");
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
// Simulate torn write: append 5 stray bytes to the segment.
|
||||
let log = tmp.path().join("wal/log.bin");
|
||||
let mut f = OpenOptions::new().append(true).open(&log).await.unwrap();
|
||||
f.write_all(&[0xAB, 0xCD, 0xEF, 0x01, 0x02]).await.unwrap();
|
||||
f.sync_all().await.unwrap();
|
||||
drop(f);
|
||||
|
||||
// Reopening drops the partial tail without erroring.
|
||||
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);
|
||||
// File is now exactly the length of two complete records.
|
||||
let meta = tokio::fs::metadata(&log).await.unwrap();
|
||||
assert_eq!(
|
||||
meta.len(),
|
||||
(RECORD_HEADER_LEN as u64 + 3) * 2,
|
||||
"segment should be truncated to complete records"
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
// Flip a byte inside the payload region (offset 20+0 = header end).
|
||||
let log = tmp.path().join("wal/log.bin");
|
||||
let mut f = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&log)
|
||||
.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);
|
||||
|
||||
let res = WriteAheadLog::open(tmp.path().join("wal")).await;
|
||||
assert!(res.is_err(), "corruption must surface, not silently drop data");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncate_up_to_removes_prefix() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut wal = open_wal(&tmp).await;
|
||||
for p in [&b"a"[..], b"b", b"c", b"d", b"e"] {
|
||||
wal.append(p).await.unwrap();
|
||||
}
|
||||
wal.truncate_up_to(3).await.unwrap();
|
||||
assert_eq!(wal.head_seq(), 4);
|
||||
assert_eq!(wal.tail_seq(), 5);
|
||||
|
||||
let recs = wal.iter_from(0).await.unwrap();
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert_eq!(recs[0].seq, 4);
|
||||
assert_eq!(recs[1].seq, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncate_full_leaves_log_empty_and_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());
|
||||
// Next append continues the seq — the log doesn't reset to 1.
|
||||
// Design choice: seq gaps are OK; consumers rely on monotonic,
|
||||
// not dense.
|
||||
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);
|
||||
// Now watermark is below head_seq — no-op.
|
||||
wal.truncate_up_to(0).await.unwrap();
|
||||
assert_eq!(wal.head_seq(), 2);
|
||||
assert_eq!(wal.tail_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]; // 1 MiB
|
||||
let seq = wal.append(&big).await.unwrap();
|
||||
let wal2 = WriteAheadLog::open(tmp.path().join("wal")).await.unwrap();
|
||||
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 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);
|
||||
let recs = wal.iter_from(0).await.unwrap();
|
||||
assert_eq!(recs.len(), 3);
|
||||
assert_eq!(recs[2].payload, b"c");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user