Skip to main content

mentedb_storage/
wal.rs

1//! Write-Ahead Log: append-only log for crash recovery.
2//!
3//! WAL entry format on disk:
4//! ```text
5//! [length: u32][lsn: u64][type: u8][page_id: u64][compressed_data: ...][crc32: u32]
6//! ```
7//!
8//! - `length`: byte count of the payload (lsn + type + page_id + compressed_data).
9//! - `compressed_data`: the data portion compressed with LZ4.
10//! - `crc32`: checksum over the entire payload.
11
12use std::fs::{File, OpenOptions};
13use std::io::{Read, Seek, SeekFrom, Write};
14use std::path::Path;
15
16use mentedb_core::error::{MenteError, MenteResult};
17use tracing::{debug, info, trace};
18
19/// Log Sequence Number.
20pub type Lsn = u64;
21
22/// WAL entry type discriminant.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[repr(u8)]
25pub enum WalEntryType {
26    PageWrite = 1,
27    /// Reserved for future transaction support. Not currently emitted.
28    Commit = 2,
29    Checkpoint = 3,
30    /// The page was returned to the free list (memory deleted).
31    PageFree = 4,
32}
33
34impl TryFrom<u8> for WalEntryType {
35    type Error = MenteError;
36    fn try_from(v: u8) -> MenteResult<Self> {
37        match v {
38            1 => Ok(Self::PageWrite),
39            2 => Ok(Self::Commit),
40            3 => Ok(Self::Checkpoint),
41            4 => Ok(Self::PageFree),
42            _ => Err(MenteError::Storage(format!("invalid WAL entry type: {v}"))),
43        }
44    }
45}
46
47/// A single WAL entry (in-memory representation).
48#[derive(Debug, Clone)]
49pub struct WalEntry {
50    /// Log sequence number.
51    pub lsn: u64,
52    /// The type of WAL operation.
53    pub entry_type: WalEntryType,
54    /// The page affected by this entry.
55    pub page_id: u64,
56    /// Serialized payload.
57    pub data: Vec<u8>,
58    /// CRC32 checksum for integrity verification.
59    pub checksum: u32,
60}
61
62/// Append-only write-ahead log.
63pub struct Wal {
64    file: File,
65    dir_path: std::path::PathBuf,
66    next_lsn: u64,
67}
68
69/// Minimum payload size: lsn(8) + type(1) + page_id(8).
70const MIN_PAYLOAD: usize = 17;
71
72impl Wal {
73    /// Open or create a WAL file at `dir_path/wal.log`.
74    pub fn open(dir_path: &Path) -> MenteResult<Self> {
75        let wal_path = dir_path.join("wal.log");
76        let exists = wal_path.exists()
77            && std::fs::metadata(&wal_path)
78                .map(|m| m.len() > 0)
79                .unwrap_or(false);
80
81        let file = OpenOptions::new()
82            .read(true)
83            .write(true)
84            .create(true)
85            .truncate(false)
86            .open(&wal_path)?;
87
88        let mut wal = Self {
89            file,
90            dir_path: dir_path.to_path_buf(),
91            next_lsn: 1,
92        };
93
94        if exists {
95            let entries = wal.read_all_entries()?;
96            if let Some(last) = entries.last() {
97                wal.next_lsn = last.lsn + 1;
98            }
99            info!(
100                next_lsn = wal.next_lsn,
101                entries = entries.len(),
102                "opened existing WAL"
103            );
104        } else {
105            info!("created new WAL");
106        }
107
108        Ok(wal)
109    }
110
111    /// Acquire a blocking exclusive file lock on the WAL file.
112    ///
113    /// Uses `flock(2)` (via fs2) which works across processes on the same host.
114    /// Blocks until the lock is available — callers should hold it only for
115    /// the duration of append + fsync.
116    pub fn lock_exclusive(&self) -> MenteResult<()> {
117        use fs2::FileExt;
118        self.file
119            .lock_exclusive()
120            .map_err(|e| MenteError::Storage(format!("WAL flock failed: {e}")))
121    }
122
123    /// Release the file lock on the WAL file.
124    pub fn unlock(&self) -> MenteResult<()> {
125        fs2::FileExt::unlock(&self.file)
126            .map_err(|e| MenteError::Storage(format!("WAL unlock failed: {e}")))
127    }
128
129    /// Re-read the WAL file to find the highest LSN, updating next_lsn.
130    /// Must be called under flock to see writes from other processes.
131    ///
132    /// Fast path: reads each entry's raw payload and CRC-validates it, but
133    /// skips the expensive LZ4 decompression. Only extracts the LSN (first
134    /// 8 bytes of each payload).
135    pub fn reload_lsn(&mut self) -> MenteResult<()> {
136        self.file.seek(SeekFrom::Start(0))?;
137        let file_len = self.file.metadata()?.len();
138        let mut offset: u64 = 0;
139        let mut last_lsn: Option<u64> = None;
140
141        while offset + 4 <= file_len {
142            // Read payload length
143            let mut len_buf = [0u8; 4];
144            if self.file.read_exact(&mut len_buf).is_err() {
145                break;
146            }
147            let payload_len = u32::from_le_bytes(len_buf) as usize;
148            offset += 4;
149
150            if payload_len < MIN_PAYLOAD || offset + payload_len as u64 + 4 > file_len {
151                break;
152            }
153
154            // Read full payload (no decompress) for CRC validation
155            let mut payload = vec![0u8; payload_len];
156            if self.file.read_exact(&mut payload).is_err() {
157                break;
158            }
159            offset += payload_len as u64;
160
161            // Read and verify CRC
162            let mut crc_buf = [0u8; 4];
163            if self.file.read_exact(&mut crc_buf).is_err() {
164                break;
165            }
166            let stored_crc = u32::from_le_bytes(crc_buf);
167            offset += 4;
168
169            let computed_crc = {
170                let mut h = crc32fast::Hasher::new();
171                h.update(&payload);
172                h.finalize()
173            };
174            if computed_crc != stored_crc {
175                break; // Corruption — stop here, same as read_all_entries.
176            }
177
178            // Extract LSN from first 8 bytes of payload
179            let lsn = u64::from_le_bytes(payload[0..8].try_into().unwrap());
180            last_lsn = Some(lsn);
181        }
182
183        self.next_lsn = last_lsn.map_or(1, |l| l + 1);
184        debug!(next_lsn = self.next_lsn, "reloaded WAL LSN (fast scan)");
185        Ok(())
186    }
187
188    /// Append an entry to the WAL and return its LSN.
189    pub fn append(
190        &mut self,
191        entry_type: WalEntryType,
192        page_id: u64,
193        data: &[u8],
194    ) -> MenteResult<Lsn> {
195        let lsn = self.next_lsn;
196        self.next_lsn += 1;
197
198        let compressed = lz4_flex::compress_prepend_size(data);
199
200        // Build the payload: lsn + type + page_id + compressed_data
201        let payload_len = 8 + 1 + 8 + compressed.len();
202        let mut payload = Vec::with_capacity(payload_len);
203        payload.extend_from_slice(&lsn.to_le_bytes());
204        payload.push(entry_type as u8);
205        payload.extend_from_slice(&page_id.to_le_bytes());
206        payload.extend_from_slice(&compressed);
207
208        let crc = {
209            let mut h = crc32fast::Hasher::new();
210            h.update(&payload);
211            h.finalize()
212        };
213
214        self.file.seek(SeekFrom::End(0))?;
215        self.file.write_all(&(payload_len as u32).to_le_bytes())?;
216        self.file.write_all(&payload)?;
217        self.file.write_all(&crc.to_le_bytes())?;
218
219        trace!(lsn, page_id, "appended WAL entry");
220        Ok(lsn)
221    }
222
223    /// Flush the WAL to durable storage (fdatasync).
224    pub fn sync(&mut self) -> MenteResult<()> {
225        self.file.sync_data()?;
226        debug!("WAL synced");
227        Ok(())
228    }
229
230    /// Read all valid entries from the WAL for recovery.
231    pub fn iterate(&mut self) -> MenteResult<Vec<WalEntry>> {
232        self.read_all_entries()
233    }
234
235    /// Truncate all entries with LSN **less than** `before_lsn`.
236    ///
237    /// Uses atomic write-to-temp-then-rename to avoid data loss on crash.
238    pub fn truncate(&mut self, before_lsn: Lsn) -> MenteResult<()> {
239        let entries = self.read_all_entries()?;
240        let to_keep: Vec<&WalEntry> = entries.iter().filter(|e| e.lsn >= before_lsn).collect();
241
242        let wal_path = self.dir_path.join("wal.log");
243        let tmp_path = self.dir_path.join("wal.log.tmp");
244
245        {
246            let mut tmp_file = OpenOptions::new()
247                .write(true)
248                .create(true)
249                .truncate(true)
250                .open(&tmp_path)?;
251
252            for entry in to_keep {
253                let compressed = lz4_flex::compress_prepend_size(&entry.data);
254
255                let payload_len = 8 + 1 + 8 + compressed.len();
256                let mut payload = Vec::with_capacity(payload_len);
257                payload.extend_from_slice(&entry.lsn.to_le_bytes());
258                payload.push(entry.entry_type as u8);
259                payload.extend_from_slice(&entry.page_id.to_le_bytes());
260                payload.extend_from_slice(&compressed);
261
262                let crc = {
263                    let mut h = crc32fast::Hasher::new();
264                    h.update(&payload);
265                    h.finalize()
266                };
267
268                tmp_file.write_all(&(payload_len as u32).to_le_bytes())?;
269                tmp_file.write_all(&payload)?;
270                tmp_file.write_all(&crc.to_le_bytes())?;
271            }
272
273            tmp_file.sync_data()?;
274        }
275
276        std::fs::rename(&tmp_path, &wal_path)?;
277
278        // Reopen the renamed file and re-acquire flock so callers' subsequent
279        // unlock() releases the correct fd.
280        let new_file = OpenOptions::new().read(true).write(true).open(&wal_path)?;
281        fs2::FileExt::lock_exclusive(&new_file)
282            .map_err(|e| MenteError::Storage(format!("WAL flock re-acquire failed: {e}")))?;
283        self.file = new_file;
284
285        debug!(before_lsn, "WAL truncated (atomic)");
286        Ok(())
287    }
288
289    /// Current next LSN (useful for external callers).
290    pub fn next_lsn(&self) -> Lsn {
291        self.next_lsn
292    }
293
294    /// Returns the current WAL file size in bytes.
295    pub fn file_size(&self) -> u64 {
296        self.file.metadata().map(|m| m.len()).unwrap_or(0)
297    }
298
299    // ---- internal helpers ----
300
301    fn read_all_entries(&mut self) -> MenteResult<Vec<WalEntry>> {
302        self.file.seek(SeekFrom::Start(0))?;
303        let file_len = self.file.metadata()?.len();
304        let mut offset: u64 = 0;
305        let mut entries = Vec::new();
306
307        while offset + 4 <= file_len {
308            // Read length
309            let mut len_buf = [0u8; 4];
310            if self.file.read_exact(&mut len_buf).is_err() {
311                break;
312            }
313            let payload_len = u32::from_le_bytes(len_buf) as usize;
314            offset += 4;
315
316            if payload_len < MIN_PAYLOAD || offset + payload_len as u64 + 4 > file_len {
317                break;
318            }
319
320            // Read payload
321            let mut payload = vec![0u8; payload_len];
322            if self.file.read_exact(&mut payload).is_err() {
323                break;
324            }
325            offset += payload_len as u64;
326
327            // Read CRC
328            let mut crc_buf = [0u8; 4];
329            if self.file.read_exact(&mut crc_buf).is_err() {
330                break;
331            }
332            let stored_crc = u32::from_le_bytes(crc_buf);
333            offset += 4;
334
335            // Verify CRC
336            let computed_crc = {
337                let mut h = crc32fast::Hasher::new();
338                h.update(&payload);
339                h.finalize()
340            };
341            if computed_crc != stored_crc {
342                break; // Corruption — stop here.
343            }
344
345            // Parse
346            let lsn = u64::from_le_bytes(payload[0..8].try_into().unwrap());
347            let entry_type = match WalEntryType::try_from(payload[8]) {
348                Ok(t) => t,
349                Err(_) => break,
350            };
351            let page_id = u64::from_le_bytes(payload[9..17].try_into().unwrap());
352            let compressed_data = &payload[17..];
353
354            let data = lz4_flex::decompress_size_prepended(compressed_data)
355                .map_err(|e| MenteError::Storage(format!("LZ4 decompress failed: {e}")))?;
356
357            entries.push(WalEntry {
358                lsn,
359                entry_type,
360                page_id,
361                data,
362                checksum: stored_crc,
363            });
364        }
365
366        Ok(entries)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn setup() -> (tempfile::TempDir, Wal) {
375        let dir = tempfile::tempdir().unwrap();
376        let wal = Wal::open(dir.path()).unwrap();
377        (dir, wal)
378    }
379
380    #[test]
381    fn test_append_and_iterate() {
382        let (_dir, mut wal) = setup();
383
384        let lsn1 = wal.append(WalEntryType::PageWrite, 1, b"hello").unwrap();
385        let lsn2 = wal.append(WalEntryType::PageWrite, 2, b"world").unwrap();
386        assert_eq!(lsn1, 1);
387        assert_eq!(lsn2, 2);
388
389        let entries = wal.iterate().unwrap();
390        assert_eq!(entries.len(), 2);
391        assert_eq!(entries[0].lsn, 1);
392        assert_eq!(entries[0].data, b"hello");
393        assert_eq!(entries[1].lsn, 2);
394        assert_eq!(entries[1].data, b"world");
395    }
396
397    #[test]
398    fn test_sync() {
399        let (_dir, mut wal) = setup();
400        wal.append(WalEntryType::Commit, 0, b"").unwrap();
401        wal.sync().unwrap(); // should not panic
402    }
403
404    #[test]
405    fn test_truncate() {
406        let (_dir, mut wal) = setup();
407
408        wal.append(WalEntryType::PageWrite, 1, b"a").unwrap();
409        wal.append(WalEntryType::PageWrite, 2, b"b").unwrap();
410        wal.append(WalEntryType::Checkpoint, 0, b"").unwrap();
411
412        // Truncate everything before LSN 3.
413        wal.truncate(3).unwrap();
414
415        let entries = wal.iterate().unwrap();
416        assert_eq!(entries.len(), 1);
417        assert_eq!(entries[0].lsn, 3);
418    }
419
420    #[test]
421    fn test_recovery_reopen() {
422        let dir = tempfile::tempdir().unwrap();
423        {
424            let mut wal = Wal::open(dir.path()).unwrap();
425            wal.append(WalEntryType::PageWrite, 10, b"recovery-data")
426                .unwrap();
427            wal.sync().unwrap();
428        }
429        {
430            let mut wal = Wal::open(dir.path()).unwrap();
431            assert_eq!(wal.next_lsn(), 2);
432            let entries = wal.iterate().unwrap();
433            assert_eq!(entries.len(), 1);
434            assert_eq!(entries[0].page_id, 10);
435            assert_eq!(entries[0].data, b"recovery-data");
436        }
437    }
438
439    #[test]
440    fn test_empty_data_entry() {
441        let (_dir, mut wal) = setup();
442        let lsn = wal.append(WalEntryType::Checkpoint, 0, b"").unwrap();
443        let entries = wal.iterate().unwrap();
444        assert_eq!(entries.len(), 1);
445        assert_eq!(entries[0].lsn, lsn);
446        assert!(entries[0].data.is_empty());
447    }
448
449    #[test]
450    fn test_large_data_compression() {
451        let (_dir, mut wal) = setup();
452        let big_data = vec![0xABu8; 8192];
453        wal.append(WalEntryType::PageWrite, 5, &big_data).unwrap();
454
455        let entries = wal.iterate().unwrap();
456        assert_eq!(entries.len(), 1);
457        assert_eq!(entries[0].data, big_data);
458    }
459
460    #[test]
461    fn test_append_then_sync_is_durable() {
462        // append() alone does not fsync — callers must call sync() for durability.
463        // This matches the group-commit pattern: batch appends, sync once.
464        let dir = tempfile::tempdir().unwrap();
465        {
466            let mut wal = Wal::open(dir.path()).unwrap();
467            wal.append(WalEntryType::PageWrite, 1, b"batch1").unwrap();
468            wal.append(WalEntryType::PageWrite, 2, b"batch2").unwrap();
469            wal.sync().unwrap();
470        }
471        {
472            let mut wal = Wal::open(dir.path()).unwrap();
473            let entries = wal.iterate().unwrap();
474            assert_eq!(entries.len(), 2);
475            assert_eq!(entries[0].data, b"batch1");
476            assert_eq!(entries[1].data, b"batch2");
477        }
478    }
479
480    #[test]
481    fn test_truncate_atomic_preserves_kept_entries() {
482        let dir = tempfile::tempdir().unwrap();
483        {
484            let mut wal = Wal::open(dir.path()).unwrap();
485            wal.append(WalEntryType::PageWrite, 1, b"old1").unwrap();
486            wal.append(WalEntryType::PageWrite, 2, b"old2").unwrap();
487            wal.append(WalEntryType::PageWrite, 3, b"keep1").unwrap();
488            wal.append(WalEntryType::PageWrite, 4, b"keep2").unwrap();
489
490            wal.truncate(3).unwrap();
491
492            let entries = wal.iterate().unwrap();
493            assert_eq!(entries.len(), 2);
494            assert_eq!(entries[0].data, b"keep1");
495            assert_eq!(entries[1].data, b"keep2");
496        }
497        // Verify survives reopen
498        {
499            let mut wal = Wal::open(dir.path()).unwrap();
500            let entries = wal.iterate().unwrap();
501            assert_eq!(entries.len(), 2);
502            assert_eq!(entries[0].lsn, 3);
503            assert_eq!(entries[1].lsn, 4);
504        }
505    }
506
507    #[test]
508    fn test_truncate_no_temp_file_left_behind() {
509        let dir = tempfile::tempdir().unwrap();
510        let mut wal = Wal::open(dir.path()).unwrap();
511        wal.append(WalEntryType::PageWrite, 1, b"a").unwrap();
512        wal.truncate(2).unwrap();
513
514        // Temp file should not exist after truncation
515        assert!(!dir.path().join("wal.log.tmp").exists());
516    }
517
518    #[test]
519    fn test_append_after_truncate_works() {
520        let (_dir, mut wal) = setup();
521        wal.append(WalEntryType::PageWrite, 1, b"before").unwrap();
522        wal.truncate(2).unwrap();
523
524        // Should be able to append after truncation (file handle is valid)
525        wal.append(WalEntryType::PageWrite, 10, b"after").unwrap();
526        let entries = wal.iterate().unwrap();
527        assert_eq!(entries.len(), 1);
528        assert_eq!(entries[0].data, b"after");
529    }
530}