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