Skip to main content

alopex_core/log/
wal.rs

1//! A Write-Ahead Log implementation.
2
3use crate::error::{Error, Result};
4use crate::types::{Key, TxnId, Value};
5use serde::{Deserialize, Serialize};
6use std::fs::{File, OpenOptions};
7use std::io::{BufReader, BufWriter, Read, Write};
8use std::path::Path;
9use tracing::warn;
10
11/// A record in the Write-Ahead Log.
12#[derive(Serialize, Deserialize, Debug, PartialEq)]
13pub enum WalRecord {
14    /// Signals the start of a transaction's writes. Not strictly required but good for clarity.
15    Begin(TxnId),
16    /// A key-value pair to be written.
17    Put(TxnId, Key, Value),
18    /// A key to be deleted.
19    Delete(TxnId, Key),
20    /// Commits a transaction.
21    Commit(TxnId),
22}
23
24/// A writer for the Write-Ahead Log.
25pub struct WalWriter {
26    writer: BufWriter<File>,
27}
28
29impl WalWriter {
30    /// Creates a new WAL writer for the given file path.
31    ///
32    /// Before opening for append, the existing log is repaired by truncating it to
33    /// the end of its last fully-valid record. A crash can leave a torn record at
34    /// the tail (a partial or checksum-failing frame). Without repair, the next
35    /// append would land *after* that torn record, leaving it permanently in the
36    /// middle of the log; replay stops at the first torn record, so every record
37    /// appended afterwards would be silently unrecoverable. Repairing on open
38    /// guarantees appends always extend a fully-replayable log.
39    pub fn new(path: &Path) -> Result<Self> {
40        Self::repair_torn_tail(path)?;
41        let file = OpenOptions::new().append(true).create(true).open(path)?;
42        Ok(Self {
43            writer: BufWriter::new(file),
44        })
45    }
46
47    /// Truncates an existing WAL to the end of its last fully-valid record.
48    ///
49    /// No-op if the file does not exist or is already clean (the valid prefix spans
50    /// the whole file).
51    fn repair_torn_tail(path: &Path) -> Result<()> {
52        let file_len = match std::fs::metadata(path) {
53            Ok(meta) => meta.len(),
54            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
55            Err(e) => return Err(Error::Io(e)),
56        };
57        if file_len == 0 {
58            return Ok(());
59        }
60
61        let mut reader = WalReader::new(path)?;
62        // Drive the iterator to the end; `valid_prefix_len` then holds the offset of
63        // the last fully-parsed record. Any reader error is surfaced (a genuine I/O
64        // fault must not be silently swallowed during repair).
65        for record in reader.by_ref() {
66            record?;
67        }
68        let valid_len = reader.valid_prefix_len();
69        let stop_reason = reader.last_stop_reason();
70        drop(reader);
71
72        if valid_len < file_len {
73            warn!(
74                path = %path.display(),
75                stop_reason = ?stop_reason,
76                valid_len,
77                file_len,
78                "WAL recovery stopped at torn tail"
79            );
80            let file = OpenOptions::new().write(true).open(path)?;
81            file.set_len(valid_len)?;
82            file.sync_all()?;
83        }
84        Ok(())
85    }
86
87    /// Appends a record to the log buffer.
88    ///
89    /// The record is serialized and framed with:
90    /// - Length (u32)
91    /// - Checksum (u32)
92    /// - Data ([u8])
93    ///
94    /// This only writes into the in-process buffer; it does **not** fsync. The
95    /// caller must invoke [`WalWriter::sync`] at the transaction commit boundary
96    /// to make the appended records durable. Decoupling append from fsync turns
97    /// an N-record transaction's N fsyncs into a single fsync at commit, which
98    /// is the standard WAL group-commit-at-the-record-boundary pattern (cf.
99    /// `lsm::wal::SyncMode`/`force_sync`, and fjall/mini-lsm reference impls).
100    /// Durability semantics are unchanged: a commit is only acknowledged after
101    /// [`WalWriter::sync`] returns, so every record of a committed transaction
102    /// is on stable storage (v0.5 Durability requirement CORE-5.1).
103    pub fn append(&mut self, record: &WalRecord) -> Result<()> {
104        let data = bincode::serialize(record).map_err(|e| Error::Io(std::io::Error::other(e)))?;
105        let checksum = crc32fast::hash(&data);
106
107        self.writer.write_all(&(data.len() as u32).to_le_bytes())?;
108        self.writer.write_all(&checksum.to_le_bytes())?;
109        self.writer.write_all(&data)?;
110
111        Ok(())
112    }
113
114    /// Flushes the buffer and fsyncs the underlying file, making all previously
115    /// appended records durable.
116    ///
117    /// This is the durability point for a transaction commit: once it returns
118    /// `Ok`, every record appended since the last `sync` is guaranteed to be on
119    /// stable storage. A `sync` failure is propagated so the caller can refuse
120    /// to acknowledge the commit (CORE-5.1: a WAL fsync failure must not be
121    /// reported as a successful commit).
122    pub fn sync(&mut self) -> Result<()> {
123        self.writer.flush()?;
124        self.writer.get_ref().sync_all()?;
125        Ok(())
126    }
127}
128
129/// A reader for the Write-Ahead Log.
130pub struct WalReader {
131    reader: BufReader<File>,
132    /// Total file length, used to detect a torn tail whose framed length points
133    /// past the end of the file.
134    file_len: u64,
135    /// Bytes consumed so far, tracked to bound record-body allocation.
136    consumed: u64,
137    /// End offset of the last record that passed checksum verification and
138    /// deserialized successfully.
139    valid_prefix: u64,
140    /// Reason iteration stopped before the physical end of the file, if a
141    /// torn/corrupt tail was detected.
142    last_stop_reason: Option<&'static str>,
143}
144
145impl WalReader {
146    /// Creates a new WAL reader for the given file path.
147    pub fn new(path: &Path) -> Result<Self> {
148        let file = OpenOptions::new().read(true).open(path)?;
149        let file_len = file.metadata()?.len();
150        Ok(Self {
151            reader: BufReader::new(file),
152            file_len,
153            consumed: 0,
154            valid_prefix: 0,
155            last_stop_reason: None,
156        })
157    }
158
159    /// Byte offset of the end of the last fully-parsed record, i.e. the length of
160    /// the valid, replayable prefix of the log.
161    ///
162    /// After iteration stops (either at a clean boundary or a torn tail), this is
163    /// the offset to which the WAL should be truncated so that subsequent appends
164    /// extend a fully-replayable log rather than being shadowed by a torn record
165    /// left in the middle of the file.
166    pub fn valid_prefix_len(&self) -> u64 {
167        self.valid_prefix
168    }
169
170    /// Reason the reader stopped before the physical end of the file.
171    fn last_stop_reason(&self) -> Option<&'static str> {
172        self.last_stop_reason
173    }
174}
175
176impl Iterator for WalReader {
177    type Item = Result<WalRecord>;
178
179    fn next(&mut self) -> Option<Self::Item> {
180        let record_start = self.consumed;
181        if self.valid_prefix != record_start {
182            return None;
183        }
184
185        let mut header = [0u8; 8]; // 4 bytes for length, 4 for checksum
186        let mut read = 0;
187
188        while read < header.len() {
189            match self.reader.read(&mut header[read..]) {
190                Ok(0) if read == 0 => {
191                    // Clean EOF on a record boundary.
192                    return None;
193                }
194                Ok(0) => {
195                    // Crash-tolerant behavior: treat a partially-written header as EOF.
196                    self.last_stop_reason = Some("torn WAL record header");
197                    return None;
198                }
199                Ok(n) => {
200                    read += n;
201                }
202                Err(e) => return Some(Err(e.into())),
203            }
204        }
205        self.consumed += header.len() as u64;
206
207        let len = u32::from_le_bytes(header[0..4].try_into().unwrap());
208        let expected_checksum = u32::from_le_bytes(header[4..8].try_into().unwrap());
209
210        // Crash-tolerant behavior: if the framed length points past the end of the
211        // file, the header itself is part of a torn tail (its bytes straddle the
212        // truncation point). Treat it as end-of-log instead of allocating a buffer
213        // sized by an untrusted length, which guards against huge allocations on a
214        // corrupt/garbage header.
215        if len as u64 > self.file_len.saturating_sub(self.consumed) {
216            self.last_stop_reason = Some("WAL record length exceeds remaining file bytes");
217            return None;
218        }
219
220        let mut data = vec![0u8; len as usize];
221        if let Err(e) = self.reader.read_exact(&mut data) {
222            // Crash-tolerant behavior: treat a partially-written record body as EOF.
223            //
224            // This allows recovery from a torn/truncated WAL tail by replaying only the
225            // successfully-written prefix.
226            if e.kind() == std::io::ErrorKind::UnexpectedEof {
227                self.last_stop_reason = Some("torn WAL record body");
228                return None;
229            }
230            return Some(Err(Error::Io(e)));
231        }
232        self.consumed += len as u64;
233
234        let actual_checksum = crc32fast::hash(&data);
235        if actual_checksum != expected_checksum {
236            // Crash-tolerant behavior: a checksum mismatch marks the end of the
237            // durable prefix. Records are appended atomically per-record and read
238            // in order, so any prior record verified successfully; a mismatch can
239            // therefore only arise on a torn tail where the truncation point left a
240            // full-length header followed by a partially-written or garbage body.
241            // Stop replay and recover the valid prefix, mirroring the torn-header
242            // and torn-body handling above rather than failing the whole recovery.
243            self.last_stop_reason = Some("WAL record checksum mismatch");
244            return None;
245        }
246
247        match bincode::deserialize(&data) {
248            Ok(record) => {
249                self.valid_prefix = self.consumed;
250                Some(Ok(record))
251            }
252            Err(_) => {
253                self.last_stop_reason = Some("WAL record deserialization failed");
254                None
255            }
256        }
257    }
258}
259
260#[cfg(all(test, not(target_arch = "wasm32")))]
261mod tests {
262    use super::*;
263    use std::io::Write;
264    use tempfile::tempdir;
265
266    struct VecWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
267
268    impl std::io::Write for VecWriter {
269        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
270            let mut guard = self.0.lock().unwrap();
271            guard.extend_from_slice(buf);
272            Ok(buf.len())
273        }
274
275        fn flush(&mut self) -> std::io::Result<()> {
276            Ok(())
277        }
278    }
279
280    #[test]
281    fn wal_reader_ignores_truncated_tail() {
282        let dir = tempdir().unwrap();
283        let path = dir.path().join("wal.log");
284
285        {
286            let mut writer = WalWriter::new(&path).unwrap();
287            writer.append(&WalRecord::Begin(TxnId(1))).unwrap();
288            writer.append(&WalRecord::Commit(TxnId(1))).unwrap();
289        }
290
291        {
292            // Simulate a crash after writing only part of the next header.
293            let mut file = std::fs::OpenOptions::new()
294                .append(true)
295                .open(&path)
296                .unwrap();
297            file.write_all(&[0u8; 4]).unwrap();
298        }
299
300        let mut reader = WalReader::new(&path).unwrap();
301        assert!(reader.next().unwrap().is_ok());
302        assert!(reader.next().unwrap().is_ok());
303        assert!(reader.next().is_none());
304    }
305
306    #[test]
307    fn wal_reader_stops_at_checksum_mismatch_tail() {
308        // A crash that truncates the file at an arbitrary byte (as a torn tail) can
309        // leave a full-length header followed by a partial/garbage body whose length
310        // still fits in the file. The body then fails the checksum. Recovery must
311        // replay the valid prefix and stop, not abort the whole recovery.
312        let dir = tempdir().unwrap();
313        let path = dir.path().join("wal.log");
314
315        {
316            let mut writer = WalWriter::new(&path).unwrap();
317            writer.append(&WalRecord::Begin(TxnId(1))).unwrap();
318            writer.append(&WalRecord::Commit(TxnId(1))).unwrap();
319            // A third record whose body we will corrupt in place.
320            writer.append(&WalRecord::Begin(TxnId(2))).unwrap();
321        }
322
323        {
324            // Flip the last byte of the file: the framed length still fits, so
325            // `read_exact` succeeds, but the checksum no longer matches.
326            let len = std::fs::metadata(&path).unwrap().len();
327            let mut file = std::fs::OpenOptions::new()
328                .read(true)
329                .write(true)
330                .open(&path)
331                .unwrap();
332            use std::io::{Seek, SeekFrom};
333            file.seek(SeekFrom::Start(len - 1)).unwrap();
334            let mut last = [0u8; 1];
335            use std::io::Read as _;
336            file.read_exact(&mut last).unwrap();
337            file.seek(SeekFrom::Start(len - 1)).unwrap();
338            file.write_all(&[last[0] ^ 0xFF]).unwrap();
339        }
340
341        let mut reader = WalReader::new(&path).unwrap();
342        assert!(reader.next().unwrap().is_ok());
343        assert!(reader.next().unwrap().is_ok());
344        // The corrupted third record is treated as a torn tail: clean end-of-log.
345        assert!(reader.next().is_none());
346    }
347
348    #[test]
349    fn wal_reader_stops_on_garbage_length_header() {
350        // A torn tail whose header bytes straddle the truncation point can decode to
351        // an enormous framed length. The reader must treat it as end-of-log rather
352        // than allocating a buffer sized by the untrusted length.
353        let dir = tempdir().unwrap();
354        let path = dir.path().join("wal.log");
355
356        {
357            let mut writer = WalWriter::new(&path).unwrap();
358            writer.append(&WalRecord::Begin(TxnId(1))).unwrap();
359        }
360
361        {
362            // Append a header claiming a 4 GiB body with no body following.
363            let mut file = std::fs::OpenOptions::new()
364                .append(true)
365                .open(&path)
366                .unwrap();
367            file.write_all(&u32::MAX.to_le_bytes()).unwrap(); // length
368            file.write_all(&0u32.to_le_bytes()).unwrap(); // checksum
369        }
370
371        let mut reader = WalReader::new(&path).unwrap();
372        assert!(reader.next().unwrap().is_ok());
373        assert!(reader.next().is_none());
374    }
375
376    #[test]
377    fn wal_writer_logs_torn_tail_repair_warning() {
378        let dir = tempdir().unwrap();
379        let path = dir.path().join("wal.log");
380
381        {
382            let mut writer = WalWriter::new(&path).unwrap();
383            writer.append(&WalRecord::Begin(TxnId(1))).unwrap();
384            writer.sync().unwrap();
385        }
386
387        {
388            let mut file = std::fs::OpenOptions::new()
389                .append(true)
390                .open(&path)
391                .unwrap();
392            file.write_all(&[0u8; 4]).unwrap();
393        }
394
395        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
396        let make_writer = {
397            let buf = buffer.clone();
398            move || VecWriter(buf.clone())
399        };
400        let subscriber = tracing_subscriber::fmt()
401            .with_max_level(tracing::Level::WARN)
402            .with_writer(make_writer)
403            .without_time()
404            .finish();
405        let _guard = tracing::subscriber::set_default(subscriber);
406
407        let _writer = WalWriter::new(&path).unwrap();
408
409        let log = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
410        assert!(
411            log.contains("WAL recovery stopped at torn tail"),
412            "expected WAL repair warning, got: {}",
413            log
414        );
415        assert!(
416            log.contains("torn WAL record header"),
417            "expected stop reason in warning, got: {}",
418            log
419        );
420    }
421}