open_wal/wal.rs
1//! The single-writer `Wal` handle — open, append, commit, replay, checkpoint.
2//!
3//! **Scope through M5:** `append` is pure memory (§7.1); `commit` writes the
4//! staged batch and `fdatasync`s it, splitting across segments on whole-record
5//! boundaries and rolling as needed (§7.2–7.4); `open` cold-starts an empty
6//! directory or runs full multi-segment recovery (§8 — torn-tail truncation +
7//! durable zeroing, fatal mid-log corruption, cross-segment continuity); and
8//! `checkpoint` reclaims space by deleting whole sealed segments fully
9//! superseded by `up_to`, oldest-first + dir-fsync, advancing `oldest_lsn` (§9).
10//! The active segment is never deleted and survivors stay a contiguous suffix at
11//! every crash point (D8/D9).
12
13use std::cell::Cell;
14use std::fs::{File, OpenOptions};
15use std::io;
16use std::marker::PhantomData;
17use std::os::unix::fs::FileExt;
18use std::path::{Path, PathBuf};
19
20use crate::error::{Result, WalError};
21use crate::observer::{DurabilityObserver, NullObserver};
22use crate::reader::Reader;
23use crate::recovery;
24use crate::segment::{self, HEADER_SIZE};
25use crate::{Lsn, WalConfig};
26
27/// Outcome of recovery, returned by [`Wal::open`] (§6).
28#[derive(Debug, Clone, Copy)]
29pub struct RecoveryReport {
30 /// `P`: base LSN of the oldest surviving segment (1 until the first
31 /// checkpoint).
32 pub oldest_lsn: Lsn,
33 /// `k`: highest recovered durable LSN (`oldest_lsn - 1` if the suffix is
34 /// empty).
35 pub durable_lsn: Lsn,
36 /// Whether the tail was clean or a torn tail was truncated.
37 pub tail_state: TailState,
38 /// Number of segment files inspected during recovery.
39 pub segments_scanned: usize,
40}
41
42/// State of the active segment's tail after recovery (§6).
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TailState {
45 /// The tail ended cleanly (sentinel / end of records); no truncation.
46 Clean,
47 /// A torn tail was detected, truncated, and durably zeroed at this offset of
48 /// the active segment (§8.2.1).
49 TruncatedAt {
50 /// `base_lsn` of the active segment that was truncated.
51 segment_base: Lsn,
52 /// Byte offset within that segment at which the tail was truncated.
53 offset: u64,
54 },
55}
56
57/// Single-writer, append-only write-ahead log handle.
58///
59/// `Send` but **not `Sync`** (§6.2): the write methods take `&mut self`, and the
60/// `PhantomData<Cell<()>>` marker makes sharing the handle across threads a
61/// compile error, so concurrent writers cannot exist. Generic over a
62/// [`DurabilityObserver`]; the default [`NullObserver`] compiles away.
63pub struct Wal<O: DurabilityObserver = NullObserver> {
64 /// Held open for the handle's lifetime so the exclusive `flock` is retained;
65 /// dropping the `Wal` releases the lock.
66 _lock: File,
67 /// The WAL directory, retained so a [`Reader`] can open sealed segments by
68 /// path and `commit` can create new ones on roll.
69 dir: PathBuf,
70 /// Sorted (ascending) `base_lsn`s of all live segments, oldest first; the
71 /// last is the active segment. Updated on every roll. Lets a [`Reader`]
72 /// replay across segments (§8.1).
73 segments: Vec<Lsn>,
74 /// The active (highest-`base_lsn`) segment. Its base is `*segments.last()`.
75 active: File,
76 /// Offset of the next byte to write in the active segment.
77 write_offset: u64,
78 oldest_lsn: Lsn,
79 last_lsn: Lsn,
80 durable_lsn: Lsn,
81 segment_size: u64,
82 max_record_size: u32,
83 /// Reusable encode buffer for the current uncommitted batch (§7.1).
84 staging: Vec<u8>,
85 observer: O,
86 /// Set after a durability failure; all subsequent ops return `Poisoned`
87 /// (§12).
88 poisoned: bool,
89 /// Makes `Wal` `!Sync` (single-writer enforcement, §6.2).
90 _not_sync: PhantomData<Cell<()>>,
91}
92
93/// The recovered (or cold-started) writer state, produced by
94/// [`Wal::cold_start`]/[`Wal::recover_all`] and consumed by [`Wal::open_with`].
95struct Recovered {
96 /// The active (highest-`base_lsn`) segment, open read/write for appends.
97 active: File,
98 /// Offset of the next byte to write in the active segment.
99 write_offset: u64,
100 /// Highest durable LSN (active segment's `base_lsn - 1` for an empty active
101 /// segment).
102 last_lsn: Lsn,
103 /// `P`: base LSN of the oldest surviving segment.
104 oldest_lsn: Lsn,
105 /// All live segments' bases, sorted ascending (last is the active segment).
106 segments: Vec<Lsn>,
107 /// Tail state of the active segment after recovery.
108 tail_state: TailState,
109}
110
111impl Wal<NullObserver> {
112 /// Open or create a WAL in `dir` with the default no-op observer.
113 pub fn open(dir: &Path, config: WalConfig) -> Result<(Wal<NullObserver>, RecoveryReport)> {
114 Wal::open_with(dir, config, NullObserver)
115 }
116}
117
118impl<O: DurabilityObserver> Wal<O> {
119 /// Open or create a WAL in `dir`, running recovery, with an explicit
120 /// `observer` (§6). Acquires an exclusive advisory lock on the directory;
121 /// fails with [`Locked`](WalError::Locked) if already held.
122 ///
123 /// Runs full recovery (§8): it cold-starts an empty directory (creating
124 /// `…0001.wal`) or discovers every `*.wal` segment, sorts them by `base_lsn`,
125 /// validates each header, discards an incomplete-header highest-base file left
126 /// by a crash mid-create (§8.4), recovers each segment's record run (§8.2 —
127 /// torn-tail truncation + durable zeroing on the active segment, fatal mid-log
128 /// corruption), and verifies cross-segment LSN continuity (§8.1).
129 pub fn open_with(
130 dir: &Path,
131 config: WalConfig,
132 observer: O,
133 ) -> Result<(Wal<O>, RecoveryReport)> {
134 // §5.3 precondition, additive form (no `segment_size - 91` underflow).
135 if u64::from(config.max_record_size) + 91 > config.segment_size {
136 return Err(WalError::InvalidConfig);
137 }
138
139 std::fs::create_dir_all(dir)?;
140
141 // Exclusive writer lock for the handle's lifetime (§6.2).
142 let lock = OpenOptions::new()
143 .read(true)
144 .write(true)
145 .create(true)
146 .truncate(false)
147 .open(dir.join("LOCK"))?;
148 match rustix::fs::flock(&lock, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
149 Ok(()) => {}
150 // EWOULDBLOCK == EAGAIN on Linux/macOS; the lock is already held.
151 Err(rustix::io::Errno::WOULDBLOCK) => {
152 return Err(WalError::Locked);
153 }
154 Err(e) => return Err(WalError::Io(io::Error::from(e))),
155 }
156
157 // Discover segments: sorted by base_lsn, never trusting dir order (§8.6).
158 let mut bases: Vec<u64> = Vec::new();
159 for entry in std::fs::read_dir(dir)? {
160 let entry = entry?;
161 if let Some(name) = entry.file_name().to_str() {
162 if let Some(base) = segment::parse_base_lsn(name) {
163 bases.push(base);
164 }
165 }
166 }
167 bases.sort_unstable();
168
169 // §8.4: a highest-base file with an incomplete/absent header (crash mid
170 // segment-create, before any record) is discarded; the prior segment
171 // becomes active. May empty `bases` (a crashed cold start) ⇒ cold start.
172 Self::discard_incomplete_highest(dir, &mut bases, config)?;
173
174 let rec = if bases.is_empty() {
175 Self::cold_start(dir, config.segment_size)?
176 } else {
177 Self::recover_all(dir, &bases, config)?
178 };
179
180 let durable_lsn = rec.last_lsn;
181 let report = RecoveryReport {
182 oldest_lsn: rec.oldest_lsn,
183 durable_lsn,
184 tail_state: rec.tail_state,
185 segments_scanned: rec.segments.len(),
186 };
187
188 let wal = Wal {
189 _lock: lock,
190 dir: dir.to_path_buf(),
191 segments: rec.segments,
192 active: rec.active,
193 write_offset: rec.write_offset,
194 oldest_lsn: rec.oldest_lsn,
195 last_lsn: rec.last_lsn,
196 durable_lsn,
197 segment_size: config.segment_size,
198 max_record_size: config.max_record_size,
199 staging: Vec::new(),
200 observer,
201 poisoned: false,
202 _not_sync: PhantomData,
203 };
204 Ok((wal, report))
205 }
206
207 /// Cold start (§8.4): create `…0001.wal`, then fsync the directory so the
208 /// new filename is durable (§7.4 step 5).
209 fn cold_start(dir: &Path, segment_size: u64) -> Result<Recovered> {
210 let active = segment::create(dir, Lsn::FIRST, segment_size)?;
211 fsync_dir(dir)?;
212 // base 1, empty: write offset just past the header, durable_lsn = 0.
213 Ok(Recovered {
214 active,
215 write_offset: HEADER_SIZE,
216 last_lsn: Lsn::NONE,
217 oldest_lsn: Lsn::FIRST,
218 segments: vec![Lsn::FIRST],
219 tail_state: TailState::Clean,
220 })
221 }
222
223 /// §8.4 discard of the incomplete-header highest-base file. If the
224 /// highest-base segment's header does **not** validate, it is either a crash
225 /// mid `segment::create` (header never fully written/synced — and since the
226 /// header is synced *before* any record, such a file holds **no durable
227 /// records**, so discarding it loses nothing) or media corruption of a
228 /// *populated* active segment (fatal, §14.4e). The two are distinguished by
229 /// whether a valid record exists at the first record slot:
230 /// - **no record** ⇒ incomplete create ⇒ unlink it + dir-fsync, drop it from
231 /// `bases` (the prior segment becomes active; an emptied `bases` ⇒ cold
232 /// start);
233 /// - **a record present** ⇒ a real segment with a corrupt header ⇒ fatal
234 /// [`BadSegmentHeader`].
235 ///
236 /// A bad header on a *non-highest* (sealed) segment is always fatal (§8.1
237 /// step 2) and is handled later in [`recover_all`], not here.
238 fn discard_incomplete_highest(
239 dir: &Path,
240 bases: &mut Vec<u64>,
241 config: WalConfig,
242 ) -> Result<()> {
243 let Some(&highest) = bases.last() else {
244 return Ok(());
245 };
246 let base = Lsn(highest);
247 let path = dir.join(segment::filename_for(base));
248 let file = OpenOptions::new().read(true).write(true).open(&path)?;
249
250 let mut header = [0u8; HEADER_SIZE as usize];
251 match file.read_exact_at(&mut header, 0) {
252 Ok(()) => {
253 // Header valid + matching its filename ⇒ a legitimate active
254 // segment (possibly empty); leave it for `recover_all`. (Written
255 // as `matches!` with a guard rather than a let-chain to stay on
256 // the 1.85 MSRV.)
257 if matches!(segment::decode_header(&header), Ok(parsed) if parsed.base_lsn == base)
258 {
259 return Ok(());
260 }
261 // A full 64-byte header that does not validate. Discard iff the
262 // file holds no record (a `fallocate`d but not-yet-header-written
263 // create); a populated segment with a corrupt header is fatal
264 // (§14.4e segment-header corruption ⇒ FATAL), not a discard.
265 let mut buf = Vec::new();
266 let first = segment::read_record_at(
267 &file,
268 HEADER_SIZE,
269 config.segment_size,
270 config.max_record_size,
271 &mut buf,
272 )?;
273 if matches!(first, segment::ScanOutcome::Record { .. }) {
274 return Err(WalError::BadSegmentHeader);
275 }
276 }
277 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
278 // Fewer than 64 bytes on disk. `segment::create` leaves the file
279 // at size 0 (after `create_new`, before `fallocate`) and only ever
280 // jumps to `segment_size` thereafter — so size 0 is a clean
281 // incomplete create (discard), while 1..63 bytes can only be a
282 // real segment physically truncated into its header (§14.4f),
283 // which is fatal `BadSegmentHeader` (the records below 64 are
284 // gone, so this is not a recoverable torn tail).
285 if file.metadata()?.len() != 0 {
286 return Err(WalError::BadSegmentHeader);
287 }
288 }
289 Err(e) => return Err(WalError::Io(e)),
290 }
291
292 // Incomplete create: unlink and make the unlink durable (dir-fsync), then
293 // drop it so the prior segment (if any) is treated as active.
294 drop(file);
295 std::fs::remove_file(&path)?;
296 fsync_dir(dir)?;
297 bases.pop();
298 Ok(())
299 }
300
301 /// Multi-segment recovery (§8.1): validate each header, recover each segment's
302 /// record run (§8.2 — only the highest-base segment is `is_active`, so only it
303 /// may carry a torn tail), and verify cross-segment LSN continuity. `bases` is
304 /// non-empty and sorted ascending; its last element is the active segment.
305 fn recover_all(dir: &Path, bases: &[u64], config: WalConfig) -> Result<Recovered> {
306 let oldest_lsn = Lsn(bases[0]);
307 let last_idx = bases.len() - 1;
308
309 let mut active: Option<File> = None;
310 let mut write_offset = HEADER_SIZE;
311 let mut last_lsn = Lsn::NONE;
312 let mut tail_state = TailState::Clean;
313 // Max LSN of the previous (lower-base) segment, for the continuity check.
314 // `None` until the first non-empty segment is seen.
315 let mut prev_max: Option<Lsn> = None;
316
317 for (i, &base_u64) in bases.iter().enumerate() {
318 let base = Lsn(base_u64);
319 let is_active = i == last_idx;
320
321 let file = OpenOptions::new()
322 .read(true)
323 .write(true)
324 .open(dir.join(segment::filename_for(base)))?;
325
326 // Validate the header and confirm it matches its filename. A bad
327 // header is fatal (§8.1 step 2): the header is written and synced at
328 // creation, before any record, so it is never a torn tail. A header
329 // physically truncated below 64 bytes (§14.4f) maps to
330 // `BadSegmentHeader`, not a raw `UnexpectedEof`, keeping recovery
331 // total (D11). (The highest-base incomplete-header case was already
332 // discarded in `discard_incomplete_highest`.)
333 let mut header = [0u8; HEADER_SIZE as usize];
334 match file.read_exact_at(&mut header, 0) {
335 Ok(()) => {}
336 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
337 return Err(WalError::BadSegmentHeader);
338 }
339 Err(e) => return Err(WalError::Io(e)),
340 }
341 let parsed = segment::decode_header(&header)?;
342 if parsed.base_lsn != base {
343 return Err(WalError::BadSegmentHeader);
344 }
345
346 // Cross-segment continuity (§8.1 step 3): this segment's base must
347 // immediately follow the previous non-empty segment's max LSN.
348 // (`is_some_and` rather than a let-chain to stay on the 1.85 MSRV.)
349 if prev_max.is_some_and(|pm| pm.next() != base) {
350 return Err(WalError::ContiguityViolation);
351 }
352
353 let seg = recovery::recover_segment(
354 &file,
355 base,
356 is_active,
357 config.segment_size,
358 config.max_record_size,
359 )?;
360
361 if is_active {
362 active = Some(file);
363 write_offset = seg.write_offset;
364 last_lsn = seg.max_lsn;
365 tail_state = seg.tail_state;
366 } else {
367 // A sealed segment must contain ≥1 record: a roll only ever
368 // occurs to write records that did not fit, so an empty sealed
369 // segment is an internal gap (§8.1 step 3, fatal).
370 if seg.max_lsn < base {
371 return Err(WalError::ContiguityViolation);
372 }
373 prev_max = Some(seg.max_lsn);
374 }
375 }
376
377 Ok(Recovered {
378 active: active.expect("bases is non-empty ⇒ an active segment was set"),
379 write_offset,
380 last_lsn,
381 oldest_lsn,
382 segments: bases.iter().map(|&b| Lsn(b)).collect(),
383 tail_state,
384 })
385 }
386
387 /// Sequence + buffer a record (§7.1). Pure memory: no syscall, no allocation
388 /// once the staging buffer is warm. The record is **not** durable until a
389 /// later `commit` returns covering it.
390 pub fn append(&mut self, payload: &[u8]) -> Result<Lsn> {
391 if self.poisoned {
392 return Err(WalError::Poisoned);
393 }
394 if payload.len() > self.max_record_size as usize {
395 return Err(WalError::RecordTooLarge);
396 }
397 let lsn = self.last_lsn.next();
398 crate::record::encode_into(&mut self.staging, lsn, payload);
399 self.last_lsn = lsn;
400 Ok(lsn)
401 }
402
403 /// Make all buffered records durable (§7.2), splitting across segments on
404 /// whole-record boundaries when the batch does not fit the active segment
405 /// (§7.3). Each segment touched gets its own `write` + `fdatasync`
406 /// (`F_FULLFSYNC` on macOS) and advances `durable_lsn` to that segment's last
407 /// LSN; the observer fires after each advance (§15.3).
408 ///
409 /// **`commit` is not atomic** (§4.1): on a multi-segment split a crash or an
410 /// I/O failure between two segments' syncs leaves the first segment durable
411 /// and the rest lost — a valid dense suffix (D9), never an internal gap. On
412 /// any `write`/`fdatasync`/roll failure the handle is **poisoned** (§12);
413 /// `durable_lsn` keeps whatever earlier segments achieved (monotonic, never
414 /// regresses).
415 pub fn commit(&mut self) -> Result<Lsn> {
416 if self.poisoned {
417 return Err(WalError::Poisoned);
418 }
419 if self.staging.is_empty() {
420 return Ok(self.durable_lsn);
421 }
422
423 let total = self.staging.len();
424 let mut pos = 0usize;
425
426 // Commit-time whole-record split (§7.3). Loop until the staging buffer is
427 // fully written; "seal + roll" counts as progress, so an empty prefix
428 // cannot spin (termination guaranteed by the §5.3 `max_record_size` bound,
429 // which makes any single record fit a fresh segment).
430 while pos < total {
431 // Step 1: the prefix of whole records that fits the active segment's
432 // remaining space. MAY be empty (the next record does not fit).
433 let remaining = self.segment_size - self.write_offset;
434 let mut scan = pos;
435 let mut prefix_last_lsn = Lsn::NONE;
436 while scan < total {
437 let (lsn, framed) = crate::record::peek(&self.staging[scan..]);
438 if (scan - pos) as u64 + framed as u64 > remaining {
439 break;
440 }
441 prefix_last_lsn = lsn;
442 scan += framed;
443 }
444
445 // Step 2: write + sync the non-empty prefix, advancing durable_lsn for
446 // this segment. A single `write(2)` per segment at the tracked offset.
447 if scan > pos {
448 if let Err(e) = self
449 .active
450 .write_all_at(&self.staging[pos..scan], self.write_offset)
451 {
452 self.poisoned = true;
453 return Err(WalError::Io(e));
454 }
455 if segment::sync_data_fully(&self.active).is_err() {
456 self.poisoned = true;
457 return Err(WalError::FsyncFailed);
458 }
459 self.write_offset += (scan - pos) as u64;
460 self.durable_lsn = prefix_last_lsn;
461 self.observer.on_durable(self.durable_lsn);
462 pos = scan;
463 }
464
465 // Step 3: records remain ⇒ seal the active segment (its remaining
466 // bytes are the pre-allocated zero sentinel — no write needed, and it
467 // is never touched again, D12) and roll to a new segment based at the
468 // first not-yet-written record's LSN.
469 if pos < total {
470 let (new_base, _) = crate::record::peek(&self.staging[pos..]);
471 self.roll(new_base)?;
472 }
473 }
474
475 self.staging.clear();
476 Ok(self.durable_lsn)
477 }
478
479 /// Seal the active segment and roll to a fresh one based at `new_base` (§7.4).
480 /// Creates + pre-allocates + header-writes + `fdatasync`s the new file, then
481 /// `fsync`s the directory so the new filename is durable (the §14.4d gotcha).
482 /// The just-sealed segment is immutable from here on (D12) — only checkpoint
483 /// (M5) deletes it. Poisons the handle on any failure (§12).
484 fn roll(&mut self, new_base: Lsn) -> Result<()> {
485 let new = match segment::create(&self.dir, new_base, self.segment_size) {
486 Ok(f) => f,
487 Err(e) => {
488 self.poisoned = true;
489 return Err(e);
490 }
491 };
492 // Make the new filename durable. The `inject_no_dir_fsync` feature is the
493 // §14.4d negative control: a deliberately-buggy build that omits this
494 // dir-fsync MUST fail recovery under a LazyFS `clear-cache` after a roll
495 // (the rolled segment's filename was never made durable, so the
496 // post-roll records vanish). It is a test-only feature — never enable it
497 // in a real build.
498 #[cfg(not(feature = "inject_no_dir_fsync"))]
499 if let Err(e) = fsync_dir(&self.dir) {
500 self.poisoned = true;
501 return Err(e);
502 }
503 self.active = new;
504 self.write_offset = HEADER_SIZE;
505 self.segments.push(new_base);
506 Ok(())
507 }
508
509 /// Highest durable LSN (§6).
510 #[must_use]
511 pub fn durable_lsn(&self) -> Lsn {
512 self.durable_lsn
513 }
514
515 /// Highest assigned LSN, durable or still buffered (§6).
516 #[must_use]
517 pub fn last_lsn(&self) -> Lsn {
518 self.last_lsn
519 }
520
521 /// A streaming replay [`Reader`] starting at `from` (§6).
522 ///
523 /// `from == Lsn(0)` means "from the beginning". A `from` below the oldest
524 /// available LSN is a fatal gap (§15.4) — the needed records were
525 /// checkpointed away; never a silent skip. (Dormant in M2, where
526 /// `oldest_lsn == 1`.)
527 pub fn reader_from(&self, from: Lsn) -> Result<Reader<'_>> {
528 if from.0 != 0 && from < self.oldest_lsn {
529 return Err(WalError::ContiguityViolation);
530 }
531 let effective_from = if from.0 == 0 { Lsn::FIRST } else { from };
532 // Open the oldest segment for the reader. (Opening it here, before any
533 // measured `Reader::next`, keeps the single-segment read hot path
534 // zero-alloc; crossing a boundary later opens the next file lazily.)
535 let first = File::open(self.dir.join(segment::filename_for(self.segments[0])))?;
536 Ok(Reader::new(
537 &self.dir,
538 &self.segments,
539 first,
540 effective_from,
541 self.segment_size,
542 self.max_record_size,
543 ))
544 }
545
546 /// Delete whole sealed segments fully superseded by `up_to`, reclaiming space
547 /// (§9). A segment covering `[b, b')` (`b'` = the next segment's base) is
548 /// deletable iff `b' − 1 ≤ up_to`; the **active segment is never deleted**.
549 ///
550 /// Deletion is **oldest-first**, then a single directory `fsync` makes the
551 /// unlinks durable. Because only a prefix is ever removed, survivors remain a
552 /// contiguous suffix at *every* crash point (D8/D9): a crash before the
553 /// dir-fsync recovers via the §4 D2 "missing prefix accepted silently" path to
554 /// the same suffix. Checkpoint only unlinks whole files — it never rewrites or
555 /// compacts — and advances `oldest_lsn (P)`.
556 ///
557 /// **Caller rule (§9):** pass only `up_to ≤ your latest durable *snapshot*
558 /// LSN`, never `durable_lsn` — recovery is "latest durable snapshot + replay of
559 /// the log after it", so deleting the log past your snapshot silently caps
560 /// recovery. The WAL trusts the caller and cannot verify this (the inverse of
561 /// D8). Any unlink/fsync failure **poisons** the handle (§12).
562 pub fn checkpoint(&mut self, up_to: Lsn) -> Result<()> {
563 if self.poisoned {
564 return Err(WalError::Poisoned);
565 }
566
567 let n = deletable_prefix_len(&self.segments, up_to);
568 if n == 0 {
569 // Nothing fully superseded (or only the active segment remains).
570 return Ok(());
571 }
572
573 // Unlink oldest-first. A `NotFound` is tolerated so a checkpoint re-run
574 // after a crash that already removed a file (but not yet its dir-fsync) is
575 // idempotent (D7) rather than fatal.
576 for &base in &self.segments[..n] {
577 match std::fs::remove_file(self.dir.join(segment::filename_for(base))) {
578 Ok(()) => {}
579 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
580 Err(e) => {
581 self.poisoned = true;
582 return Err(WalError::Io(e));
583 }
584 }
585 }
586
587 // One dir-fsync makes the unlinks durable (§9); a failure poisons (§12).
588 if let Err(e) = fsync_dir(&self.dir) {
589 self.poisoned = true;
590 return Err(e);
591 }
592
593 // In-memory state is advanced only *after* the deletions are durable, so a
594 // crash before the dir-fsync recovers to the same contiguous suffix.
595 self.segments.drain(..n);
596 self.oldest_lsn = *self
597 .segments
598 .first()
599 .expect("the active segment is never deleted ⇒ ≥1 segment remains");
600 Ok(())
601 }
602}
603
604/// Count of oldest **sealed** segments fully superseded by `up_to` and therefore
605/// deletable (§9). Segment `i` covers `[bases[i], bases[i+1])`, so it is deletable
606/// iff `bases[i+1] − 1 ≤ up_to`. The active segment (`bases.last()`) is never
607/// deletable, so the result is always `< bases.len()` and `checkpoint` always
608/// leaves ≥1 segment. `bases` is sorted ascending (the writer's invariant).
609fn deletable_prefix_len(bases: &[Lsn], up_to: Lsn) -> usize {
610 if bases.is_empty() {
611 return 0;
612 }
613 // The candidate `b'` boundaries are `bases[1..]` (the active segment, index 0
614 // of this tail's owner, is never its own `b'`; slicing from 1 also excludes the
615 // last segment as a deletable). Since `bases` is strictly increasing, the
616 // predicate `b − 1 ≤ up_to` is monotone over that tail, so `partition_point`
617 // binary-searches the cut in O(log N). `b.0 − 1` cannot underflow: a valid base
618 // is ≥ 1 (`Lsn(0)` is the reserved sentinel, rejected at header decode).
619 bases[1..].partition_point(|&b| b.0 - 1 <= up_to.0)
620}
621
622/// `fsync` a directory so a newly-created filename within it is durable (§7.4).
623fn fsync_dir(dir: &Path) -> Result<()> {
624 let dir_file = File::open(dir)?;
625 rustix::fs::fsync(&dir_file).map_err(io::Error::from)?;
626 Ok(())
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use crate::record;
633
634 fn cfg() -> WalConfig {
635 // Small but single-segment: holds the modest batches these tests use.
636 WalConfig {
637 segment_size: 64 * 1024,
638 max_record_size: 4096,
639 }
640 }
641
642 /// A tiny segment that forces rolls and commit-time splits: 512-byte
643 /// segments (448 usable after the 64-byte header), 256-byte max record.
644 fn tiny_cfg() -> WalConfig {
645 WalConfig {
646 segment_size: 512,
647 max_record_size: 256,
648 }
649 }
650
651 fn tmp() -> tempfile::TempDir {
652 tempfile::tempdir().unwrap()
653 }
654
655 /// Fabricate a segment at `base` holding `payloads` as dense records starting
656 /// at `base` (empty ⇒ a header-only segment), bypassing `Wal` so multi-segment
657 /// layouts can be built for recovery tests. Uses `cfg()`'s `segment_size`.
658 fn fab_segment(dir: &Path, base: Lsn, payloads: &[&[u8]]) {
659 let f = segment::create(dir, base, cfg().segment_size).unwrap();
660 let mut offset = HEADER_SIZE;
661 let mut lsn = base;
662 let mut buf = Vec::new();
663 for p in payloads {
664 buf.clear();
665 let framed = record::encode_into(&mut buf, lsn, p);
666 f.write_all_at(&buf, offset).unwrap();
667 offset += framed as u64;
668 lsn = lsn.next();
669 }
670 f.sync_data().unwrap();
671 }
672
673 /// Clobber the first 8 header bytes (the magic) of the segment at `base`,
674 /// durably — simulating a torn/incomplete create or header corruption.
675 fn clobber_header(dir: &Path, base: Lsn) {
676 let f = OpenOptions::new()
677 .write(true)
678 .open(dir.join(segment::filename_for(base)))
679 .unwrap();
680 f.write_all_at(&[0xFFu8; 8], 0).unwrap();
681 f.sync_data().unwrap();
682 }
683
684 #[test]
685 fn open_rejects_config_violating_section_5_3() {
686 let dir = tmp();
687 let bad = WalConfig {
688 segment_size: 100,
689 max_record_size: 100, // 100 + 91 > 100
690 };
691 assert!(matches!(
692 Wal::open(dir.path(), bad),
693 Err(WalError::InvalidConfig)
694 ));
695
696 // Degenerate sub-91 segments: the subtractive `segment_size - 91` form
697 // would underflow/wrap and bypass the check entirely; the additive form
698 // (`max_record_size + 91 > segment_size`) must still reject them.
699 for tiny in [
700 WalConfig {
701 segment_size: 90,
702 max_record_size: 0,
703 },
704 WalConfig {
705 segment_size: 0,
706 max_record_size: 0,
707 },
708 ] {
709 assert!(matches!(
710 Wal::open(dir.path(), tiny),
711 Err(WalError::InvalidConfig)
712 ));
713 }
714 }
715
716 #[test]
717 fn cold_start_creates_first_segment() {
718 let dir = tmp();
719 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
720 assert_eq!(report.oldest_lsn, Lsn(1));
721 assert_eq!(report.durable_lsn, Lsn(0));
722 assert_eq!(report.tail_state, TailState::Clean);
723 assert_eq!(wal.last_lsn(), Lsn(0));
724 assert!(dir.path().join("00000000000000000001.wal").exists());
725 }
726
727 #[test]
728 fn lsn_assignment_is_monotone_dense_from_one() {
729 let dir = tmp();
730 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
731 assert_eq!(wal.append(b"a").unwrap(), Lsn(1));
732 assert_eq!(wal.append(b"b").unwrap(), Lsn(2));
733 assert_eq!(wal.append(b"c").unwrap(), Lsn(3));
734 assert_eq!(wal.last_lsn(), Lsn(3));
735 assert_eq!(wal.durable_lsn(), Lsn(0)); // not yet committed
736 }
737
738 #[test]
739 fn append_rejects_oversized_payload() {
740 let dir = tmp();
741 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
742 let too_big = vec![0u8; cfg().max_record_size as usize + 1];
743 assert!(matches!(
744 wal.append(&too_big),
745 Err(WalError::RecordTooLarge)
746 ));
747 // A max-sized payload is accepted.
748 let max = vec![0u8; cfg().max_record_size as usize];
749 assert!(wal.append(&max).is_ok());
750 }
751
752 #[test]
753 fn max_sized_record_fits_segment_and_round_trips() {
754 // §14.1: a max-sized record (max = segment - 91) fits and round-trips.
755 let dir = tmp();
756 let c = WalConfig {
757 segment_size: 8 * 1024,
758 max_record_size: 8 * 1024 - 91,
759 };
760 let (mut wal, _) = Wal::open(dir.path(), c).unwrap();
761 let payload = vec![0xABu8; c.max_record_size as usize];
762 wal.append(&payload).unwrap();
763 assert_eq!(wal.commit().unwrap(), Lsn(1));
764
765 let mut reader = wal.reader_from(Lsn(1)).unwrap();
766 let (lsn, got) = reader.next().unwrap().unwrap();
767 assert_eq!(lsn, Lsn(1));
768 assert_eq!(got, &payload[..]);
769 assert!(reader.next().is_none());
770 }
771
772 #[test]
773 fn commit_then_read_back() {
774 let dir = tmp();
775 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
776 wal.append(b"hello").unwrap();
777 wal.append(b"world").unwrap();
778 assert_eq!(wal.commit().unwrap(), Lsn(2));
779 assert_eq!(wal.durable_lsn(), Lsn(2));
780
781 let mut reader = wal.reader_from(Lsn(1)).unwrap();
782 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(1), &b"hello"[..]));
783 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(2), &b"world"[..]));
784 assert!(reader.next().is_none());
785 }
786
787 #[test]
788 fn empty_commit_is_a_noop() {
789 let dir = tmp();
790 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
791 assert_eq!(wal.commit().unwrap(), Lsn(0));
792 }
793
794 #[test]
795 fn reopen_recovers_committed_records() {
796 let dir = tmp();
797 {
798 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
799 wal.append(b"one").unwrap();
800 wal.append(b"two").unwrap();
801 wal.commit().unwrap();
802 }
803 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
804 assert_eq!(report.durable_lsn, Lsn(2));
805 assert_eq!(report.oldest_lsn, Lsn(1));
806 assert_eq!(wal.last_lsn(), Lsn(2));
807
808 let mut reader = wal.reader_from(Lsn(1)).unwrap();
809 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(1), &b"one"[..]));
810 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(2), &b"two"[..]));
811 assert!(reader.next().is_none());
812 }
813
814 #[test]
815 fn append_after_reopen_resumes_write_offset() {
816 // Exercises the resume `write_offset` that reopen_single accumulates:
817 // writing after a reopen must neither overwrite the last record nor
818 // leave a hole. Replay across the boundary must be dense.
819 let dir = tmp();
820 {
821 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
822 wal.append(b"a").unwrap();
823 wal.append(b"b").unwrap();
824 wal.append(b"c").unwrap();
825 wal.commit().unwrap();
826 }
827 {
828 let (mut wal, report) = Wal::open(dir.path(), cfg()).unwrap();
829 assert_eq!(report.durable_lsn, Lsn(3));
830 assert_eq!(wal.append(b"d").unwrap(), Lsn(4));
831 assert_eq!(wal.append(b"e").unwrap(), Lsn(5));
832 assert_eq!(wal.commit().unwrap(), Lsn(5));
833 }
834 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
835 assert_eq!(report.durable_lsn, Lsn(5));
836 let mut reader = wal.reader_from(Lsn(0)).unwrap();
837 let expected: [&[u8]; 5] = [b"a", b"b", b"c", b"d", b"e"];
838 for (i, want) in expected.iter().enumerate() {
839 let (lsn, got) = reader.next().unwrap().unwrap();
840 assert_eq!(lsn, Lsn(i as u64 + 1));
841 assert_eq!(got, *want);
842 }
843 assert!(reader.next().is_none());
844 }
845
846 #[test]
847 fn commit_splits_batch_across_segments_on_whole_records() {
848 // §7.3 / P4: a batch larger than the active segment splits on whole-record
849 // boundaries; `durable_lsn` advances per segment; replay reconstructs the
850 // dense sequence with no record spanning a boundary (D2/D6). Each framed
851 // record is 20 + 200 + pad(4) = 224 bytes, so two fit per 512-byte segment
852 // (64 + 448) and the third forces a roll.
853 let dir = tmp();
854 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
855 let payload = vec![0xCDu8; 200];
856 for _ in 0..5 {
857 wal.append(&payload).unwrap();
858 }
859 assert_eq!(wal.commit().unwrap(), Lsn(5));
860 assert_eq!(wal.durable_lsn(), Lsn(5));
861 // 5 records, 2 per segment ⇒ segments based at 1, 3, 5.
862 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3), Lsn(5)]);
863 for b in [1u64, 3, 5] {
864 assert!(
865 dir.path().join(segment::filename_for(Lsn(b))).exists(),
866 "segment {b} should exist"
867 );
868 }
869 let mut r = wal.reader_from(Lsn(0)).unwrap();
870 for i in 1..=5 {
871 let (lsn, got) = r.next().unwrap().unwrap();
872 assert_eq!(lsn, Lsn(i));
873 assert_eq!(got, &payload[..]);
874 }
875 assert!(r.next().is_none());
876 }
877
878 #[test]
879 fn commit_split_advances_durable_lsn_per_segment() {
880 // §7.2/§15.3: the observer fires once per synced segment, with a strictly
881 // monotone watermark — the achieved durable LSN of each segment in turn.
882 use crate::observer::DurabilityObserver;
883 struct Rec(std::rc::Rc<std::cell::RefCell<Vec<u64>>>);
884 impl DurabilityObserver for Rec {
885 fn on_durable(&mut self, lsn: Lsn) {
886 self.0.borrow_mut().push(lsn.0);
887 }
888 }
889 let dir = tmp();
890 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
891 let (mut wal, _) = Wal::open_with(dir.path(), tiny_cfg(), Rec(seen.clone())).unwrap();
892 for _ in 0..5 {
893 wal.append(&[0u8; 200]).unwrap();
894 }
895 assert_eq!(wal.commit().unwrap(), Lsn(5));
896 // Three segments synced in order ⇒ watermarks 2, 4, 5 (monotone).
897 assert_eq!(*seen.borrow(), vec![2, 4, 5]);
898 }
899
900 #[test]
901 fn commit_handles_empty_prefix_seal_and_roll() {
902 // §7.3 empty-prefix: when the active segment's remaining space cannot hold
903 // even the next whole record, seal it as-is (its pre-allocated zeros are
904 // the §5.4 sentinel) and roll — counts as progress, no spin, no split.
905 let dir = tmp();
906 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
907 // First commit: one 200-byte record (framed 224) ⇒ write_offset = 288.
908 wal.append(&[0u8; 200]).unwrap();
909 assert_eq!(wal.commit().unwrap(), Lsn(1));
910 // Remaining = 512 - 288 = 224. A 256-byte record frames to 280 > 224, so
911 // the prefix is empty ⇒ seal + roll; it then fits the fresh segment.
912 wal.append(&[1u8; 256]).unwrap();
913 assert_eq!(wal.commit().unwrap(), Lsn(2));
914 assert_eq!(wal.segments, vec![Lsn(1), Lsn(2)]);
915 // r2 decodes whole from the new segment.
916 let mut r = wal.reader_from(Lsn(2)).unwrap();
917 let (lsn, got) = r.next().unwrap().unwrap();
918 assert_eq!(lsn, Lsn(2));
919 assert_eq!(got.len(), 256);
920 assert!(r.next().is_none());
921 }
922
923 #[test]
924 fn append_after_reopen_resumes_across_a_roll() {
925 // A reopened multi-segment log keeps appending into the active segment and
926 // rolls again as needed; replay stays dense across close/reopen (D2/D6).
927 let dir = tmp();
928 let payload = vec![7u8; 200];
929 {
930 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
931 for _ in 0..3 {
932 wal.append(&payload).unwrap();
933 }
934 assert_eq!(wal.commit().unwrap(), Lsn(3)); // segs 1, 3
935 }
936 {
937 let (mut wal, report) = Wal::open(dir.path(), tiny_cfg()).unwrap();
938 assert_eq!(report.durable_lsn, Lsn(3));
939 for _ in 0..2 {
940 wal.append(&payload).unwrap();
941 }
942 assert_eq!(wal.commit().unwrap(), Lsn(5)); // rolls to seg 5
943 }
944 let (wal, report) = Wal::open(dir.path(), tiny_cfg()).unwrap();
945 assert_eq!(report.durable_lsn, Lsn(5));
946 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3), Lsn(5)]);
947 let mut r = wal.reader_from(Lsn(0)).unwrap();
948 for i in 1..=5 {
949 assert_eq!(r.next().unwrap().unwrap().0, Lsn(i));
950 }
951 assert!(r.next().is_none());
952 }
953
954 #[test]
955 fn recover_multi_segment_clean_roundtrip() {
956 // §8.1: a fabricated 2-segment log reopens, validates continuity, and
957 // replays the dense sequence across the boundary (D2/D6).
958 let dir = tmp();
959 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]); // lsn 1,2
960 fab_segment(dir.path(), Lsn(3), &[b"c"]); // lsn 3
961 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
962 assert_eq!(report.oldest_lsn, Lsn(1));
963 assert_eq!(report.durable_lsn, Lsn(3));
964 assert_eq!(report.segments_scanned, 2);
965 let mut r = wal.reader_from(Lsn(0)).unwrap();
966 assert_eq!(r.next().unwrap().unwrap(), (Lsn(1), &b"a"[..]));
967 assert_eq!(r.next().unwrap().unwrap(), (Lsn(2), &b"b"[..]));
968 assert_eq!(r.next().unwrap().unwrap(), (Lsn(3), &b"c"[..]));
969 assert!(r.next().is_none());
970 }
971
972 #[test]
973 fn recover_is_idempotent_across_repeated_open() {
974 // D7: reopening a multi-segment log repeatedly is stable.
975 let dir = tmp();
976 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]);
977 fab_segment(dir.path(), Lsn(3), &[b"c", b"d"]);
978 for _ in 0..3 {
979 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
980 assert_eq!(report.oldest_lsn, Lsn(1));
981 assert_eq!(report.durable_lsn, Lsn(4));
982 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3)]);
983 }
984 }
985
986 #[test]
987 fn recover_cross_segment_gap_is_contiguity_violation() {
988 // §8.1 step 3 (D2): a gap between a sealed segment's max LSN and the next
989 // segment's base is fatal, never a silent internal gap.
990 let dir = tmp();
991 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]); // max 2
992 fab_segment(dir.path(), Lsn(5), &[b"c"]); // base 5 ≠ 3
993 assert!(matches!(
994 Wal::open(dir.path(), cfg()),
995 Err(WalError::ContiguityViolation)
996 ));
997 }
998
999 #[test]
1000 fn recover_empty_sealed_segment_is_contiguity_violation() {
1001 // §8.1 step 3: a sealed (non-highest) segment must hold ≥1 record; an
1002 // empty one is a fatal internal gap.
1003 let dir = tmp();
1004 fab_segment(dir.path(), Lsn(1), &[b"a"]); // max 1
1005 fab_segment(dir.path(), Lsn(2), &[]); // empty SEALED
1006 fab_segment(dir.path(), Lsn(3), &[b"c"]); // highest ⇒ seg 2 is sealed
1007 assert!(matches!(
1008 Wal::open(dir.path(), cfg()),
1009 Err(WalError::ContiguityViolation)
1010 ));
1011 }
1012
1013 #[test]
1014 fn recover_empty_active_segment_yields_base_minus_one() {
1015 // §8.4: an empty active segment (crash right after a roll) is valid;
1016 // durable_lsn = base − 1 (the prior segment's max).
1017 let dir = tmp();
1018 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]); // max 2
1019 fab_segment(dir.path(), Lsn(3), &[]); // empty ACTIVE
1020 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
1021 assert_eq!(report.oldest_lsn, Lsn(1));
1022 assert_eq!(report.durable_lsn, Lsn(2)); // base 3 − 1
1023 assert_eq!(report.tail_state, TailState::Clean);
1024 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3)]);
1025 // Replay returns only the prior segment's records.
1026 let mut r = wal.reader_from(Lsn(0)).unwrap();
1027 assert_eq!(r.next().unwrap().unwrap().0, Lsn(1));
1028 assert_eq!(r.next().unwrap().unwrap().0, Lsn(2));
1029 assert!(r.next().is_none());
1030 }
1031
1032 #[test]
1033 fn recover_discards_incomplete_highest_base_file() {
1034 // §8.4: a highest-base file with a corrupt/incomplete header and NO
1035 // records (crash mid segment-create) is discarded; the prior segment
1036 // becomes active.
1037 let dir = tmp();
1038 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]);
1039 // A freshly-created seg 3 (pre-allocated zeros, no record) with a trashed
1040 // header — the torn-create signature.
1041 segment::create(dir.path(), Lsn(3), cfg().segment_size).unwrap();
1042 clobber_header(dir.path(), Lsn(3));
1043 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
1044 assert_eq!(report.durable_lsn, Lsn(2));
1045 assert_eq!(wal.segments, vec![Lsn(1)]);
1046 assert!(
1047 !dir.path().join(segment::filename_for(Lsn(3))).exists(),
1048 "the incomplete highest-base file must be unlinked"
1049 );
1050 }
1051
1052 #[test]
1053 fn recover_incomplete_sole_segment_cold_starts() {
1054 // §8.4: a crashed cold start (sole base-1 file, incomplete header, no
1055 // records) is discarded and recovery cold-starts a fresh empty log.
1056 let dir = tmp();
1057 segment::create(dir.path(), Lsn(1), cfg().segment_size).unwrap();
1058 clobber_header(dir.path(), Lsn(1));
1059 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
1060 assert_eq!(report.oldest_lsn, Lsn(1));
1061 assert_eq!(report.durable_lsn, Lsn(0));
1062 assert_eq!(wal.segments, vec![Lsn(1)]);
1063 }
1064
1065 #[test]
1066 fn recover_corrupt_header_on_populated_highest_is_fatal() {
1067 // §14.4e: a populated highest segment with a corrupt header is NOT a torn
1068 // create — it is fatal corruption, not a discard.
1069 let dir = tmp();
1070 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]);
1071 fab_segment(dir.path(), Lsn(3), &[b"c"]); // has a real record
1072 clobber_header(dir.path(), Lsn(3));
1073 assert!(matches!(
1074 Wal::open(dir.path(), cfg()),
1075 Err(WalError::BadSegmentHeader)
1076 ));
1077 }
1078
1079 #[test]
1080 fn recover_corrupt_header_on_sealed_segment_is_fatal() {
1081 // §8.1 step 2: a bad header on a sealed (non-highest) segment is always
1082 // fatal — no discard path.
1083 let dir = tmp();
1084 fab_segment(dir.path(), Lsn(1), &[b"a", b"b"]);
1085 fab_segment(dir.path(), Lsn(3), &[b"c"]);
1086 clobber_header(dir.path(), Lsn(1));
1087 assert!(matches!(
1088 Wal::open(dir.path(), cfg()),
1089 Err(WalError::BadSegmentHeader)
1090 ));
1091 }
1092
1093 #[test]
1094 fn recover_missing_prefix_is_accepted_silently() {
1095 // §4 D2 / §8.1: a checkpointed-away prefix (P > 1) opens fine; oldest_lsn
1096 // is the lowest surviving base, and a reader from below it is a fatal gap.
1097 let dir = tmp();
1098 fab_segment(dir.path(), Lsn(5), &[b"e", b"f"]);
1099 let (wal, report) = Wal::open(dir.path(), cfg()).unwrap();
1100 assert_eq!(report.oldest_lsn, Lsn(5));
1101 assert_eq!(report.durable_lsn, Lsn(6));
1102 assert!(matches!(
1103 wal.reader_from(Lsn(4)),
1104 Err(WalError::ContiguityViolation)
1105 ));
1106 let mut r = wal.reader_from(Lsn(5)).unwrap();
1107 assert_eq!(r.next().unwrap().unwrap(), (Lsn(5), &b"e"[..]));
1108 assert_eq!(r.next().unwrap().unwrap(), (Lsn(6), &b"f"[..]));
1109 assert!(r.next().is_none());
1110 }
1111
1112 #[test]
1113 fn second_open_is_locked() {
1114 let dir = tmp();
1115 let (_held, _) = Wal::open(dir.path(), cfg()).unwrap();
1116 assert!(matches!(
1117 Wal::open(dir.path(), cfg()),
1118 Err(WalError::Locked)
1119 ));
1120 }
1121
1122 #[test]
1123 fn handle_is_send() {
1124 // §6.2: the write handle is `Send` (it may be moved to another thread) but
1125 // **not** `Sync` (it cannot be shared). This asserts the `Send` half at
1126 // compile time; the `!Sync` half is the `tests/ui/wal_not_sync.rs`
1127 // trybuild compile-fail proof (§14.6). Together they pin "Send but !Sync".
1128 fn assert_send<T: Send>() {}
1129 assert_send::<Wal<NullObserver>>();
1130 }
1131
1132 #[test]
1133 fn reader_from_skips_earlier_records() {
1134 let dir = tmp();
1135 let (mut wal, _) = Wal::open(dir.path(), cfg()).unwrap();
1136 for i in 1..=5u8 {
1137 wal.append(&[i]).unwrap();
1138 }
1139 wal.commit().unwrap();
1140 let mut reader = wal.reader_from(Lsn(3)).unwrap();
1141 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(3), &[3u8][..]));
1142 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(4), &[4u8][..]));
1143 assert_eq!(reader.next().unwrap().unwrap(), (Lsn(5), &[5u8][..]));
1144 assert!(reader.next().is_none());
1145 }
1146
1147 // ----- M5: checkpoint / retention (§9) -----
1148
1149 #[test]
1150 fn deletable_prefix_len_boundary_math() {
1151 // §14.1: exhaustive small-case boundary check of `b' − 1 ≤ up_to`.
1152 // Segments based at 1, 3, 6 (active = 6, never deletable). Their covering
1153 // ranges are [1,3), [3,6); the deletion boundaries are b'−1 = 2 and 5.
1154 let bases = [Lsn(1), Lsn(3), Lsn(6)];
1155 // up_to → expected number of deletable (oldest) segments.
1156 let cases: &[(u64, usize)] = &[
1157 (0, 0), // below everything
1158 (1, 0), // one below seg-1's boundary (b'−1 = 2)
1159 (2, 1), // exactly at seg-1's boundary ⇒ seg 1 deletable
1160 (3, 1), // between boundaries
1161 (4, 1), // one below seg-3's boundary (b'−1 = 5)
1162 (5, 2), // exactly at seg-3's boundary ⇒ segs 1 and 3 deletable
1163 (6, 2), // above the last sealed boundary; active (6) still kept
1164 (1000, 2),
1165 ];
1166 for &(up_to, want) in cases {
1167 assert_eq!(
1168 deletable_prefix_len(&bases, Lsn(up_to)),
1169 want,
1170 "up_to={up_to}"
1171 );
1172 }
1173 // The active segment is never counted: a single-segment log and an empty
1174 // list yield 0 for any up_to (no underflow, no over-delete).
1175 assert_eq!(deletable_prefix_len(&[Lsn(1)], Lsn(u64::MAX)), 0);
1176 assert_eq!(deletable_prefix_len(&[Lsn(42)], Lsn(u64::MAX)), 0);
1177 assert_eq!(deletable_prefix_len(&[], Lsn(u64::MAX)), 0);
1178 }
1179
1180 /// Build a 3-segment log (bases 1, 3, 5) by committing five 200-byte records
1181 /// into tiny (512-byte) segments; returns the dir handle.
1182 fn three_segment_log(dir: &Path) -> Vec<Vec<u8>> {
1183 let (mut wal, _) = Wal::open(dir, tiny_cfg()).unwrap();
1184 let payloads: Vec<Vec<u8>> = (1..=5u8).map(|i| vec![i; 200]).collect();
1185 for p in &payloads {
1186 wal.append(p).unwrap();
1187 }
1188 assert_eq!(wal.commit().unwrap(), Lsn(5));
1189 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3), Lsn(5)]);
1190 payloads
1191 }
1192
1193 #[test]
1194 fn checkpoint_deletes_superseded_sealed_segments_oldest_first() {
1195 // §9: checkpoint(up_to) unlinks the fully-superseded oldest segments and
1196 // advances oldest_lsn; the active segment is kept.
1197 let dir = tmp();
1198 three_segment_log(dir.path());
1199 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1200 // Seg 1 covers [1,3) ⇒ deletable at up_to ≥ 2. up_to = 2 deletes only it.
1201 wal.checkpoint(Lsn(2)).unwrap();
1202 assert_eq!(wal.segments, vec![Lsn(3), Lsn(5)]);
1203 assert_eq!(wal.oldest_lsn, Lsn(3));
1204 assert!(!dir.path().join(segment::filename_for(Lsn(1))).exists());
1205 assert!(dir.path().join(segment::filename_for(Lsn(3))).exists());
1206 assert!(dir.path().join(segment::filename_for(Lsn(5))).exists());
1207 }
1208
1209 #[test]
1210 fn checkpoint_never_deletes_the_active_segment() {
1211 // §9: even an up_to far beyond durable keeps the active segment (it is the
1212 // only one without a known b'); nothing is over-deleted.
1213 let dir = tmp();
1214 three_segment_log(dir.path());
1215 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1216 wal.checkpoint(Lsn(u64::MAX)).unwrap();
1217 assert_eq!(wal.segments, vec![Lsn(5)]); // only the active base 5 remains
1218 assert_eq!(wal.oldest_lsn, Lsn(5));
1219 assert_eq!(wal.durable_lsn(), Lsn(5)); // untouched
1220 assert_eq!(wal.last_lsn(), Lsn(5));
1221 }
1222
1223 #[test]
1224 fn checkpoint_boundary_is_exact() {
1225 // D8: up_to one below a segment's b'−1 boundary keeps it; exactly at it
1226 // deletes it. Seg 1 = [1,3) ⇒ boundary b'−1 = 2.
1227 let dir = tmp();
1228 three_segment_log(dir.path());
1229 {
1230 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1231 wal.checkpoint(Lsn(1)).unwrap(); // one below ⇒ no deletion
1232 assert_eq!(wal.segments, vec![Lsn(1), Lsn(3), Lsn(5)]);
1233 }
1234 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1235 wal.checkpoint(Lsn(2)).unwrap(); // exactly at ⇒ delete seg 1
1236 assert_eq!(wal.segments, vec![Lsn(3), Lsn(5)]);
1237 }
1238
1239 #[test]
1240 fn checkpoint_then_reopen_recovers_dense_suffix() {
1241 // §14.2 P5 / D7,D8: after checkpoint the log reopens to a dense suffix from
1242 // the new oldest_lsn; retained records are byte-identical; a reader from
1243 // below the new P is a fatal gap (§15.4); none of the kept records is lost.
1244 let dir = tmp();
1245 let payloads = three_segment_log(dir.path());
1246 {
1247 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1248 wal.checkpoint(Lsn(2)).unwrap(); // drop seg 1 (lsns 1,2)
1249 }
1250 let (wal, report) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1251 assert_eq!(report.oldest_lsn, Lsn(3));
1252 assert_eq!(report.durable_lsn, Lsn(5));
1253 // Below the new P ⇒ fatal gap, never a silent skip.
1254 assert!(matches!(
1255 wal.reader_from(Lsn(2)),
1256 Err(WalError::ContiguityViolation)
1257 ));
1258 // Dense, byte-identical replay of the retained suffix [3, 5].
1259 let mut r = wal.reader_from(Lsn(0)).unwrap();
1260 for lsn in 3..=5u64 {
1261 let (got_lsn, got) = r.next().unwrap().unwrap();
1262 assert_eq!(got_lsn, Lsn(lsn));
1263 assert_eq!(got, &payloads[lsn as usize - 1][..]);
1264 }
1265 assert!(r.next().is_none());
1266 }
1267
1268 #[test]
1269 fn checkpoint_is_idempotent_and_below_oldest_is_noop() {
1270 // D7: repeating the same checkpoint is a no-op; an up_to below the current
1271 // oldest deletes nothing.
1272 let dir = tmp();
1273 three_segment_log(dir.path());
1274 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1275 wal.checkpoint(Lsn(2)).unwrap();
1276 assert_eq!(wal.segments, vec![Lsn(3), Lsn(5)]);
1277 wal.checkpoint(Lsn(2)).unwrap(); // same call again ⇒ unchanged
1278 assert_eq!(wal.segments, vec![Lsn(3), Lsn(5)]);
1279 wal.checkpoint(Lsn(0)).unwrap(); // below oldest ⇒ unchanged
1280 assert_eq!(wal.segments, vec![Lsn(3), Lsn(5)]);
1281 }
1282
1283 #[test]
1284 fn checkpoint_can_still_append_and_roll_after() {
1285 // After reclaiming the prefix, appends continue into the active segment and
1286 // roll as usual; replay stays dense from the new oldest_lsn.
1287 let dir = tmp();
1288 three_segment_log(dir.path());
1289 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1290 wal.checkpoint(Lsn(4)).unwrap(); // drop segs 1 and 3 ⇒ oldest = 5
1291 assert_eq!(wal.segments, vec![Lsn(5)]);
1292 for _ in 0..3 {
1293 wal.append(&[9u8; 200]).unwrap();
1294 }
1295 assert_eq!(wal.commit().unwrap(), Lsn(8)); // rolls past seg 5
1296 let mut r = wal.reader_from(Lsn(0)).unwrap();
1297 for lsn in 5..=8u64 {
1298 assert_eq!(r.next().unwrap().unwrap().0, Lsn(lsn));
1299 }
1300 assert!(r.next().is_none());
1301 }
1302
1303 #[test]
1304 fn checkpoint_on_poisoned_handle_errors_and_deletes_nothing() {
1305 // §12: a poisoned handle refuses checkpoint and touches no files.
1306 let dir = tmp();
1307 three_segment_log(dir.path());
1308 let (mut wal, _) = Wal::open(dir.path(), tiny_cfg()).unwrap();
1309 wal.poisoned = true;
1310 assert!(matches!(wal.checkpoint(Lsn(2)), Err(WalError::Poisoned)));
1311 assert!(dir.path().join(segment::filename_for(Lsn(1))).exists());
1312 }
1313}