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    pub 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
136/// Fsync-coordination state shared between the `Wal`, the Normal-mode
137/// background flusher, and any outstanding [`WalDurabilityTicket`]s.
138///
139/// This is the heart of Full-mode group commit: `dirty_gen` counts
140/// flush-to-OS generations, `synced_gen` tracks the highest generation an
141/// fsync has covered, and `sync_file` is both the fd fsyncs go through and
142/// the leader election — whoever holds the mutex fsyncs on behalf of every
143/// generation registered before the fsync started.
144#[derive(Debug)]
145struct WalSyncShared {
146    /// Monotonic counter bumped on every durable-intent flush-to-OS (non-Off).
147    /// A generation is only registered after its bytes reached the OS file
148    /// (`BufWriter::flush`), so an fsync issued afterwards always covers it.
149    dirty_gen: AtomicU64,
150    /// Highest `dirty_gen` value known to be fsync-durable. Advanced by
151    /// group-commit leaders and by the Normal background flusher.
152    synced_gen: AtomicU64,
153    /// Number of `sync_data` calls issued on the WAL file. Test/metrics hook:
154    /// group-commit coalescing shows up as fewer fsyncs than commits.
155    fsync_count: AtomicU64,
156    /// The fd used for fsyncs, doubling as the group-commit leader lock.
157    /// `None` only if cloning the writer's fd failed on (re)open.
158    sync_file: Mutex<Option<File>>,
159}
160
161impl WalSyncShared {
162    fn new(sync_file: Option<File>) -> Self {
163        WalSyncShared {
164            dirty_gen: AtomicU64::new(0),
165            synced_gen: AtomicU64::new(0),
166            fsync_count: AtomicU64::new(0),
167            sync_file: Mutex::new(sync_file),
168        }
169    }
170
171    /// Block until an fsync covering `gen` has completed (leader/follower
172    /// group commit). The first caller to take the lock fsyncs once for every
173    /// generation registered so far; callers queued behind it wake up already
174    /// covered and return without an fsync of their own. A lone caller finds
175    /// the lock free and fsyncs immediately — group commit never introduces a
176    /// wait for company.
177    fn sync_until(&self, gen: u64) -> io::Result<()> {
178        if self.synced_gen.load(Ordering::Acquire) >= gen {
179            return Ok(());
180        }
181        let guard = self
182            .sync_file
183            .lock()
184            .map_err(|_| io::Error::other("WAL sync lock poisoned"))?;
185        // A leader that ran while we were queued may already have covered us.
186        if self.synced_gen.load(Ordering::Acquire) >= gen {
187            return Ok(());
188        }
189        let file = guard
190            .as_ref()
191            .ok_or_else(|| io::Error::other("WAL sync fd unavailable"))?;
192        // Snapshot BEFORE the fsync: every generation registered by now has
193        // its bytes in the OS file already, so this one fsync covers them all.
194        let cover = self.dirty_gen.load(Ordering::Acquire);
195        file.sync_data()?;
196        self.fsync_count.fetch_add(1, Ordering::Relaxed);
197        self.synced_gen.fetch_max(cover, Ordering::AcqRel);
198        Ok(())
199    }
200
201    /// Swap the fsync fd and mark every generation registered so far as
202    /// settled. Called when the WAL file is truncated or recreated: the bytes
203    /// those generations covered are gone from the log — either already
204    /// durable elsewhere (checkpoint flushed the heaps; the discard paths
205    /// `sync_data` the truncated file) or intentionally discarded by rollback
206    /// — so no ticket must ever block on them again.
207    fn replace_file(&self, file: Option<File>) {
208        // Take the leader lock so an in-flight fsync on the old fd finishes
209        // before the swap. Poisoning is impossible in practice (the critical
210        // section cannot panic) but recover anyway rather than propagate.
211        let mut guard = match self.sync_file.lock() {
212            Ok(g) => g,
213            Err(poisoned) => poisoned.into_inner(),
214        };
215        let d = self.dirty_gen.load(Ordering::Acquire);
216        self.synced_gen.fetch_max(d, Ordering::AcqRel);
217        *guard = file;
218    }
219}
220
221/// A claim on WAL durability handed out by a deferred Full-mode flush: the
222/// commit's records have reached the OS file but are not yet guaranteed on
223/// stable storage. [`Self::wait`] blocks until an fsync covering them has
224/// completed — the caller must not acknowledge the commit before `wait`
225/// returns `Ok(())`.
226///
227/// Tickets are cumulative: generations are registered in order, so waiting on
228/// a later ticket also makes every earlier generation durable. Waiting takes
229/// no `Wal` lock, which is what lets a committer release the engine's write
230/// lock first and other committers append while the fsync runs — the overlap
231/// that lets one fsync cover many commits.
232#[derive(Debug)]
233#[must_use = "a commit must not be acknowledged until wait() returns Ok"]
234pub struct WalDurabilityTicket {
235    gen: u64,
236    shared: Arc<WalSyncShared>,
237}
238
239impl WalDurabilityTicket {
240    /// Block until an fsync covering this ticket's WAL records has completed.
241    /// See [`WalSyncShared::sync_until`] for the leader/follower scheme.
242    pub fn wait(self) -> io::Result<()> {
243        self.shared.sync_until(self.gen)
244    }
245}
246
247pub struct Wal {
248    path: PathBuf,
249    writer: Option<BufWriter<File>>,
250    batch_size: usize,
251    pending: usize,
252    sync_mode: WalSyncMode,
253    /// Monotonic LSN counter. Starts at 1 (0 means "no WAL record has
254    /// ever touched this page") and increments by 1 on every `append`.
255    next_lsn: u64,
256    /// File length as of the last successful WAL sync/truncate/open.
257    ///
258    /// `BufWriter` may write large pending records through to the OS file
259    /// before [`Self::flush`] is called. Those bytes are file-visible but
260    /// not transaction-durable. Rollback truncates back to this boundary so
261    /// a same-process reopen cannot replay uncommitted records.
262    records_start: u64,
263    synced_len: u64,
264    /// Group-commit fsync coordination (see [`WalSyncShared`]).
265    shared: Arc<WalSyncShared>,
266    /// When `true`, a Full-mode `flush()` registers the generation it needs
267    /// durable instead of fsyncing inline; [`Self::take_durability_ticket`]
268    /// hands the claim to the caller, who must wait on it before
269    /// acknowledging the commit. See [`Self::set_defer_sync`].
270    defer_sync: bool,
271    /// Highest generation registered by deferred flushes since the last
272    /// `take_durability_ticket`. Cumulative — a later generation covers all
273    /// earlier ones, so overwriting never loses coverage.
274    deferred_gen: Option<u64>,
275    /// Background fsync thread; present only while in `Normal` mode.
276    flusher: Option<Flusher>,
277}
278
279/// Background fsync worker for [`WalSyncMode::Normal`]. Owns a cloned WAL file
280/// descriptor and fsyncs it on [`NORMAL_FSYNC_INTERVAL`] whenever new bytes
281/// have been buffered, keeping the fsync off the commit/lock path. fsync on the
282/// cloned fd flushes the same underlying file (inode) the writer appends to.
283struct Flusher {
284    handle: Option<JoinHandle<()>>,
285    /// `(stop, condvar)` — set `stop=true` + notify to wake the thread early.
286    ctl: Arc<(Mutex<bool>, Condvar)>,
287}
288
289impl Flusher {
290    fn spawn(file: File, shared: Arc<WalSyncShared>, interval: Duration) -> Flusher {
291        let ctl: Arc<(Mutex<bool>, Condvar)> = Arc::new((Mutex::new(false), Condvar::new()));
292        let ctl_thread = Arc::clone(&ctl);
293        let handle = std::thread::Builder::new()
294            .name("powdb-wal-flusher".into())
295            .spawn(move || {
296                let (lock, cvar) = &*ctl_thread;
297                loop {
298                    let stopping = {
299                        let stop = lock.lock().expect("wal flusher lock");
300                        if *stop {
301                            true
302                        } else {
303                            let (stop, _timeout) =
304                                cvar.wait_timeout(stop, interval).expect("wal flusher wait");
305                            *stop
306                        }
307                    };
308                    // fsync if the writer has buffered new bytes since last sync.
309                    let d = shared.dirty_gen.load(Ordering::Acquire);
310                    if d > shared.synced_gen.load(Ordering::Acquire) {
311                        match file.sync_data() {
312                            Ok(()) => {
313                                shared.fsync_count.fetch_add(1, Ordering::Relaxed);
314                                // fetch_max, not store: a Full-mode group
315                                // commit may have advanced past `d` between
316                                // the load and the fsync (mode switches).
317                                shared.synced_gen.fetch_max(d, Ordering::AcqRel);
318                            }
319                            // In Normal mode this background fsync is the ONLY
320                            // durability point. Swallowing the error (the old
321                            // `&& .is_ok()`) meant an ENOSPC/EIO would keep the
322                            // writer acking commits that never reached stable
323                            // storage, with no signal. Surface it; synced_gen
324                            // stays un-advanced so the next tick retries.
325                            Err(e) => tracing::warn!(
326                                error = %e,
327                                "WAL background fsync failed; commits since the last \
328                                 successful sync are not yet durable (will retry)"
329                            ),
330                        }
331                    }
332                    if stopping {
333                        break;
334                    }
335                }
336            })
337            .expect("spawn wal flusher thread");
338        Flusher {
339            handle: Some(handle),
340            ctl,
341        }
342    }
343
344    fn stop(&mut self) {
345        {
346            let (lock, cvar) = &*self.ctl;
347            let mut stop = lock.lock().expect("wal flusher lock");
348            *stop = true;
349            cvar.notify_all();
350        }
351        if let Some(h) = self.handle.take() {
352            let _ = h.join();
353        }
354    }
355}
356
357impl Drop for Flusher {
358    fn drop(&mut self) {
359        self.stop();
360    }
361}
362
363impl Wal {
364    pub fn create(path: &Path, batch_size: usize) -> io::Result<Self> {
365        let mut file = OpenOptions::new()
366            .create(true)
367            .write(true)
368            .read(true)
369            .truncate(true)
370            .open(path)?;
371        write_wal_file_header(&mut file)?;
372        let sync_fd = file.try_clone()?;
373        Ok(Wal {
374            path: path.to_path_buf(),
375            writer: Some(BufWriter::new(file)),
376            batch_size,
377            pending: 0,
378            sync_mode: WalSyncMode::default(),
379            next_lsn: 1,
380            records_start: WAL_FILE_HEADER_SIZE,
381            synced_len: WAL_FILE_HEADER_SIZE,
382            shared: Arc::new(WalSyncShared::new(Some(sync_fd))),
383            defer_sync: false,
384            deferred_gen: None,
385            flusher: None,
386        })
387    }
388
389    pub fn open(path: &Path, batch_size: usize) -> io::Result<Self> {
390        let mut file = OpenOptions::new()
391            .create(true)
392            .read(true)
393            .append(true)
394            .open(path)?;
395        let records_start = wal_records_start(&mut file)?;
396        let synced_len = file.metadata()?.len();
397        let sync_fd = file.try_clone()?;
398        Ok(Wal {
399            path: path.to_path_buf(),
400            writer: Some(BufWriter::new(file)),
401            batch_size,
402            pending: 0,
403            sync_mode: WalSyncMode::default(),
404            next_lsn: 1,
405            records_start,
406            synced_len,
407            shared: Arc::new(WalSyncShared::new(Some(sync_fd))),
408            defer_sync: false,
409            deferred_gen: None,
410            flusher: None,
411        })
412    }
413
414    /// Toggle the durability mode. See [`WalSyncMode`] for the contract.
415    /// Starts the background flusher when entering `Normal`, and stops it when
416    /// leaving `Normal`. The fsync-behavior change takes effect on the next
417    /// `flush()`.
418    pub fn set_sync_mode(&mut self, mode: WalSyncMode) {
419        if mode == self.sync_mode {
420            return;
421        }
422        self.sync_mode = mode;
423        match mode {
424            WalSyncMode::Normal => self.start_flusher(),
425            WalSyncMode::Full | WalSyncMode::Off => self.stop_flusher(),
426        }
427    }
428
429    /// Spawn the Normal-mode background flusher if not already running. The
430    /// flusher fsyncs a cloned WAL fd, so it never contends on the writer.
431    fn start_flusher(&mut self) {
432        if self.flusher.is_some() {
433            return;
434        }
435        if let Some(writer) = self.writer.as_ref() {
436            if let Ok(file) = writer.get_ref().try_clone() {
437                self.flusher = Some(Flusher::spawn(
438                    file,
439                    Arc::clone(&self.shared),
440                    NORMAL_FSYNC_INTERVAL,
441                ));
442            }
443        }
444    }
445
446    /// Stop the background flusher (final fsync + join), if running.
447    fn stop_flusher(&mut self) {
448        if let Some(mut f) = self.flusher.take() {
449            f.stop();
450        }
451    }
452
453    /// The highest dirty generation known to be fsync-durable. Advances on
454    /// every Full commit and on each Normal background-flusher cycle. Exposed
455    /// for tests and (future) metrics.
456    pub fn synced_generation(&self) -> u64 {
457        self.shared.synced_gen.load(Ordering::Acquire)
458    }
459
460    /// Number of fsyncs issued against the WAL file (group-commit leaders,
461    /// inline Full-mode flushes, and the Normal background flusher). Exposed
462    /// for tests and (future) metrics: group-commit coalescing shows up as
463    /// fewer fsyncs than commits.
464    pub fn fsync_count(&self) -> u64 {
465        self.shared.fsync_count.load(Ordering::Relaxed)
466    }
467
468    /// Defer Full-mode commit fsyncs. While enabled, [`Self::flush`]
469    /// registers the generation it needs durable instead of fsyncing inline;
470    /// the pending claim is retrieved with [`Self::take_durability_ticket`]
471    /// and the caller must wait on it before acknowledging the commit. This
472    /// is how group commit lets the fsync leave the engine's exclusive-lock
473    /// hold: append + register under the lock, wait after releasing it.
474    ///
475    /// `Normal` and `Off` modes are unaffected (they never fsync inline).
476    pub fn set_defer_sync(&mut self, defer: bool) {
477        self.defer_sync = defer;
478    }
479
480    /// Take the durability claim registered by deferred flushes since the
481    /// last take, if any. Generations are cumulative, so one ticket covers
482    /// every deferred flush that happened before it was taken.
483    pub fn take_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
484        self.deferred_gen.take().map(|gen| WalDurabilityTicket {
485            gen,
486            shared: Arc::clone(&self.shared),
487        })
488    }
489
490    /// Returns the current sync mode (used by tests + introspection).
491    pub fn sync_mode(&self) -> WalSyncMode {
492        self.sync_mode
493    }
494
495    /// `true` when the WAL is in [`WalSyncMode::Off`] — i.e. every
496    /// `append`/`flush` is a no-op. Catalog mutation hot paths check
497    /// this BEFORE constructing WAL payloads so we don't pay
498    /// `encode_row_into` + `encode_wal_payload` allocs only to throw
499    /// the result away inside `append`. This is the difference between
500    /// "no fsync" and "free" — the former is still 50–60% slower than
501    /// the no-WAL baseline on `update_by_filter`/`delete_by_filter`,
502    /// the latter matches the baseline.
503    #[inline]
504    pub fn is_off(&self) -> bool {
505        matches!(self.sync_mode, WalSyncMode::Off)
506    }
507
508    /// LSN of the most recently appended record, or 0 if nothing has
509    /// been appended yet (or the WAL is off).
510    ///
511    /// Used by schema-change paths to capture a "barrier LSN" that
512    /// reflects the DDL record's position in the log; the heap can then
513    /// stamp its pages with that LSN so replay skips every
514    /// Insert/Update/Delete that pre-dates the schema change (those rows
515    /// have already been migrated to the new layout in place).
516    #[inline]
517    pub fn last_appended_lsn(&self) -> u64 {
518        if matches!(self.sync_mode, WalSyncMode::Off) {
519            return 0;
520        }
521        self.next_lsn.saturating_sub(1)
522    }
523
524    /// Ensure the next LSN this WAL hands out is at least `lsn`. Called on
525    /// open, after recovery, to restore monotonicity: heap pages carry the
526    /// LSNs stamped during replay (and by DDL rewrites), but `Wal::open`
527    /// always resets `next_lsn` to 1. Without this, writes taken after a
528    /// crash-recovery would reuse LSNs at or below those stamped page LSNs,
529    /// and the next crash's replay would skip them as already-applied —
530    /// silent data loss. Never lowers the counter.
531    pub fn set_next_lsn_at_least(&mut self, lsn: u64) {
532        if lsn > self.next_lsn {
533            self.next_lsn = lsn;
534        }
535    }
536
537    /// Append a record to the WAL buffer. Auto-flushes when batch is full.
538    ///
539    /// In [`WalSyncMode::Off`] this is a zero-work no-op — see the enum's
540    /// doc for the durability contract.
541    pub fn append(
542        &mut self,
543        tx_id: u64,
544        record_type: WalRecordType,
545        data: &[u8],
546    ) -> io::Result<()> {
547        if matches!(self.sync_mode, WalSyncMode::Off) {
548            return Ok(());
549        }
550        let lsn = self.next_lsn;
551        self.next_lsn += 1;
552        let total_len = (WAL_HEADER_SIZE + data.len()) as u32;
553
554        // Compute CRC over tx_id + type + lsn + data
555        let mut crc_input = Vec::with_capacity(17 + data.len());
556        crc_input.extend_from_slice(&tx_id.to_le_bytes());
557        crc_input.push(record_type as u8);
558        crc_input.extend_from_slice(&lsn.to_le_bytes());
559        crc_input.extend_from_slice(data);
560        let crc = crc32fast::hash(&crc_input);
561
562        // Write: len + crc + tx_id + type + lsn + data
563        let writer = self
564            .writer
565            .as_mut()
566            .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
567        writer.write_all(&total_len.to_le_bytes())?;
568        writer.write_all(&crc.to_le_bytes())?;
569        writer.write_all(&tx_id.to_le_bytes())?;
570        writer.write_all(&[record_type as u8])?;
571        writer.write_all(&lsn.to_le_bytes())?;
572        writer.write_all(data)?;
573
574        self.pending += 1;
575        if self.pending >= self.batch_size {
576            self.flush()?;
577        }
578        Ok(())
579    }
580
581    /// Flush buffered records to disk (the group commit point).
582    ///
583    /// In `Full` mode the commit is durable when this returns: the buffered
584    /// bytes are pushed to the OS and an fsync covering them completes before
585    /// the call returns — unless durability deferral is active (see
586    /// [`Self::set_defer_sync`]), in which case the fsync obligation is
587    /// registered and handed to the caller via
588    /// [`Self::take_durability_ticket`]. Either way, concurrent committers'
589    /// fsyncs coalesce: one fsync covers every generation registered before
590    /// it started, and a lone committer fsyncs immediately (no batching
591    /// delay).
592    ///
593    /// No-op if nothing has been appended since the last flush. This makes
594    /// it safe for the executor to unconditionally call `sync_wal` at the
595    /// end of every statement — read queries pay zero fsync cost.
596    pub fn flush(&mut self) -> io::Result<()> {
597        let Some(gen) = self.flush_to_os()? else {
598            return Ok(());
599        };
600        // SQLite-style synchronous knob: only the fsync is gated on the mode.
601        // The flush-to-OS above always runs so a process crash still recovers
602        // cleanly via `read_all`. In `Full` the fsync happens here (or via
603        // the deferred ticket); in `Normal` the background flusher fsyncs off
604        // this path.
605        if matches!(self.sync_mode, WalSyncMode::Full) {
606            if self.defer_sync {
607                // Cumulative: the newest generation covers all earlier ones,
608                // so overwriting an untaken claim never loses coverage.
609                self.deferred_gen = Some(gen);
610            } else {
611                self.shared.sync_until(gen)?;
612            }
613        }
614        Ok(())
615    }
616
617    /// Push buffered records through to the OS file (no fsync) and register
618    /// the resulting dirty generation. Returns `Ok(None)` when there was
619    /// nothing pending or the WAL is `Off`.
620    fn flush_to_os(&mut self) -> io::Result<Option<u64>> {
621        let batch = self.pending;
622        if batch == 0 {
623            return Ok(None);
624        }
625        // Borrow the writer only for the I/O, then drop it before touching the
626        // generation counters (which borrow `self`).
627        let new_len = {
628            let writer = self
629                .writer
630                .as_mut()
631                .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
632            writer.flush()?;
633            writer.get_ref().metadata()?.len()
634        };
635        self.synced_len = new_len;
636        self.pending = 0;
637        if matches!(self.sync_mode, WalSyncMode::Off) {
638            return Ok(None);
639        }
640        // Registered only after the bytes are OS-visible, so any fsync issued
641        // from here on covers this generation.
642        let gen = self.shared.dirty_gen.fetch_add(1, Ordering::Release) + 1;
643        debug!(records = batch, "wal group commit");
644        Ok(Some(gen))
645    }
646
647    /// True when records have been appended to the in-memory WAL buffer
648    /// since the last durable flush.
649    #[inline]
650    pub fn has_pending(&self) -> bool {
651        self.pending > 0
652    }
653
654    /// Flush pending WAL bytes, then return the durable file length. Used as
655    /// an explicit-transaction rollback boundary.
656    pub fn synced_len(&mut self) -> io::Result<u64> {
657        self.flush()?;
658        Ok(self.synced_len)
659    }
660
661    /// Discard buffered (not-yet-flushed) WAL bytes and truncate the durable
662    /// log back to `len`. This is intentionally not implemented by dropping
663    /// the existing BufWriter: BufWriter's Drop attempts to flush buffered
664    /// bytes, which would resurrect rolled-back records.
665    pub fn discard_and_truncate_to(&mut self, len: u64) -> io::Result<()> {
666        if matches!(self.sync_mode, WalSyncMode::Off) {
667            self.pending = 0;
668            self.synced_len = len;
669            return Ok(());
670        }
671
672        if let Some(writer) = self.writer.take() {
673            let (_file, _buffer_result) = writer.into_parts();
674        }
675
676        let mut file = OpenOptions::new()
677            .create(true)
678            .read(true)
679            .append(true)
680            .open(&self.path)?;
681        file.set_len(len)?;
682        file.seek(SeekFrom::End(0))?;
683        file.sync_data()?;
684        let sync_fd = file.try_clone()?;
685        self.writer = Some(BufWriter::new(file));
686        // Everything that survived the truncation was just `sync_data`ed
687        // above, and everything past `len` is intentionally discarded; settle
688        // all registered generations and drop any deferred claim.
689        self.deferred_gen = None;
690        self.shared.replace_file(Some(sync_fd));
691        self.pending = 0;
692        self.synced_len = len;
693        Ok(())
694    }
695
696    /// Read all valid records from the WAL file.
697    pub fn read_all(&self) -> io::Result<Vec<WalRecord>> {
698        self.read_through_len(u64::MAX)
699    }
700
701    /// Read valid records up to a byte length boundary in the WAL file.
702    pub fn read_through_len(&self, max_len: u64) -> io::Result<Vec<WalRecord>> {
703        let mut file = File::open(&self.path)?;
704        let file_len = file.metadata()?.len().min(max_len);
705        let mut file_for_header = File::open(&self.path)?;
706        let mut pos = wal_records_start(&mut file_for_header)?;
707        let mut records = Vec::new();
708
709        while pos + WAL_HEADER_SIZE as u64 <= file_len {
710            file.seek(SeekFrom::Start(pos))?;
711
712            let mut header = [0u8; WAL_HEADER_SIZE];
713            if file.read_exact(&mut header).is_err() {
714                break;
715            }
716
717            // These slice-to-array conversions are infallible (fixed-size
718            // sub-slices of a 17-byte array) but we avoid `unwrap` to
719            // satisfy the project-wide zero-panic policy.
720            let total_len_bytes: [u8; 4] = match header[0..4].try_into() {
721                Ok(b) => b,
722                Err(_) => break,
723            };
724            let total_len = u32::from_le_bytes(total_len_bytes) as usize;
725            let stored_crc_bytes: [u8; 4] = match header[4..8].try_into() {
726                Ok(b) => b,
727                Err(_) => break,
728            };
729            let stored_crc = u32::from_le_bytes(stored_crc_bytes);
730            let tx_id_bytes: [u8; 8] = match header[8..16].try_into() {
731                Ok(b) => b,
732                Err(_) => break,
733            };
734            let tx_id = u64::from_le_bytes(tx_id_bytes);
735            let record_type = match WalRecordType::from_u8(header[16]) {
736                Some(rt) => rt,
737                None => break,
738            };
739            let lsn_bytes: [u8; 8] = match header[17..25].try_into() {
740                Ok(b) => b,
741                Err(_) => break,
742            };
743            let lsn = u64::from_le_bytes(lsn_bytes);
744
745            // TASK-11: Verify the record fits within the file before
746            // allocating. Catches truncated writes without any allocation.
747            if pos + total_len as u64 > file_len {
748                break; // Record extends beyond file — truncated write
749            }
750
751            // TASK-09: Use checked_sub to prevent integer underflow when
752            // a corrupted WAL has total_len < WAL_HEADER_SIZE.
753            let data_len = match total_len.checked_sub(WAL_HEADER_SIZE) {
754                Some(len) => len,
755                None => break, // Corrupted record — stop replay
756            };
757
758            // TASK-10: Cap allocation size before reading data. A crafted
759            // WAL claiming a huge total_len would otherwise allocate
760            // gigabytes before the CRC check rejects the record.
761            if data_len > MAX_WAL_RECORD_SIZE {
762                break; // Unreasonably large record — treat as corruption
763            }
764
765            let mut data = vec![0u8; data_len];
766            if data_len > 0 {
767                file.read_exact(&mut data)?;
768            }
769
770            // Verify CRC (includes lsn in the hash input)
771            let mut crc_input = Vec::with_capacity(17 + data.len());
772            crc_input.extend_from_slice(&tx_id.to_le_bytes());
773            crc_input.push(record_type as u8);
774            crc_input.extend_from_slice(&lsn.to_le_bytes());
775            crc_input.extend_from_slice(&data);
776            let computed_crc = crc32fast::hash(&crc_input);
777
778            if computed_crc != stored_crc {
779                break; // Corrupted record — stop here
780            }
781
782            records.push(WalRecord {
783                tx_id,
784                record_type,
785                lsn,
786                data,
787            });
788            pos += total_len as u64;
789        }
790
791        Ok(records)
792    }
793
794    /// Truncate the WAL (after checkpoint).
795    pub fn truncate(&mut self) -> io::Result<()> {
796        // Settle any deferred durability claim before destroying the records
797        // it covers: this keeps the "WAL records are durable before truncate"
798        // ordering airtight even if a caller checkpoints while deferral is
799        // active.
800        if let Some(gen) = self.deferred_gen.take() {
801            self.shared.sync_until(gen)?;
802        }
803        let mut file = OpenOptions::new()
804            .write(true)
805            .read(true)
806            .truncate(true)
807            .open(&self.path)?;
808        write_wal_file_header(&mut file)?;
809        let sync_fd = file.try_clone()?;
810        self.writer = Some(BufWriter::new(file));
811        // The old records are gone; settle their generations and swap the
812        // fsync fd so outstanding tickets can never block on them.
813        self.shared.replace_file(Some(sync_fd));
814        self.records_start = WAL_FILE_HEADER_SIZE;
815        self.pending = 0;
816        self.synced_len = WAL_FILE_HEADER_SIZE;
817        Ok(())
818    }
819
820    /// Discard records appended since the last successful [`Self::flush`].
821    ///
822    /// This is intentionally different from `flush`: it must not flush the
823    /// current `BufWriter`, because rollback uses it to abandon uncommitted
824    /// transaction records. `BufWriter::into_parts` lets us drop the buffered
825    /// bytes without writing them, then we truncate any large records that
826    /// had already spilled through to the file back to the last synced
827    /// boundary.
828    pub fn discard_pending(&mut self) -> io::Result<()> {
829        if matches!(self.sync_mode, WalSyncMode::Off) {
830            self.pending = 0;
831            return Ok(());
832        }
833
834        if let Some(writer) = self.writer.take() {
835            let (_file, _buffer) = writer.into_parts();
836        }
837
838        let file = OpenOptions::new()
839            .read(true)
840            .append(true)
841            .create(true)
842            .truncate(false)
843            .open(&self.path)?;
844        file.set_len(self.synced_len)?;
845        file.sync_data()?;
846        let sync_fd = file.try_clone()?;
847        self.writer = Some(BufWriter::new(file));
848        // The surviving prefix was just `sync_data`ed; settle all registered
849        // generations and drop any deferred claim over discarded bytes.
850        self.deferred_gen = None;
851        self.shared.replace_file(Some(sync_fd));
852        self.pending = 0;
853        self.synced_len = self.records_start;
854        Ok(())
855    }
856}
857
858impl Drop for Wal {
859    fn drop(&mut self) {
860        // Clean shutdown must be durable regardless of mode: push any buffered
861        // bytes to the OS and fsync, so a Normal-mode commit that hasn't yet
862        // hit the background flusher's interval is still durable on a graceful
863        // exit. (A process *crash* skips this — Normal's bounded-loss contract
864        // only applies to OS-crash / power-loss, which this cannot help.)
865        if !matches!(self.sync_mode, WalSyncMode::Off) {
866            if let Some(writer) = self.writer.as_mut() {
867                let _ = writer.flush();
868                let _ = writer.get_ref().sync_data();
869            }
870        }
871        self.stop_flusher();
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    fn temp_wal(name: &str) -> (Wal, PathBuf) {
880        let path = std::env::temp_dir().join(format!("powdb_wal_{name}_{}", std::process::id()));
881        let wal = Wal::create(&path, 4).unwrap();
882        (wal, path)
883    }
884
885    #[test]
886    fn test_append_and_flush() {
887        let (mut wal, path) = temp_wal("basic");
888        wal.append(1, WalRecordType::Insert, b"row data 1").unwrap();
889        wal.append(1, WalRecordType::Insert, b"row data 2").unwrap();
890        wal.flush().unwrap();
891
892        let records = wal.read_all().unwrap();
893        assert_eq!(records.len(), 2);
894        assert_eq!(records[0].tx_id, 1);
895        assert_eq!(records[0].data, b"row data 1");
896        assert_eq!(records[1].data, b"row data 2");
897        drop(wal);
898        std::fs::remove_file(&path).ok();
899    }
900
901    #[test]
902    fn test_group_commit_auto_flush() {
903        let (mut wal, path) = temp_wal("group");
904        // Batch size is 4 — after 4 appends, should auto-flush
905        for i in 0..4 {
906            wal.append(1, WalRecordType::Insert, format!("row {i}").as_bytes())
907                .unwrap();
908        }
909        // Should have flushed automatically
910        let records = wal.read_all().unwrap();
911        assert_eq!(records.len(), 4);
912        drop(wal);
913        std::fs::remove_file(&path).ok();
914    }
915
916    #[test]
917    fn test_normal_mode_persists_records_across_reopen() {
918        // NORMAL durability: commits are acked after the buffered bytes reach
919        // the OS (BufWriter::flush) without a per-commit fsync; a background
920        // flusher + clean shutdown make them durable. Data must survive a
921        // clean close + reopen.
922        let path =
923            std::env::temp_dir().join(format!("powdb_wal_normal_reopen_{}", std::process::id()));
924        std::fs::remove_file(&path).ok();
925        {
926            let mut wal = Wal::create(&path, 4).unwrap();
927            wal.set_sync_mode(WalSyncMode::Normal);
928            assert_eq!(wal.sync_mode(), WalSyncMode::Normal);
929            wal.append(1, WalRecordType::Insert, b"n1").unwrap();
930            wal.append(1, WalRecordType::Insert, b"n2").unwrap();
931            wal.flush().unwrap();
932        } // drop: stop flusher + final fsync
933        let wal = Wal::open(&path, 4).unwrap();
934        let records = wal.read_all().unwrap();
935        assert_eq!(records.len(), 2);
936        assert_eq!(records[0].data, b"n1");
937        assert_eq!(records[1].data, b"n2");
938        std::fs::remove_file(&path).ok();
939    }
940
941    #[test]
942    fn test_normal_mode_background_flusher_syncs_off_commit_path() {
943        // In NORMAL mode flush() must NOT fsync inline; the background flusher
944        // fsyncs on its interval and advances the synced generation. Proves the
945        // fsync is off the commit path (the latency win) yet still happens.
946        let path = std::env::temp_dir().join(format!("powdb_wal_normal_bg_{}", std::process::id()));
947        std::fs::remove_file(&path).ok();
948        let mut wal = Wal::create(&path, 1000).unwrap(); // large batch: no auto-flush
949        wal.set_sync_mode(WalSyncMode::Normal);
950        wal.append(1, WalRecordType::Insert, b"bg1").unwrap();
951        wal.flush().unwrap(); // buffers to OS + marks dirty; no inline fsync
952                              // The background flusher should fsync within its (~10 ms) interval.
953        std::thread::sleep(std::time::Duration::from_millis(80));
954        assert!(
955            wal.synced_generation() >= 1,
956            "background flusher did not sync (synced_generation = {})",
957            wal.synced_generation()
958        );
959        std::fs::remove_file(&path).ok();
960    }
961
962    #[test]
963    fn test_lone_committer_fsyncs_immediately_per_commit() {
964        // Group commit must never delay a lone committer: with no other
965        // waiters, every flush fsyncs immediately — exactly one fsync per
966        // commit, no timers, no batching window.
967        let (mut wal, path) = temp_wal("lone_committer");
968        let base = wal.fsync_count();
969        for i in 0..10u32 {
970            wal.append(1, WalRecordType::Insert, format!("c{i}").as_bytes())
971                .unwrap();
972            wal.flush().unwrap();
973        }
974        assert_eq!(
975            wal.fsync_count() - base,
976            10,
977            "a lone sequential committer must fsync exactly once per commit"
978        );
979        drop(wal);
980        std::fs::remove_file(&path).ok();
981    }
982
983    #[test]
984    fn test_deferred_tickets_coalesce_one_fsync_for_two_commits() {
985        // Two commits registered before either waits: the first wait's fsync
986        // covers both generations, the second wait returns without an fsync.
987        let path = std::env::temp_dir().join(format!(
988            "powdb_wal_gc_coalesce2_{}_{}",
989            std::process::id(),
990            std::time::SystemTime::now()
991                .duration_since(std::time::UNIX_EPOCH)
992                .unwrap()
993                .as_nanos()
994        ));
995        let mut wal = Wal::create(&path, 1024).unwrap();
996        wal.set_defer_sync(true);
997
998        wal.append(1, WalRecordType::Insert, b"a").unwrap();
999        wal.flush().unwrap();
1000        let t1 = wal.take_durability_ticket().expect("ticket for commit 1");
1001
1002        wal.append(2, WalRecordType::Insert, b"b").unwrap();
1003        wal.flush().unwrap();
1004        let t2 = wal.take_durability_ticket().expect("ticket for commit 2");
1005
1006        let base = wal.fsync_count();
1007        t2.wait().unwrap(); // leader — its fsync covers generation 1 too
1008        t1.wait().unwrap(); // already covered, no second fsync
1009        assert_eq!(
1010            wal.fsync_count() - base,
1011            1,
1012            "one fsync must cover both queued commits"
1013        );
1014        assert_eq!(wal.read_all().unwrap().len(), 2);
1015        drop(wal);
1016        std::fs::remove_file(&path).ok();
1017    }
1018
1019    #[test]
1020    fn test_concurrent_committers_share_one_fsync() {
1021        // Classic group commit: N committers append + register (serialized by
1022        // the writer lock), all reach the barrier before any of them waits,
1023        // then the first waiter's fsync covers every registered generation.
1024        use std::sync::Barrier;
1025
1026        let path = std::env::temp_dir().join(format!(
1027            "powdb_wal_gc_concurrent_{}_{}",
1028            std::process::id(),
1029            std::time::SystemTime::now()
1030                .duration_since(std::time::UNIX_EPOCH)
1031                .unwrap()
1032                .as_nanos()
1033        ));
1034        let wal = Arc::new(Mutex::new(Wal::create(&path, 1024).unwrap()));
1035        wal.lock().unwrap().set_defer_sync(true);
1036
1037        let n = 8;
1038        let barrier = Arc::new(Barrier::new(n));
1039        let mut handles = Vec::new();
1040        for t in 0..n {
1041            let wal = Arc::clone(&wal);
1042            let barrier = Arc::clone(&barrier);
1043            handles.push(std::thread::spawn(move || {
1044                let ticket = {
1045                    let mut w = wal.lock().unwrap();
1046                    w.append(t as u64 + 1, WalRecordType::Insert, b"row")
1047                        .unwrap();
1048                    w.flush().unwrap();
1049                    w.take_durability_ticket().expect("deferred ticket")
1050                };
1051                barrier.wait();
1052                ticket.wait().unwrap();
1053            }));
1054        }
1055        for h in handles {
1056            h.join().unwrap();
1057        }
1058
1059        let w = wal.lock().unwrap();
1060        assert_eq!(w.read_all().unwrap().len(), n);
1061        assert_eq!(
1062            w.fsync_count(),
1063            1,
1064            "all {n} overlapping commits must be covered by a single fsync"
1065        );
1066        drop(w);
1067        drop(wal);
1068        std::fs::remove_file(&path).ok();
1069    }
1070
1071    #[test]
1072    fn test_crc_integrity() {
1073        let (mut wal, path) = temp_wal("crc");
1074        wal.append(1, WalRecordType::Insert, b"important data")
1075            .unwrap();
1076        wal.flush().unwrap();
1077
1078        let records = wal.read_all().unwrap();
1079        assert_eq!(records.len(), 1);
1080        // CRC was validated during read_all — if we get here, integrity is good
1081        drop(wal);
1082        std::fs::remove_file(&path).ok();
1083    }
1084
1085    #[test]
1086    fn test_multiple_transactions() {
1087        let (mut wal, path) = temp_wal("multi_tx");
1088        wal.append(1, WalRecordType::Insert, b"tx1 op1").unwrap();
1089        wal.append(2, WalRecordType::Insert, b"tx2 op1").unwrap();
1090        wal.append(1, WalRecordType::Commit, b"").unwrap();
1091        wal.append(2, WalRecordType::Commit, b"").unwrap();
1092        wal.flush().unwrap();
1093
1094        let records = wal.read_all().unwrap();
1095        assert_eq!(records.len(), 4);
1096        assert_eq!(records[0].tx_id, 1);
1097        assert_eq!(records[2].tx_id, 1);
1098        assert_eq!(records[2].record_type, WalRecordType::Commit);
1099        drop(wal);
1100        std::fs::remove_file(&path).ok();
1101    }
1102
1103    #[test]
1104    fn test_truncate() {
1105        let (mut wal, path) = temp_wal("trunc");
1106        for i in 0..8 {
1107            wal.append(1, WalRecordType::Insert, format!("data {i}").as_bytes())
1108                .unwrap();
1109        }
1110        wal.flush().unwrap();
1111        assert_eq!(wal.read_all().unwrap().len(), 8);
1112
1113        wal.truncate().unwrap();
1114        assert_eq!(wal.read_all().unwrap().len(), 0);
1115        drop(wal);
1116        std::fs::remove_file(&path).ok();
1117    }
1118
1119    #[test]
1120    fn test_reopen_wal() {
1121        let path = std::env::temp_dir().join(format!("powdb_wal_reopen_{}", std::process::id()));
1122        {
1123            let mut wal = Wal::create(&path, 128).unwrap();
1124            wal.append(1, WalRecordType::Insert, b"persistent").unwrap();
1125            wal.append(1, WalRecordType::Commit, b"").unwrap();
1126            wal.flush().unwrap();
1127        }
1128        {
1129            let wal = Wal::open(&path, 128).unwrap();
1130            let records = wal.read_all().unwrap();
1131            assert_eq!(records.len(), 2);
1132            assert_eq!(records[0].data, b"persistent");
1133            assert_eq!(records[1].record_type, WalRecordType::Commit);
1134        }
1135        std::fs::remove_file(&path).ok();
1136    }
1137}