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}