Skip to main content

powdb_storage/
wal.rs

1use std::fs::{File, OpenOptions};
2use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Condvar, Mutex};
6use std::thread::JoinHandle;
7use std::time::Duration;
8use tracing::debug;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[repr(u8)]
12pub enum WalRecordType {
13    Insert = 1,
14    Update = 2,
15    Delete = 3,
16    Commit = 4,
17    Rollback = 5,
18    DdlCreateTable = 6,
19    DdlDropTable = 7,
20    DdlAddColumn = 8,
21    DdlDropColumn = 9,
22    Begin = 10,
23}
24
25impl WalRecordType {
26    fn from_u8(v: u8) -> Option<Self> {
27        match v {
28            1 => Some(WalRecordType::Insert),
29            2 => Some(WalRecordType::Update),
30            3 => Some(WalRecordType::Delete),
31            4 => Some(WalRecordType::Commit),
32            5 => Some(WalRecordType::Rollback),
33            6 => Some(WalRecordType::DdlCreateTable),
34            7 => Some(WalRecordType::DdlDropTable),
35            8 => Some(WalRecordType::DdlAddColumn),
36            9 => Some(WalRecordType::DdlDropColumn),
37            10 => Some(WalRecordType::Begin),
38            _ => None,
39        }
40    }
41}
42
43pub const WAL_MAGIC: &[u8; 4] = b"PWAL";
44pub const WAL_FORMAT_VERSION: u16 = 1;
45const WAL_FILE_HEADER_SIZE: u64 = 8;
46
47/// WAL record header: len(4) + crc32(4) + tx_id(8) + type(1) + lsn(8) = 25 bytes
48const WAL_HEADER_SIZE: usize = 25;
49
50fn write_wal_file_header(file: &mut File) -> io::Result<()> {
51    file.seek(SeekFrom::Start(0))?;
52    file.write_all(WAL_MAGIC)?;
53    file.write_all(&WAL_FORMAT_VERSION.to_le_bytes())?;
54    file.write_all(&0u16.to_le_bytes())?;
55    file.seek(SeekFrom::End(0))?;
56    Ok(())
57}
58
59fn wal_records_start(file: &mut File) -> io::Result<u64> {
60    let len = file.metadata()?.len();
61    if len == 0 {
62        write_wal_file_header(file)?;
63        return Ok(WAL_FILE_HEADER_SIZE);
64    }
65    if len >= WAL_FILE_HEADER_SIZE {
66        file.seek(SeekFrom::Start(0))?;
67        let mut hdr = [0u8; WAL_FILE_HEADER_SIZE as usize];
68        file.read_exact(&mut hdr)?;
69        if &hdr[0..4] == WAL_MAGIC {
70            let version = u16::from_le_bytes(hdr[4..6].try_into().expect("2-byte WAL version"));
71            if version != WAL_FORMAT_VERSION {
72                return Err(io::Error::new(
73                    io::ErrorKind::InvalidData,
74                    format!("unsupported WAL format version: {version}"),
75                ));
76            }
77            return Ok(WAL_FILE_HEADER_SIZE);
78        }
79    }
80    // Legacy 0.4.x WAL: no file header; records start at byte 0.
81    Ok(0)
82}
83
84/// Maximum allowed size for a single WAL record's data payload.
85/// Records claiming more than 256 MB are treated as corruption and
86/// stop replay — this prevents a crafted WAL from causing a
87/// multi-gigabyte allocation before the CRC check can reject it.
88const MAX_WAL_RECORD_SIZE: usize = 256 * 1024 * 1024;
89
90#[derive(Debug)]
91pub struct WalRecord {
92    pub tx_id: u64,
93    pub record_type: WalRecordType,
94    /// Monotonic log sequence number assigned at append time. Used by
95    /// the page-level idempotent replay: if a page's on-disk LSN is
96    /// `>=` this record's LSN, the record has already been applied and
97    /// replay skips it.
98    pub lsn: u64,
99    pub data: Vec<u8>,
100}
101
102/// Durability mode for the WAL — analogous to SQLite's `PRAGMA synchronous`
103/// combined with `journal_mode=OFF`.
104///
105/// * `Full` — every mutation appends a record and `flush()` calls
106///   `sync_data()` so the OS guarantees the bytes hit stable storage before
107///   the call returns. This is the default and the only safe choice when
108///   crash recovery must be perfect.
109///
110/// * `Off`  — every `append()` and `flush()` is a zero-work no-op. No CRC,
111///   no BufWriter, no fsync, no recovery. This matches SQLite's `:memory:`
112///   semantics and is the only way to compare apples-to-apples against
113///   in-memory engines in benches. Never use this in production — a crash
114///   loses every mutation since the last `Catalog::checkpoint()`.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum WalSyncMode {
117    #[default]
118    Full,
119    /// `Normal` — every commit buffers its record through to the OS
120    /// (`BufWriter::flush`, so the bytes are file-visible) and returns
121    /// WITHOUT an fsync; a background flusher fsyncs on a fixed interval
122    /// ([`NORMAL_FSYNC_INTERVAL`]). A *process* crash loses nothing (replay
123    /// reads the bytes already in the OS page cache); an *OS* crash / power
124    /// loss can lose only the unsynced tail (≤ one interval of writes). This
125    /// is SQLite `synchronous=NORMAL` / Postgres `synchronous_commit=off`
126    /// semantics: opt-in, bounded-loss, and ~15–40× faster single-row writes
127    /// because the fsync leaves the commit/lock path.
128    Normal,
129    Off,
130}
131
132/// How often the background flusher fsyncs in [`WalSyncMode::Normal`]. This is
133/// the upper bound on the crash-loss window (OS-crash / power-loss only).
134const NORMAL_FSYNC_INTERVAL: Duration = Duration::from_millis(10);
135
136pub struct Wal {
137    path: PathBuf,
138    writer: Option<BufWriter<File>>,
139    batch_size: usize,
140    pending: usize,
141    sync_mode: WalSyncMode,
142    /// Monotonic LSN counter. Starts at 1 (0 means "no WAL record has
143    /// ever touched this page") and increments by 1 on every `append`.
144    next_lsn: u64,
145    /// File length as of the last successful WAL sync/truncate/open.
146    ///
147    /// `BufWriter` may write large pending records through to the OS file
148    /// before [`Self::flush`] is called. Those bytes are file-visible but
149    /// not transaction-durable. Rollback truncates back to this boundary so
150    /// a same-process reopen cannot replay uncommitted records.
151    records_start: u64,
152    synced_len: u64,
153    /// Monotonic counter bumped on every durable-intent `flush()` (non-Off).
154    /// The Normal background flusher fsyncs whenever `dirty_gen > synced_gen`.
155    dirty_gen: Arc<AtomicU64>,
156    /// Highest `dirty_gen` value known to be fsync-durable. Advanced by Full's
157    /// inline fsync and by the Normal background flusher.
158    synced_gen: Arc<AtomicU64>,
159    /// Background fsync thread; present only while in `Normal` mode.
160    flusher: Option<Flusher>,
161}
162
163/// Background fsync worker for [`WalSyncMode::Normal`]. Owns a cloned WAL file
164/// descriptor and fsyncs it on [`NORMAL_FSYNC_INTERVAL`] whenever new bytes
165/// have been buffered, keeping the fsync off the commit/lock path. fsync on the
166/// cloned fd flushes the same underlying file (inode) the writer appends to.
167struct Flusher {
168    handle: Option<JoinHandle<()>>,
169    /// `(stop, condvar)` — set `stop=true` + notify to wake the thread early.
170    ctl: Arc<(Mutex<bool>, Condvar)>,
171}
172
173impl Flusher {
174    fn spawn(
175        file: File,
176        dirty_gen: Arc<AtomicU64>,
177        synced_gen: Arc<AtomicU64>,
178        interval: Duration,
179    ) -> Flusher {
180        let ctl: Arc<(Mutex<bool>, Condvar)> = Arc::new((Mutex::new(false), Condvar::new()));
181        let ctl_thread = Arc::clone(&ctl);
182        let handle = std::thread::Builder::new()
183            .name("powdb-wal-flusher".into())
184            .spawn(move || {
185                let (lock, cvar) = &*ctl_thread;
186                loop {
187                    let stopping = {
188                        let stop = lock.lock().expect("wal flusher lock");
189                        if *stop {
190                            true
191                        } else {
192                            let (stop, _timeout) =
193                                cvar.wait_timeout(stop, interval).expect("wal flusher wait");
194                            *stop
195                        }
196                    };
197                    // fsync if the writer has buffered new bytes since last sync.
198                    let d = dirty_gen.load(Ordering::Acquire);
199                    if d > synced_gen.load(Ordering::Acquire) {
200                        match file.sync_data() {
201                            Ok(()) => synced_gen.store(d, Ordering::Release),
202                            // In Normal mode this background fsync is the ONLY
203                            // durability point. Swallowing the error (the old
204                            // `&& .is_ok()`) meant an ENOSPC/EIO would keep the
205                            // writer acking commits that never reached stable
206                            // storage, with no signal. Surface it; synced_gen
207                            // stays un-advanced so the next tick retries.
208                            Err(e) => tracing::warn!(
209                                error = %e,
210                                "WAL background fsync failed; commits since the last \
211                                 successful sync are not yet durable (will retry)"
212                            ),
213                        }
214                    }
215                    if stopping {
216                        break;
217                    }
218                }
219            })
220            .expect("spawn wal flusher thread");
221        Flusher {
222            handle: Some(handle),
223            ctl,
224        }
225    }
226
227    fn stop(&mut self) {
228        {
229            let (lock, cvar) = &*self.ctl;
230            let mut stop = lock.lock().expect("wal flusher lock");
231            *stop = true;
232            cvar.notify_all();
233        }
234        if let Some(h) = self.handle.take() {
235            let _ = h.join();
236        }
237    }
238}
239
240impl Drop for Flusher {
241    fn drop(&mut self) {
242        self.stop();
243    }
244}
245
246impl Wal {
247    pub fn create(path: &Path, batch_size: usize) -> io::Result<Self> {
248        let mut file = OpenOptions::new()
249            .create(true)
250            .write(true)
251            .read(true)
252            .truncate(true)
253            .open(path)?;
254        write_wal_file_header(&mut file)?;
255        Ok(Wal {
256            path: path.to_path_buf(),
257            writer: Some(BufWriter::new(file)),
258            batch_size,
259            pending: 0,
260            sync_mode: WalSyncMode::default(),
261            next_lsn: 1,
262            records_start: WAL_FILE_HEADER_SIZE,
263            synced_len: WAL_FILE_HEADER_SIZE,
264            dirty_gen: Arc::new(AtomicU64::new(0)),
265            synced_gen: Arc::new(AtomicU64::new(0)),
266            flusher: None,
267        })
268    }
269
270    pub fn open(path: &Path, batch_size: usize) -> io::Result<Self> {
271        let mut file = OpenOptions::new()
272            .create(true)
273            .read(true)
274            .append(true)
275            .open(path)?;
276        let records_start = wal_records_start(&mut file)?;
277        let synced_len = file.metadata()?.len();
278        Ok(Wal {
279            path: path.to_path_buf(),
280            writer: Some(BufWriter::new(file)),
281            batch_size,
282            pending: 0,
283            sync_mode: WalSyncMode::default(),
284            next_lsn: 1,
285            records_start,
286            synced_len,
287            dirty_gen: Arc::new(AtomicU64::new(0)),
288            synced_gen: Arc::new(AtomicU64::new(0)),
289            flusher: None,
290        })
291    }
292
293    /// Toggle the durability mode. See [`WalSyncMode`] for the contract.
294    /// Starts the background flusher when entering `Normal`, and stops it when
295    /// leaving `Normal`. The fsync-behavior change takes effect on the next
296    /// `flush()`.
297    pub fn set_sync_mode(&mut self, mode: WalSyncMode) {
298        if mode == self.sync_mode {
299            return;
300        }
301        self.sync_mode = mode;
302        match mode {
303            WalSyncMode::Normal => self.start_flusher(),
304            WalSyncMode::Full | WalSyncMode::Off => self.stop_flusher(),
305        }
306    }
307
308    /// Spawn the Normal-mode background flusher if not already running. The
309    /// flusher fsyncs a cloned WAL fd, so it never contends on the writer.
310    fn start_flusher(&mut self) {
311        if self.flusher.is_some() {
312            return;
313        }
314        if let Some(writer) = self.writer.as_ref() {
315            if let Ok(file) = writer.get_ref().try_clone() {
316                self.flusher = Some(Flusher::spawn(
317                    file,
318                    Arc::clone(&self.dirty_gen),
319                    Arc::clone(&self.synced_gen),
320                    NORMAL_FSYNC_INTERVAL,
321                ));
322            }
323        }
324    }
325
326    /// Stop the background flusher (final fsync + join), if running.
327    fn stop_flusher(&mut self) {
328        if let Some(mut f) = self.flusher.take() {
329            f.stop();
330        }
331    }
332
333    /// The highest dirty generation known to be fsync-durable. Advances on
334    /// every Full commit and on each Normal background-flusher cycle. Exposed
335    /// for tests and (future) metrics.
336    pub fn synced_generation(&self) -> u64 {
337        self.synced_gen.load(Ordering::Acquire)
338    }
339
340    /// Returns the current sync mode (used by tests + introspection).
341    pub fn sync_mode(&self) -> WalSyncMode {
342        self.sync_mode
343    }
344
345    /// `true` when the WAL is in [`WalSyncMode::Off`] — i.e. every
346    /// `append`/`flush` is a no-op. Catalog mutation hot paths check
347    /// this BEFORE constructing WAL payloads so we don't pay
348    /// `encode_row_into` + `encode_wal_payload` allocs only to throw
349    /// the result away inside `append`. This is the difference between
350    /// "no fsync" and "free" — the former is still 50–60% slower than
351    /// the no-WAL baseline on `update_by_filter`/`delete_by_filter`,
352    /// the latter matches the baseline.
353    #[inline]
354    pub fn is_off(&self) -> bool {
355        matches!(self.sync_mode, WalSyncMode::Off)
356    }
357
358    /// LSN of the most recently appended record, or 0 if nothing has
359    /// been appended yet (or the WAL is off).
360    ///
361    /// Used by schema-change paths to capture a "barrier LSN" that
362    /// reflects the DDL record's position in the log; the heap can then
363    /// stamp its pages with that LSN so replay skips every
364    /// Insert/Update/Delete that pre-dates the schema change (those rows
365    /// have already been migrated to the new layout in place).
366    #[inline]
367    pub fn last_appended_lsn(&self) -> u64 {
368        if matches!(self.sync_mode, WalSyncMode::Off) {
369            return 0;
370        }
371        self.next_lsn.saturating_sub(1)
372    }
373
374    /// Ensure the next LSN this WAL hands out is at least `lsn`. Called on
375    /// open, after recovery, to restore monotonicity: heap pages carry the
376    /// LSNs stamped during replay (and by DDL rewrites), but `Wal::open`
377    /// always resets `next_lsn` to 1. Without this, writes taken after a
378    /// crash-recovery would reuse LSNs at or below those stamped page LSNs,
379    /// and the next crash's replay would skip them as already-applied —
380    /// silent data loss. Never lowers the counter.
381    pub fn set_next_lsn_at_least(&mut self, lsn: u64) {
382        if lsn > self.next_lsn {
383            self.next_lsn = lsn;
384        }
385    }
386
387    /// Append a record to the WAL buffer. Auto-flushes when batch is full.
388    ///
389    /// In [`WalSyncMode::Off`] this is a zero-work no-op — see the enum's
390    /// doc for the durability contract.
391    pub fn append(
392        &mut self,
393        tx_id: u64,
394        record_type: WalRecordType,
395        data: &[u8],
396    ) -> io::Result<()> {
397        if matches!(self.sync_mode, WalSyncMode::Off) {
398            return Ok(());
399        }
400        let lsn = self.next_lsn;
401        self.next_lsn += 1;
402        let total_len = (WAL_HEADER_SIZE + data.len()) as u32;
403
404        // Compute CRC over tx_id + type + lsn + data
405        let mut crc_input = Vec::with_capacity(17 + data.len());
406        crc_input.extend_from_slice(&tx_id.to_le_bytes());
407        crc_input.push(record_type as u8);
408        crc_input.extend_from_slice(&lsn.to_le_bytes());
409        crc_input.extend_from_slice(data);
410        let crc = crc32fast::hash(&crc_input);
411
412        // Write: len + crc + tx_id + type + lsn + data
413        let writer = self
414            .writer
415            .as_mut()
416            .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
417        writer.write_all(&total_len.to_le_bytes())?;
418        writer.write_all(&crc.to_le_bytes())?;
419        writer.write_all(&tx_id.to_le_bytes())?;
420        writer.write_all(&[record_type as u8])?;
421        writer.write_all(&lsn.to_le_bytes())?;
422        writer.write_all(data)?;
423
424        self.pending += 1;
425        if self.pending >= self.batch_size {
426            self.flush()?;
427        }
428        Ok(())
429    }
430
431    /// Flush buffered records to disk with fsync (the group commit point).
432    ///
433    /// No-op if nothing has been appended since the last flush. This makes
434    /// it safe for the executor to unconditionally call `sync_wal` at the
435    /// end of every statement — read queries pay zero fsync cost.
436    pub fn flush(&mut self) -> io::Result<()> {
437        let batch = self.pending;
438        if batch == 0 {
439            return Ok(());
440        }
441        let is_full = matches!(self.sync_mode, WalSyncMode::Full);
442        let is_off = matches!(self.sync_mode, WalSyncMode::Off);
443        // Borrow the writer only for the I/O, then drop it before touching the
444        // generation counters (which borrow `self`).
445        let new_len = {
446            let writer = self
447                .writer
448                .as_mut()
449                .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
450            writer.flush()?;
451            // SQLite-style synchronous knob: only the explicit fsync is gated.
452            // The BufWriter::flush above always runs so a process crash still
453            // recovers cleanly via `read_all`. In `Full` we fsync inline here;
454            // in `Normal` the background flusher fsyncs off this path.
455            if is_full {
456                writer.get_ref().sync_data()?;
457            }
458            writer.get_ref().metadata()?.len()
459        };
460        if !is_off {
461            let gen = self.dirty_gen.fetch_add(1, Ordering::Release) + 1;
462            if is_full {
463                self.synced_gen.store(gen, Ordering::Release);
464            }
465        }
466        self.synced_len = new_len;
467        self.pending = 0;
468        debug!(records = batch, "wal group commit");
469        Ok(())
470    }
471
472    /// True when records have been appended to the in-memory WAL buffer
473    /// since the last durable flush.
474    #[inline]
475    pub fn has_pending(&self) -> bool {
476        self.pending > 0
477    }
478
479    /// Flush pending WAL bytes, then return the durable file length. Used as
480    /// an explicit-transaction rollback boundary.
481    pub fn synced_len(&mut self) -> io::Result<u64> {
482        self.flush()?;
483        Ok(self.synced_len)
484    }
485
486    /// Discard buffered (not-yet-flushed) WAL bytes and truncate the durable
487    /// log back to `len`. This is intentionally not implemented by dropping
488    /// the existing BufWriter: BufWriter's Drop attempts to flush buffered
489    /// bytes, which would resurrect rolled-back records.
490    pub fn discard_and_truncate_to(&mut self, len: u64) -> io::Result<()> {
491        if matches!(self.sync_mode, WalSyncMode::Off) {
492            self.pending = 0;
493            self.synced_len = len;
494            return Ok(());
495        }
496
497        if let Some(writer) = self.writer.take() {
498            let (_file, _buffer_result) = writer.into_parts();
499        }
500
501        let mut file = OpenOptions::new()
502            .create(true)
503            .read(true)
504            .append(true)
505            .open(&self.path)?;
506        file.set_len(len)?;
507        file.seek(SeekFrom::End(0))?;
508        file.sync_data()?;
509        self.writer = Some(BufWriter::new(file));
510        self.pending = 0;
511        self.synced_len = len;
512        Ok(())
513    }
514
515    /// Read all valid records from the WAL file.
516    pub fn read_all(&self) -> io::Result<Vec<WalRecord>> {
517        let mut file = File::open(&self.path)?;
518        let file_len = file.metadata()?.len();
519        let mut file_for_header = File::open(&self.path)?;
520        let mut pos = wal_records_start(&mut file_for_header)?;
521        let mut records = Vec::new();
522
523        while pos + WAL_HEADER_SIZE as u64 <= file_len {
524            file.seek(SeekFrom::Start(pos))?;
525
526            let mut header = [0u8; WAL_HEADER_SIZE];
527            if file.read_exact(&mut header).is_err() {
528                break;
529            }
530
531            // These slice-to-array conversions are infallible (fixed-size
532            // sub-slices of a 17-byte array) but we avoid `unwrap` to
533            // satisfy the project-wide zero-panic policy.
534            let total_len_bytes: [u8; 4] = match header[0..4].try_into() {
535                Ok(b) => b,
536                Err(_) => break,
537            };
538            let total_len = u32::from_le_bytes(total_len_bytes) as usize;
539            let stored_crc_bytes: [u8; 4] = match header[4..8].try_into() {
540                Ok(b) => b,
541                Err(_) => break,
542            };
543            let stored_crc = u32::from_le_bytes(stored_crc_bytes);
544            let tx_id_bytes: [u8; 8] = match header[8..16].try_into() {
545                Ok(b) => b,
546                Err(_) => break,
547            };
548            let tx_id = u64::from_le_bytes(tx_id_bytes);
549            let record_type = match WalRecordType::from_u8(header[16]) {
550                Some(rt) => rt,
551                None => break,
552            };
553            let lsn_bytes: [u8; 8] = match header[17..25].try_into() {
554                Ok(b) => b,
555                Err(_) => break,
556            };
557            let lsn = u64::from_le_bytes(lsn_bytes);
558
559            // TASK-11: Verify the record fits within the file before
560            // allocating. Catches truncated writes without any allocation.
561            if pos + total_len as u64 > file_len {
562                break; // Record extends beyond file — truncated write
563            }
564
565            // TASK-09: Use checked_sub to prevent integer underflow when
566            // a corrupted WAL has total_len < WAL_HEADER_SIZE.
567            let data_len = match total_len.checked_sub(WAL_HEADER_SIZE) {
568                Some(len) => len,
569                None => break, // Corrupted record — stop replay
570            };
571
572            // TASK-10: Cap allocation size before reading data. A crafted
573            // WAL claiming a huge total_len would otherwise allocate
574            // gigabytes before the CRC check rejects the record.
575            if data_len > MAX_WAL_RECORD_SIZE {
576                break; // Unreasonably large record — treat as corruption
577            }
578
579            let mut data = vec![0u8; data_len];
580            if data_len > 0 {
581                file.read_exact(&mut data)?;
582            }
583
584            // Verify CRC (includes lsn in the hash input)
585            let mut crc_input = Vec::with_capacity(17 + data.len());
586            crc_input.extend_from_slice(&tx_id.to_le_bytes());
587            crc_input.push(record_type as u8);
588            crc_input.extend_from_slice(&lsn.to_le_bytes());
589            crc_input.extend_from_slice(&data);
590            let computed_crc = crc32fast::hash(&crc_input);
591
592            if computed_crc != stored_crc {
593                break; // Corrupted record — stop here
594            }
595
596            records.push(WalRecord {
597                tx_id,
598                record_type,
599                lsn,
600                data,
601            });
602            pos += total_len as u64;
603        }
604
605        Ok(records)
606    }
607
608    /// Truncate the WAL (after checkpoint).
609    pub fn truncate(&mut self) -> io::Result<()> {
610        let mut file = OpenOptions::new()
611            .write(true)
612            .read(true)
613            .truncate(true)
614            .open(&self.path)?;
615        write_wal_file_header(&mut file)?;
616        self.writer = Some(BufWriter::new(file));
617        self.records_start = WAL_FILE_HEADER_SIZE;
618        self.pending = 0;
619        self.synced_len = WAL_FILE_HEADER_SIZE;
620        Ok(())
621    }
622
623    /// Discard records appended since the last successful [`Self::flush`].
624    ///
625    /// This is intentionally different from `flush`: it must not flush the
626    /// current `BufWriter`, because rollback uses it to abandon uncommitted
627    /// transaction records. `BufWriter::into_parts` lets us drop the buffered
628    /// bytes without writing them, then we truncate any large records that
629    /// had already spilled through to the file back to the last synced
630    /// boundary.
631    pub fn discard_pending(&mut self) -> io::Result<()> {
632        if matches!(self.sync_mode, WalSyncMode::Off) {
633            self.pending = 0;
634            return Ok(());
635        }
636
637        if let Some(writer) = self.writer.take() {
638            let (_file, _buffer) = writer.into_parts();
639        }
640
641        let file = OpenOptions::new()
642            .read(true)
643            .append(true)
644            .create(true)
645            .truncate(false)
646            .open(&self.path)?;
647        file.set_len(self.synced_len)?;
648        file.sync_data()?;
649        self.writer = Some(BufWriter::new(file));
650        self.pending = 0;
651        self.synced_len = self.records_start;
652        Ok(())
653    }
654}
655
656impl Drop for Wal {
657    fn drop(&mut self) {
658        // Clean shutdown must be durable regardless of mode: push any buffered
659        // bytes to the OS and fsync, so a Normal-mode commit that hasn't yet
660        // hit the background flusher's interval is still durable on a graceful
661        // exit. (A process *crash* skips this — Normal's bounded-loss contract
662        // only applies to OS-crash / power-loss, which this cannot help.)
663        if !matches!(self.sync_mode, WalSyncMode::Off) {
664            if let Some(writer) = self.writer.as_mut() {
665                let _ = writer.flush();
666                let _ = writer.get_ref().sync_data();
667            }
668        }
669        self.stop_flusher();
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    fn temp_wal(name: &str) -> (Wal, PathBuf) {
678        let path = std::env::temp_dir().join(format!("powdb_wal_{name}_{}", std::process::id()));
679        let wal = Wal::create(&path, 4).unwrap();
680        (wal, path)
681    }
682
683    #[test]
684    fn test_append_and_flush() {
685        let (mut wal, path) = temp_wal("basic");
686        wal.append(1, WalRecordType::Insert, b"row data 1").unwrap();
687        wal.append(1, WalRecordType::Insert, b"row data 2").unwrap();
688        wal.flush().unwrap();
689
690        let records = wal.read_all().unwrap();
691        assert_eq!(records.len(), 2);
692        assert_eq!(records[0].tx_id, 1);
693        assert_eq!(records[0].data, b"row data 1");
694        assert_eq!(records[1].data, b"row data 2");
695        drop(wal);
696        std::fs::remove_file(&path).ok();
697    }
698
699    #[test]
700    fn test_group_commit_auto_flush() {
701        let (mut wal, path) = temp_wal("group");
702        // Batch size is 4 — after 4 appends, should auto-flush
703        for i in 0..4 {
704            wal.append(1, WalRecordType::Insert, format!("row {i}").as_bytes())
705                .unwrap();
706        }
707        // Should have flushed automatically
708        let records = wal.read_all().unwrap();
709        assert_eq!(records.len(), 4);
710        drop(wal);
711        std::fs::remove_file(&path).ok();
712    }
713
714    #[test]
715    fn test_normal_mode_persists_records_across_reopen() {
716        // NORMAL durability: commits are acked after the buffered bytes reach
717        // the OS (BufWriter::flush) without a per-commit fsync; a background
718        // flusher + clean shutdown make them durable. Data must survive a
719        // clean close + reopen.
720        let path =
721            std::env::temp_dir().join(format!("powdb_wal_normal_reopen_{}", std::process::id()));
722        std::fs::remove_file(&path).ok();
723        {
724            let mut wal = Wal::create(&path, 4).unwrap();
725            wal.set_sync_mode(WalSyncMode::Normal);
726            assert_eq!(wal.sync_mode(), WalSyncMode::Normal);
727            wal.append(1, WalRecordType::Insert, b"n1").unwrap();
728            wal.append(1, WalRecordType::Insert, b"n2").unwrap();
729            wal.flush().unwrap();
730        } // drop: stop flusher + final fsync
731        let wal = Wal::open(&path, 4).unwrap();
732        let records = wal.read_all().unwrap();
733        assert_eq!(records.len(), 2);
734        assert_eq!(records[0].data, b"n1");
735        assert_eq!(records[1].data, b"n2");
736        std::fs::remove_file(&path).ok();
737    }
738
739    #[test]
740    fn test_normal_mode_background_flusher_syncs_off_commit_path() {
741        // In NORMAL mode flush() must NOT fsync inline; the background flusher
742        // fsyncs on its interval and advances the synced generation. Proves the
743        // fsync is off the commit path (the latency win) yet still happens.
744        let path = std::env::temp_dir().join(format!("powdb_wal_normal_bg_{}", std::process::id()));
745        std::fs::remove_file(&path).ok();
746        let mut wal = Wal::create(&path, 1000).unwrap(); // large batch: no auto-flush
747        wal.set_sync_mode(WalSyncMode::Normal);
748        wal.append(1, WalRecordType::Insert, b"bg1").unwrap();
749        wal.flush().unwrap(); // buffers to OS + marks dirty; no inline fsync
750                              // The background flusher should fsync within its (~10 ms) interval.
751        std::thread::sleep(std::time::Duration::from_millis(80));
752        assert!(
753            wal.synced_generation() >= 1,
754            "background flusher did not sync (synced_generation = {})",
755            wal.synced_generation()
756        );
757        std::fs::remove_file(&path).ok();
758    }
759
760    #[test]
761    fn test_crc_integrity() {
762        let (mut wal, path) = temp_wal("crc");
763        wal.append(1, WalRecordType::Insert, b"important data")
764            .unwrap();
765        wal.flush().unwrap();
766
767        let records = wal.read_all().unwrap();
768        assert_eq!(records.len(), 1);
769        // CRC was validated during read_all — if we get here, integrity is good
770        drop(wal);
771        std::fs::remove_file(&path).ok();
772    }
773
774    #[test]
775    fn test_multiple_transactions() {
776        let (mut wal, path) = temp_wal("multi_tx");
777        wal.append(1, WalRecordType::Insert, b"tx1 op1").unwrap();
778        wal.append(2, WalRecordType::Insert, b"tx2 op1").unwrap();
779        wal.append(1, WalRecordType::Commit, b"").unwrap();
780        wal.append(2, WalRecordType::Commit, b"").unwrap();
781        wal.flush().unwrap();
782
783        let records = wal.read_all().unwrap();
784        assert_eq!(records.len(), 4);
785        assert_eq!(records[0].tx_id, 1);
786        assert_eq!(records[2].tx_id, 1);
787        assert_eq!(records[2].record_type, WalRecordType::Commit);
788        drop(wal);
789        std::fs::remove_file(&path).ok();
790    }
791
792    #[test]
793    fn test_truncate() {
794        let (mut wal, path) = temp_wal("trunc");
795        for i in 0..8 {
796            wal.append(1, WalRecordType::Insert, format!("data {i}").as_bytes())
797                .unwrap();
798        }
799        wal.flush().unwrap();
800        assert_eq!(wal.read_all().unwrap().len(), 8);
801
802        wal.truncate().unwrap();
803        assert_eq!(wal.read_all().unwrap().len(), 0);
804        drop(wal);
805        std::fs::remove_file(&path).ok();
806    }
807
808    #[test]
809    fn test_reopen_wal() {
810        let path = std::env::temp_dir().join(format!("powdb_wal_reopen_{}", std::process::id()));
811        {
812            let mut wal = Wal::create(&path, 128).unwrap();
813            wal.append(1, WalRecordType::Insert, b"persistent").unwrap();
814            wal.append(1, WalRecordType::Commit, b"").unwrap();
815            wal.flush().unwrap();
816        }
817        {
818            let wal = Wal::open(&path, 128).unwrap();
819            let records = wal.read_all().unwrap();
820            assert_eq!(records.len(), 2);
821            assert_eq!(records[0].data, b"persistent");
822            assert_eq!(records[1].record_type, WalRecordType::Commit);
823        }
824        std::fs::remove_file(&path).ok();
825    }
826}