open_wal/lib.rs
1//! `open-wal` — a focused, embeddable, single-writer append-only write-ahead
2//! log for an LMAX-style, event-sourced system.
3//!
4//! **Durability-first:** a committed record survives process crash and power
5//! loss on honest hardware. The WAL stores **opaque byte payloads** —
6//! serialization is entirely the caller's concern. It is not a database, not
7//! multi-writer, and runs no background threads.
8//!
9//! The normative design lives in `docs/wal_design_v6.md`; the durability
10//! invariants D1–D12 there are binding on every change.
11//!
12//! This crate is built in milestones (§13). **M0 (foundations)** provides the
13//! core value types — [`Lsn`], [`WalConfig`], [`WalError`] — and the CRC-32C
14//! checksum ([`crc32c`]). **M1** adds the internal record codec (`record` —
15//! encode/decode of the §5.3 framing). **M2** adds the single-segment write
16//! path and replay: [`Wal::open`]/[`append`](Wal::append)/[`commit`](Wal::commit),
17//! a streaming [`Reader`], the [`DurabilityObserver`] hook, segment
18//! pre-allocation and `fdatasync`, and a zero-allocation hot path. **M3** adds
19//! intra-segment crash recovery (torn-tail detection + durable zeroing, fatal
20//! mid-log corruption). **M4** adds the multi-segment write path — segment roll,
21//! commit-time whole-record split, sealed-segment immutability — and
22//! multi-segment recovery (discovery, cross-segment continuity, crash-during-roll
23//! handling). Checkpoint/retention (M5) arrives later.
24
25// This is an embeddable library; every public item must be documented. With
26// CI's `clippy -D warnings`, an undocumented public item fails the build.
27#![warn(missing_docs)]
28
29mod config;
30mod crc;
31mod error;
32mod lsn;
33mod observer;
34mod reader;
35mod record;
36mod recovery;
37mod segment;
38mod wal;
39
40pub use config::WalConfig;
41pub use crc::crc32c;
42pub use error::{Result, WalError};
43pub use lsn::Lsn;
44pub use observer::{DurabilityObserver, NullObserver};
45pub use reader::Reader;
46pub use wal::{RecoveryReport, TailState, Wal};
47
48/// Internal hooks exposed **only** under the `fuzzing` feature for the M9
49/// cargo-fuzz targets (§14.5). Not part of the public API or the §6 surface:
50/// `#[doc(hidden)]` and feature-gated, so a normal build neither sees nor pays
51/// for any of it.
52///
53/// **The public path stays the source of truth.** The F1 recovery fuzzer's
54/// *primary* surface is the real public [`Wal::open`] driven over an adversarial
55/// directory of segment files — that is the production entry point and the thing
56/// actually under test (filename parse → discovery → sort → header validate →
57/// cross-segment continuity → `recover_segment`). The helpers here are for the
58/// *secondary direct-probe mode only* (the bounded-scan counter and the isolated
59/// single-record decoder), plus input *generators* the fuzzer uses to craft
60/// valid bytes to feed that public path. They must never become the thing being
61/// tested in place of production.
62#[cfg(feature = "fuzzing")]
63#[doc(hidden)]
64pub mod fuzzing {
65 use std::fs::File;
66
67 use crate::Lsn;
68
69 /// F2 / secondary: decode one framed record from arbitrary bytes, bounded by
70 /// `max_record_size`. Returns `Some((payload_len, framed_len))` for a decoded
71 /// record (so the harness can assert `payload_len <= max_record_size` and
72 /// `framed_len <= buf.len()` — bounds-soundness), `None` for any non-record
73 /// outcome. Never panics or reads OOB for any input (D11, record level).
74 #[must_use]
75 pub fn decode_record(buf: &[u8], max_record_size: u32) -> Option<(usize, usize)> {
76 match crate::record::decode(buf, max_record_size) {
77 crate::record::Decoded::Record {
78 payload,
79 framed_len,
80 ..
81 } => Some((payload.len(), framed_len)),
82 _ => None,
83 }
84 }
85
86 /// F1 secondary direct-probe mode: run intra-segment recovery (§8.2) over a
87 /// single open segment `file`, then assert the bounded forward-scan probe
88 /// stayed within [`scan_bound`]. Returns whether recovery succeeded; both
89 /// `Ok` and a clean `Err` are acceptable (D11), only a panic / OOB / unbounded
90 /// scan is a bug. The caller is responsible for the header-validated `base_lsn`
91 /// contract that the real `open` enforces upstream (here we pass arbitrary
92 /// bases deliberately, but `base_lsn == 0` would underflow `base_lsn - 1`, so
93 /// it is clamped to 1 — the lowest legal base).
94 #[must_use]
95 pub fn recover_segment_probe(
96 file: &File,
97 base_lsn: u64,
98 is_active: bool,
99 segment_size: u64,
100 max_record_size: u32,
101 ) -> bool {
102 scan_probe_reset();
103 let base = Lsn(base_lsn.max(1));
104 let r =
105 crate::recovery::recover_segment(file, base, is_active, segment_size, max_record_size);
106 let peak = scan_probe_peak();
107 assert!(
108 peak <= scan_bound(max_record_size),
109 "bounded-scan probe peak {peak} exceeded scan_bound {} (max_record_size {max_record_size})",
110 scan_bound(max_record_size),
111 );
112 r.is_ok()
113 }
114
115 /// The production bounded forward-scan distance limit, exposed so a fuzz
116 /// harness asserts against the **same symbol** the scan loop uses (never a
117 /// re-typed number that could silently drift from production).
118 #[must_use]
119 pub fn scan_bound(max_record_size: u32) -> u64 {
120 crate::recovery::scan_bound(max_record_size)
121 }
122
123 /// Reset the bounded-scan peak before a recovery run the harness will
124 /// bound-check (e.g. around the real [`Wal::open`](crate::Wal::open)).
125 pub fn scan_probe_reset() {
126 crate::recovery::scan_probe::reset();
127 }
128
129 /// The largest forward-scan distance observed since [`scan_probe_reset`].
130 #[must_use]
131 pub fn scan_probe_peak() -> u64 {
132 crate::recovery::scan_probe::peak()
133 }
134
135 /// Generator helper: the 64-byte segment header bytes for `base_lsn` (the
136 /// `created_unix_nanos` field is informational and fixed to 0 — it never
137 /// influences recovery, §8.6). Lets the fuzzer craft *valid* segment files so
138 /// the public `Wal::open` reaches discovery / continuity / `recover_segment`
139 /// with fuzzer-chosen bases, rather than always tripping on a bad header.
140 #[must_use]
141 pub fn segment_header_bytes(base_lsn: u64) -> Vec<u8> {
142 crate::segment::encode_header(Lsn(base_lsn), 0).to_vec()
143 }
144
145 /// Generator helper: append one valid framed record for `lsn`/`payload` to
146 /// `buf`, returning the framed byte count. Used to build dense, valid segment
147 /// bodies for the public-path fuzzer.
148 pub fn encode_record_into(buf: &mut Vec<u8>, lsn: u64, payload: &[u8]) -> usize {
149 crate::record::encode_into(buf, Lsn(lsn), payload)
150 }
151
152 /// The production per-segment recovery **classification** (§8.2), flattened to
153 /// a plain value for the §14.9 differential tester (`tests/differential.rs`).
154 /// This is the exact classification surface an independent reference parser
155 /// must reproduce byte-for-byte; a divergence is a recovery-classifier bug.
156 ///
157 /// `Truncated`/`Clean` carry `max_lsn` and the truncation `offset`; the two
158 /// fatal arms carry the failing `offset`. `OtherErr` catches any error the
159 /// single-segment `recover_segment` is not expected to produce here (e.g. an
160 /// I/O error) so the differential can flag it rather than silently coerce it.
161 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
162 pub enum SegClass {
163 /// Clean end of records; `max_lsn` is the highest valid LSN (`base-1` if
164 /// the segment is empty).
165 Clean { max_lsn: u64 },
166 /// Active-segment torn tail truncated at `offset`; `max_lsn` is the last
167 /// valid record before it.
168 Truncated { offset: u64, max_lsn: u64 },
169 /// Mid-log corruption: an invalid record with a valid record still ahead
170 /// within the bounded forward scan (active segment) — fatal (D5).
171 TornMidLog { offset: u64 },
172 /// Any invalid record in a sealed segment — fatal, no forward scan (D5).
173 Corruption { offset: u64 },
174 /// An error outside the classification surface (e.g. I/O). The differential
175 /// treats this as its own class so it is never silently equated.
176 OtherErr,
177 }
178
179 /// Run the **real** production `recover_segment` (§8.2) over one open segment
180 /// `file` and return its classification. Used only by the §14.9 differential
181 /// tester to compare production against an independent reference parser.
182 ///
183 /// NOTE: on a torn tail this performs the production durable zeroing of
184 /// `[offset, EOF)` (§8.2.1) as a side effect — so the differential must pass
185 /// production its **own** copy of the segment file and read the classification
186 /// from this return value, never re-derive it from the mutated file.
187 #[must_use]
188 pub fn recover_segment_classify(
189 file: &File,
190 base_lsn: u64,
191 is_active: bool,
192 segment_size: u64,
193 max_record_size: u32,
194 ) -> SegClass {
195 use crate::error::WalError;
196 use crate::wal::TailState;
197 let base = Lsn(base_lsn.max(1));
198 match crate::recovery::recover_segment(file, base, is_active, segment_size, max_record_size)
199 {
200 Ok(rec) => match rec.tail_state {
201 TailState::Clean => SegClass::Clean {
202 max_lsn: rec.max_lsn.0,
203 },
204 TailState::TruncatedAt { offset, .. } => SegClass::Truncated {
205 offset,
206 max_lsn: rec.max_lsn.0,
207 },
208 },
209 Err(WalError::TornMidLog { offset, .. }) => SegClass::TornMidLog { offset },
210 Err(WalError::Corruption { offset, .. }) => SegClass::Corruption { offset },
211 Err(_) => SegClass::OtherErr,
212 }
213 }
214}