Skip to main content

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}