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/// Process-wide WAL fsync accounting.
11///
12/// PowDB is a single-writer, fsync-bound engine: how long an fsync takes, and
13/// how many are issued, is the single most useful signal an operator has for
14/// write-path health. These are plain relaxed atomics updated at the two
15/// places that call `sync_data` (the group-commit leader and the Normal-mode
16/// background flusher), so a reader (the server's `/metrics` endpoint) can
17/// sample them without taking any lock, including the engine lock.
18///
19/// Process-global rather than per-`Wal` because a PowDB server process serves
20/// exactly one data directory, and the metrics endpoint must not reach through
21/// the engine `RwLock` to read them.
22static FSYNC_TOTAL: AtomicU64 = AtomicU64::new(0);
23static FSYNC_NANOS_TOTAL: AtomicU64 = AtomicU64::new(0);
24static FSYNC_FAILURES_TOTAL: AtomicU64 = AtomicU64::new(0);
25
26/// Snapshot of the process-wide WAL fsync counters.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct WalFsyncStats {
29    /// Successful `sync_data` calls issued against a WAL file.
30    pub count: u64,
31    /// Total nanoseconds spent inside those calls.
32    pub nanos: u64,
33    /// `sync_data` calls that returned an error.
34    pub failures: u64,
35}
36
37/// Read the process-wide WAL fsync counters. Lock-free.
38pub fn wal_fsync_stats() -> WalFsyncStats {
39    WalFsyncStats {
40        count: FSYNC_TOTAL.load(Ordering::Relaxed),
41        nanos: FSYNC_NANOS_TOTAL.load(Ordering::Relaxed),
42        failures: FSYNC_FAILURES_TOTAL.load(Ordering::Relaxed),
43    }
44}
45
46/// Error every durability claim carries once the WAL is poisoned. Kept as
47/// a constant so tests can assert on the exact contract wording.
48const WAL_POISONED_MSG: &str = "WAL poisoned by an earlier fsync failure; commits can no \
49     longer be made durable (the OS may have dropped the unsynced pages). Restart the \
50     process to recover from the on-disk log";
51
52/// Addresses of the [`WalSyncShared`]s whose next fsync must fail
53/// (each entry self-disarms when it fires). There is no portable way to make
54/// a real `fdatasync` return EIO/ENOSPC on demand, and the post-failure
55/// behavior — poison, never retry — is exactly what the fsyncgate tests have
56/// to observe. Scoped per WAL so concurrent tests' fsyncs cannot steal each
57/// other's injection (a single shared slot let a second test's arm clobber
58/// the first's), yet global (not thread-local) so the Normal-mode background
59/// flusher thread can trip it too. [`Wal::drop`] removes its own entry, so a
60/// test that arms but never fsyncs cannot leak an arm onto whatever WAL the
61/// allocator later places at the same address.
62#[cfg(test)]
63static WAL_FSYNC_FAILPOINTS: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
64
65/// Run `sync_data` on `file`, recording its duration in the process-wide
66/// counters. Every WAL fsync goes through here so the accounting cannot drift
67/// from the actual calls.
68fn timed_sync_data(file: &File) -> io::Result<()> {
69    let started = std::time::Instant::now();
70    let result = file.sync_data();
71    match &result {
72        Ok(()) => {
73            FSYNC_TOTAL.fetch_add(1, Ordering::Relaxed);
74            FSYNC_NANOS_TOTAL.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
75        }
76        Err(_) => {
77            FSYNC_FAILURES_TOTAL.fetch_add(1, Ordering::Relaxed);
78        }
79    }
80    result
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[repr(u8)]
85pub enum WalRecordType {
86    Insert = 1,
87    Update = 2,
88    Delete = 3,
89    Commit = 4,
90    Rollback = 5,
91    DdlCreateTable = 6,
92    DdlDropTable = 7,
93    DdlAddColumn = 8,
94    DdlDropColumn = 9,
95    Begin = 10,
96    /// Physical log of one overflow-chain chunk (door D4). Payload:
97    /// `page_id u32 | next_page u32 | chunk_len u16 | chunk bytes`. Replayed
98    /// by page id under the per-page LSN skip, so it is idempotent.
99    OverflowWrite = 11,
100    /// Batch of overflow pages returned to the free list (door D4). Payload:
101    /// `count u32 | page_id u32 x count`. Idempotent on replay.
102    OverflowFree = 12,
103}
104
105impl WalRecordType {
106    pub fn from_u8(v: u8) -> Option<Self> {
107        match v {
108            1 => Some(WalRecordType::Insert),
109            2 => Some(WalRecordType::Update),
110            3 => Some(WalRecordType::Delete),
111            4 => Some(WalRecordType::Commit),
112            5 => Some(WalRecordType::Rollback),
113            6 => Some(WalRecordType::DdlCreateTable),
114            7 => Some(WalRecordType::DdlDropTable),
115            8 => Some(WalRecordType::DdlAddColumn),
116            9 => Some(WalRecordType::DdlDropColumn),
117            10 => Some(WalRecordType::Begin),
118            11 => Some(WalRecordType::OverflowWrite),
119            12 => Some(WalRecordType::OverflowFree),
120            _ => None,
121        }
122    }
123}
124
125pub const WAL_MAGIC: &[u8; 4] = b"PWAL";
126pub const WAL_FORMAT_VERSION: u16 = 1;
127const WAL_FILE_HEADER_SIZE: u64 = 8;
128
129/// WAL record header: len(4) + crc32(4) + tx_id(8) + type(1) + lsn(8) = 25 bytes
130const WAL_HEADER_SIZE: usize = 25;
131
132fn write_wal_file_header(file: &mut File) -> io::Result<()> {
133    file.seek(SeekFrom::Start(0))?;
134    file.write_all(WAL_MAGIC)?;
135    file.write_all(&WAL_FORMAT_VERSION.to_le_bytes())?;
136    file.write_all(&0u16.to_le_bytes())?;
137    file.seek(SeekFrom::End(0))?;
138    Ok(())
139}
140
141fn wal_records_start(file: &mut File) -> io::Result<u64> {
142    let len = file.metadata()?.len();
143    if len == 0 {
144        write_wal_file_header(file)?;
145        return Ok(WAL_FILE_HEADER_SIZE);
146    }
147    if len >= WAL_FILE_HEADER_SIZE {
148        file.seek(SeekFrom::Start(0))?;
149        let mut hdr = [0u8; WAL_FILE_HEADER_SIZE as usize];
150        file.read_exact(&mut hdr)?;
151        if &hdr[0..4] == WAL_MAGIC {
152            let version = u16::from_le_bytes(hdr[4..6].try_into().expect("2-byte WAL version"));
153            if version != WAL_FORMAT_VERSION {
154                return Err(io::Error::new(
155                    io::ErrorKind::InvalidData,
156                    format!("unsupported WAL format version: {version}"),
157                ));
158            }
159            return Ok(WAL_FILE_HEADER_SIZE);
160        }
161    }
162    // Legacy 0.4.x WAL: no file header; records start at byte 0.
163    Ok(0)
164}
165
166/// Maximum allowed size for a single WAL record's data payload.
167/// Records claiming more than 256 MB are treated as corruption and
168/// stop replay — this prevents a crafted WAL from causing a
169/// multi-gigabyte allocation before the CRC check can reject it.
170const MAX_WAL_RECORD_SIZE: usize = 256 * 1024 * 1024;
171
172#[derive(Debug)]
173pub struct WalRecord {
174    pub tx_id: u64,
175    pub record_type: WalRecordType,
176    /// Monotonic log sequence number assigned at append time. Used by
177    /// the page-level idempotent replay: if a page's on-disk LSN is
178    /// `>=` this record's LSN, the record has already been applied and
179    /// replay skips it.
180    pub lsn: u64,
181    pub data: Vec<u8>,
182}
183
184/// Durability mode for the WAL — analogous to SQLite's `PRAGMA synchronous`
185/// combined with `journal_mode=OFF`.
186///
187/// * `Full` — every mutation appends a record and `flush()` calls
188///   `sync_data()` so the OS guarantees the bytes hit stable storage before
189///   the call returns. This is the default and the only safe choice when
190///   crash recovery must be perfect.
191///
192/// * `Off`  — every `append()` and `flush()` is a zero-work no-op. No CRC,
193///   no BufWriter, no fsync, no recovery. This matches SQLite's `:memory:`
194///   semantics and is the only way to compare apples-to-apples against
195///   in-memory engines in benches. Never use this in production — a crash
196///   loses every mutation since the last `Catalog::checkpoint()`.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum WalSyncMode {
199    #[default]
200    Full,
201    /// `Normal` — every commit buffers its record through to the OS
202    /// (`BufWriter::flush`, so the bytes are file-visible) and returns
203    /// WITHOUT an fsync; a background flusher fsyncs on a fixed interval
204    /// (`NORMAL_FSYNC_INTERVAL`). A *process* crash loses nothing (replay
205    /// reads the bytes already in the OS page cache); an *OS* crash / power
206    /// loss can lose only the unsynced tail (≤ one interval of writes). This
207    /// is SQLite `synchronous=NORMAL` / Postgres `synchronous_commit=off`
208    /// semantics: opt-in, bounded-loss, and ~15–40× faster single-row writes
209    /// because the fsync leaves the commit/lock path.
210    Normal,
211    Off,
212}
213
214/// How often the background flusher fsyncs in [`WalSyncMode::Normal`]. This is
215/// the upper bound on the crash-loss window (OS-crash / power-loss only).
216const NORMAL_FSYNC_INTERVAL: Duration = Duration::from_millis(10);
217
218/// Fsync-coordination state shared between the `Wal`, the Normal-mode
219/// background flusher, and any outstanding [`WalDurabilityTicket`]s.
220///
221/// This is the heart of Full-mode group commit: `dirty_gen` counts
222/// flush-to-OS generations, `synced_gen` tracks the highest generation an
223/// fsync has covered, and `sync_file` is both the fd fsyncs go through and
224/// the leader election — whoever holds the mutex fsyncs on behalf of every
225/// generation registered before the fsync started.
226#[derive(Debug)]
227struct WalSyncShared {
228    /// Monotonic counter bumped on every durable-intent flush-to-OS (non-Off).
229    /// A generation is only registered after its bytes reached the OS file
230    /// (`BufWriter::flush`), so an fsync issued afterwards always covers it.
231    dirty_gen: AtomicU64,
232    /// Highest `dirty_gen` value known to be fsync-durable. Advanced by
233    /// group-commit leaders and by the Normal background flusher.
234    synced_gen: AtomicU64,
235    /// Number of `sync_data` calls issued on the WAL file. Test/metrics hook:
236    /// group-commit coalescing shows up as fewer fsyncs than commits.
237    fsync_count: AtomicU64,
238    /// The fd used for fsyncs, doubling as the group-commit leader lock.
239    /// `None` only if cloning the writer's fd failed on (re)open.
240    sync_file: Mutex<Option<File>>,
241    /// Sticky fsyncgate latch. Once an fsync on this WAL has failed, the OS
242    /// may have dropped the dirty pages and marked them clean, so a RETRIED
243    /// fsync would report success without the bytes ever reaching stable
244    /// storage. Set on the first failure and never cleared: every later
245    /// durability claim fails fast with [`WAL_POISONED_MSG`] instead of
246    /// issuing another fsync. Recovery is a process restart (WAL replay
247    /// reads the log that actually made it to disk).
248    sync_poisoned: std::sync::atomic::AtomicBool,
249}
250
251impl WalSyncShared {
252    /// True (once) when the fsync failpoint is armed for THIS shared state.
253    #[cfg(test)]
254    fn take_fsync_failpoint(&self) -> bool {
255        let me = self as *const WalSyncShared as usize;
256        let mut armed = WAL_FSYNC_FAILPOINTS.lock().unwrap();
257        match armed.iter().position(|&addr| addr == me) {
258            Some(i) => {
259                armed.remove(i);
260                true
261            }
262            None => false,
263        }
264    }
265
266    /// The one fsync choke point for this WAL: [`timed_sync_data`] plus the
267    /// test-only failure injection.
268    fn fsync_file(&self, file: &File) -> io::Result<()> {
269        #[cfg(test)]
270        if self.take_fsync_failpoint() {
271            FSYNC_FAILURES_TOTAL.fetch_add(1, Ordering::Relaxed);
272            return Err(io::Error::other("injected WAL fsync failure"));
273        }
274        timed_sync_data(file)
275    }
276
277    fn new(sync_file: Option<File>) -> Self {
278        WalSyncShared {
279            dirty_gen: AtomicU64::new(0),
280            synced_gen: AtomicU64::new(0),
281            fsync_count: AtomicU64::new(0),
282            sync_file: Mutex::new(sync_file),
283            sync_poisoned: std::sync::atomic::AtomicBool::new(false),
284        }
285    }
286
287    /// Block until an fsync covering `gen` has completed (leader/follower
288    /// group commit). The first caller to take the lock fsyncs once for every
289    /// generation registered so far; callers queued behind it wake up already
290    /// covered and return without an fsync of their own. A lone caller finds
291    /// the lock free and fsyncs immediately — group commit never introduces a
292    /// wait for company.
293    fn sync_until(&self, gen: u64) -> io::Result<()> {
294        if self.synced_gen.load(Ordering::Acquire) >= gen {
295            return Ok(());
296        }
297        let guard = self
298            .sync_file
299            .lock()
300            .map_err(|_| io::Error::other("WAL sync lock poisoned"))?;
301        // A leader that ran while we were queued may already have covered us.
302        if self.synced_gen.load(Ordering::Acquire) >= gen {
303            return Ok(());
304        }
305        // Checked under the leader lock, after the covered fast paths: a
306        // generation an earlier fsync already covered IS durable and may
307        // truthfully return Ok even on a poisoned WAL.
308        if self.sync_poisoned.load(Ordering::Acquire) {
309            return Err(io::Error::other(WAL_POISONED_MSG));
310        }
311        let file = guard
312            .as_ref()
313            .ok_or_else(|| io::Error::other("WAL sync fd unavailable"))?;
314        // Snapshot BEFORE the fsync: every generation registered by now has
315        // its bytes in the OS file already, so this one fsync covers them all.
316        let cover = self.dirty_gen.load(Ordering::Acquire);
317        if let Err(e) = self.fsync_file(file) {
318            // fsyncgate: retrying after a failed fsync is unsound (the OS
319            // may have dropped the pages and would report the retry as
320            // success). Poison the WAL permanently and surface the failure.
321            self.sync_poisoned.store(true, Ordering::Release);
322            tracing::error!(
323                error = %e,
324                "WAL fsync failed; poisoning the WAL — no further commits \
325                 will be acknowledged until the process restarts"
326            );
327            return Err(e);
328        }
329        self.fsync_count.fetch_add(1, Ordering::Relaxed);
330        self.synced_gen.fetch_max(cover, Ordering::AcqRel);
331        Ok(())
332    }
333
334    /// Swap the fsync fd and mark every generation registered so far as
335    /// settled. Called when the WAL file is truncated or recreated: the bytes
336    /// those generations covered are gone from the log — either already
337    /// durable elsewhere (checkpoint flushed the heaps; the discard paths
338    /// `sync_data` the truncated file) or intentionally discarded by rollback
339    /// — so no ticket must ever block on them again.
340    fn replace_file(&self, file: Option<File>) {
341        // Take the leader lock so an in-flight fsync on the old fd finishes
342        // before the swap. Poisoning is impossible in practice (the critical
343        // section cannot panic) but recover anyway rather than propagate.
344        let mut guard = match self.sync_file.lock() {
345            Ok(g) => g,
346            Err(poisoned) => poisoned.into_inner(),
347        };
348        let d = self.dirty_gen.load(Ordering::Acquire);
349        self.synced_gen.fetch_max(d, Ordering::AcqRel);
350        *guard = file;
351    }
352}
353
354/// A claim on WAL durability handed out by a deferred Full-mode flush: the
355/// commit's records have reached the OS file but are not yet guaranteed on
356/// stable storage. [`Self::wait`] blocks until an fsync covering them has
357/// completed — the caller must not acknowledge the commit before `wait`
358/// returns `Ok(())`.
359///
360/// Tickets are cumulative: generations are registered in order, so waiting on
361/// a later ticket also makes every earlier generation durable. Waiting takes
362/// no `Wal` lock, which is what lets a committer release the engine's write
363/// lock first and other committers append while the fsync runs — the overlap
364/// that lets one fsync cover many commits.
365#[derive(Debug)]
366#[must_use = "a commit must not be acknowledged until wait() returns Ok"]
367pub struct WalDurabilityTicket {
368    gen: u64,
369    shared: Arc<WalSyncShared>,
370}
371
372impl WalDurabilityTicket {
373    /// Block until an fsync covering this ticket's WAL records has completed.
374    /// See [`WalSyncShared::sync_until`] for the leader/follower scheme.
375    pub fn wait(self) -> io::Result<()> {
376        self.shared.sync_until(self.gen)
377    }
378}
379
380pub struct Wal {
381    path: PathBuf,
382    writer: Option<BufWriter<File>>,
383    batch_size: usize,
384    pending: usize,
385    sync_mode: WalSyncMode,
386    /// Monotonic LSN counter. Starts at 1 (0 means "no WAL record has
387    /// ever touched this page") and increments by 1 on every `append`.
388    next_lsn: u64,
389    /// File length as of the last successful WAL sync/truncate/open.
390    ///
391    /// `BufWriter` may write large pending records through to the OS file
392    /// before [`Self::flush`] is called. Those bytes are file-visible but
393    /// not transaction-durable. Rollback truncates back to this boundary so
394    /// a same-process reopen cannot replay uncommitted records.
395    records_start: u64,
396    synced_len: u64,
397    /// Group-commit fsync coordination (see [`WalSyncShared`]).
398    shared: Arc<WalSyncShared>,
399    /// When `true`, a Full-mode `flush()` registers the generation it needs
400    /// durable instead of fsyncing inline; [`Self::take_durability_ticket`]
401    /// hands the claim to the caller, who must wait on it before
402    /// acknowledging the commit. See [`Self::set_defer_sync`].
403    defer_sync: bool,
404    /// Highest generation registered by deferred flushes since the last
405    /// `take_durability_ticket`. Cumulative — a later generation covers all
406    /// earlier ones, so overwriting never loses coverage.
407    deferred_gen: Option<u64>,
408    /// Background fsync thread; present only while in `Normal` mode.
409    flusher: Option<Flusher>,
410}
411
412/// Background fsync worker for [`WalSyncMode::Normal`]. Owns a cloned WAL file
413/// descriptor and fsyncs it on [`NORMAL_FSYNC_INTERVAL`] whenever new bytes
414/// have been buffered, keeping the fsync off the commit/lock path. fsync on the
415/// cloned fd flushes the same underlying file (inode) the writer appends to.
416struct Flusher {
417    handle: Option<JoinHandle<()>>,
418    /// `(stop, condvar)` — set `stop=true` + notify to wake the thread early.
419    ctl: Arc<(Mutex<bool>, Condvar)>,
420}
421
422impl Flusher {
423    fn spawn(file: File, shared: Arc<WalSyncShared>, interval: Duration) -> Flusher {
424        let ctl: Arc<(Mutex<bool>, Condvar)> = Arc::new((Mutex::new(false), Condvar::new()));
425        let ctl_thread = Arc::clone(&ctl);
426        let handle = std::thread::Builder::new()
427            .name("powdb-wal-flusher".into())
428            .spawn(move || {
429                let (lock, cvar) = &*ctl_thread;
430                loop {
431                    let stopping = {
432                        let stop = lock.lock().expect("wal flusher lock");
433                        if *stop {
434                            true
435                        } else {
436                            let (stop, _timeout) =
437                                cvar.wait_timeout(stop, interval).expect("wal flusher wait");
438                            *stop
439                        }
440                    };
441                    // A concurrent Full-mode leader may have poisoned the
442                    // WAL between ticks; a poisoned WAL has nothing left for
443                    // a flusher to do.
444                    if shared.sync_poisoned.load(Ordering::Acquire) {
445                        break;
446                    }
447                    // fsync if the writer has buffered new bytes since last sync.
448                    let d = shared.dirty_gen.load(Ordering::Acquire);
449                    if d > shared.synced_gen.load(Ordering::Acquire) {
450                        match shared.fsync_file(&file) {
451                            Ok(()) => {
452                                shared.fsync_count.fetch_add(1, Ordering::Relaxed);
453                                // fetch_max, not store: a Full-mode group
454                                // commit may have advanced past `d` between
455                                // the load and the fsync (mode switches).
456                                shared.synced_gen.fetch_max(d, Ordering::AcqRel);
457                            }
458                            // In Normal mode this background fsync is the ONLY
459                            // durability point, and retrying a failed fsync is
460                            // unsound (fsyncgate: the OS may have dropped the
461                            // pages and would report the retry as success).
462                            // Poison the WAL — later flushes fail fast with
463                            // WAL_POISONED_MSG — and stop flushing.
464                            Err(e) => {
465                                shared.sync_poisoned.store(true, Ordering::Release);
466                                tracing::error!(
467                                    error = %e,
468                                    "WAL background fsync failed; poisoning the WAL and \
469                                     stopping the flusher — no further commits will be \
470                                     acknowledged until the process restarts"
471                                );
472                                break;
473                            }
474                        }
475                    }
476                    if stopping {
477                        break;
478                    }
479                }
480            })
481            .expect("spawn wal flusher thread");
482        Flusher {
483            handle: Some(handle),
484            ctl,
485        }
486    }
487
488    fn stop(&mut self) {
489        {
490            let (lock, cvar) = &*self.ctl;
491            let mut stop = lock.lock().expect("wal flusher lock");
492            *stop = true;
493            cvar.notify_all();
494        }
495        if let Some(h) = self.handle.take() {
496            let _ = h.join();
497        }
498    }
499}
500
501impl Drop for Flusher {
502    fn drop(&mut self) {
503        self.stop();
504    }
505}
506
507impl Wal {
508    pub fn create(path: &Path, batch_size: usize) -> io::Result<Self> {
509        let mut file = OpenOptions::new()
510            .create(true)
511            .write(true)
512            .read(true)
513            .truncate(true)
514            .open(path)?;
515        write_wal_file_header(&mut file)?;
516        let sync_fd = file.try_clone()?;
517        Ok(Wal {
518            path: path.to_path_buf(),
519            writer: Some(BufWriter::new(file)),
520            batch_size,
521            pending: 0,
522            sync_mode: WalSyncMode::default(),
523            next_lsn: 1,
524            records_start: WAL_FILE_HEADER_SIZE,
525            synced_len: WAL_FILE_HEADER_SIZE,
526            shared: Arc::new(WalSyncShared::new(Some(sync_fd))),
527            defer_sync: false,
528            deferred_gen: None,
529            flusher: None,
530        })
531    }
532
533    pub fn open(path: &Path, batch_size: usize) -> io::Result<Self> {
534        let mut file = OpenOptions::new()
535            .create(true)
536            .read(true)
537            .append(true)
538            .open(path)?;
539        let records_start = wal_records_start(&mut file)?;
540        let synced_len = file.metadata()?.len();
541        let sync_fd = file.try_clone()?;
542        Ok(Wal {
543            path: path.to_path_buf(),
544            writer: Some(BufWriter::new(file)),
545            batch_size,
546            pending: 0,
547            sync_mode: WalSyncMode::default(),
548            next_lsn: 1,
549            records_start,
550            synced_len,
551            shared: Arc::new(WalSyncShared::new(Some(sync_fd))),
552            defer_sync: false,
553            deferred_gen: None,
554            flusher: None,
555        })
556    }
557
558    /// Toggle the durability mode. See [`WalSyncMode`] for the contract.
559    /// Starts the background flusher when entering `Normal`, and stops it when
560    /// leaving `Normal`. The fsync-behavior change takes effect on the next
561    /// `flush()`.
562    pub fn set_sync_mode(&mut self, mode: WalSyncMode) {
563        if mode == self.sync_mode {
564            return;
565        }
566        self.sync_mode = mode;
567        match mode {
568            WalSyncMode::Normal => self.start_flusher(),
569            WalSyncMode::Full | WalSyncMode::Off => self.stop_flusher(),
570        }
571    }
572
573    /// Spawn the Normal-mode background flusher if not already running. The
574    /// flusher fsyncs a cloned WAL fd, so it never contends on the writer.
575    fn start_flusher(&mut self) {
576        if self.flusher.is_some() {
577            return;
578        }
579        if let Some(writer) = self.writer.as_ref() {
580            if let Ok(file) = writer.get_ref().try_clone() {
581                self.flusher = Some(Flusher::spawn(
582                    file,
583                    Arc::clone(&self.shared),
584                    NORMAL_FSYNC_INTERVAL,
585                ));
586            }
587        }
588    }
589
590    /// Stop the background flusher (final fsync + join), if running.
591    fn stop_flusher(&mut self) {
592        if let Some(mut f) = self.flusher.take() {
593            f.stop();
594        }
595    }
596
597    /// Arm the fsync failpoint for this WAL: its next fsync — from any
598    /// thread, including the Normal-mode background flusher — fails with an
599    /// injected error. Self-disarming.
600    #[cfg(test)]
601    fn arm_fsync_failpoint(&self) {
602        let me = Arc::as_ptr(&self.shared) as usize;
603        let mut armed = WAL_FSYNC_FAILPOINTS.lock().unwrap();
604        if !armed.contains(&me) {
605            armed.push(me);
606        }
607    }
608
609    /// Remove this WAL's armed failpoint, if any (see the static's docs).
610    #[cfg(test)]
611    fn disarm_fsync_failpoint(&self) {
612        let me = Arc::as_ptr(&self.shared) as usize;
613        WAL_FSYNC_FAILPOINTS.lock().unwrap().retain(|&a| a != me);
614    }
615
616    /// True once any fsync on this WAL has failed. The WAL is then
617    /// permanently poisoned — every later durability claim fails fast with
618    /// the same error instead of retrying the fsync (fsyncgate: a retried
619    /// fsync can report success over pages the OS already dropped). Restart
620    /// the process to recover from the on-disk log.
621    pub fn is_sync_poisoned(&self) -> bool {
622        self.shared
623            .sync_poisoned
624            .load(std::sync::atomic::Ordering::Acquire)
625    }
626
627    /// The highest dirty generation known to be fsync-durable. Advances on
628    /// every Full commit and on each Normal background-flusher cycle. Exposed
629    /// for tests and (future) metrics.
630    pub fn synced_generation(&self) -> u64 {
631        self.shared.synced_gen.load(Ordering::Acquire)
632    }
633
634    /// Number of fsyncs issued against the WAL file (group-commit leaders,
635    /// inline Full-mode flushes, and the Normal background flusher). Exposed
636    /// for tests and (future) metrics: group-commit coalescing shows up as
637    /// fewer fsyncs than commits.
638    pub fn fsync_count(&self) -> u64 {
639        self.shared.fsync_count.load(Ordering::Relaxed)
640    }
641
642    /// Defer Full-mode commit fsyncs. While enabled, [`Self::flush`]
643    /// registers the generation it needs durable instead of fsyncing inline;
644    /// the pending claim is retrieved with [`Self::take_durability_ticket`]
645    /// and the caller must wait on it before acknowledging the commit. This
646    /// is how group commit lets the fsync leave the engine's exclusive-lock
647    /// hold: append + register under the lock, wait after releasing it.
648    ///
649    /// `Normal` and `Off` modes are unaffected (they never fsync inline).
650    pub fn set_defer_sync(&mut self, defer: bool) {
651        self.defer_sync = defer;
652    }
653
654    /// Take the durability claim registered by deferred flushes since the
655    /// last take, if any. Generations are cumulative, so one ticket covers
656    /// every deferred flush that happened before it was taken.
657    pub fn take_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
658        self.deferred_gen.take().map(|gen| WalDurabilityTicket {
659            gen,
660            shared: Arc::clone(&self.shared),
661        })
662    }
663
664    /// Returns the current sync mode (used by tests + introspection).
665    pub fn sync_mode(&self) -> WalSyncMode {
666        self.sync_mode
667    }
668
669    /// `true` when the WAL is in [`WalSyncMode::Off`] — i.e. every
670    /// `append`/`flush` is a no-op. Catalog mutation hot paths check
671    /// this BEFORE constructing WAL payloads so we don't pay
672    /// `encode_row_into` + `encode_wal_payload` allocs only to throw
673    /// the result away inside `append`. This is the difference between
674    /// "no fsync" and "free" — the former is still 50–60% slower than
675    /// the no-WAL baseline on `update_by_filter`/`delete_by_filter`,
676    /// the latter matches the baseline.
677    #[inline]
678    pub fn is_off(&self) -> bool {
679        matches!(self.sync_mode, WalSyncMode::Off)
680    }
681
682    /// LSN of the most recently appended record, or 0 if nothing has
683    /// been appended yet (or the WAL is off).
684    ///
685    /// Used by schema-change paths to capture a "barrier LSN" that
686    /// reflects the DDL record's position in the log; the heap can then
687    /// stamp its pages with that LSN so replay skips every
688    /// Insert/Update/Delete that pre-dates the schema change (those rows
689    /// have already been migrated to the new layout in place).
690    #[inline]
691    pub fn last_appended_lsn(&self) -> u64 {
692        if matches!(self.sync_mode, WalSyncMode::Off) {
693            return 0;
694        }
695        self.next_lsn.saturating_sub(1)
696    }
697
698    /// Ensure the next LSN this WAL hands out is at least `lsn`. Called on
699    /// open, after recovery, to restore monotonicity: heap pages carry the
700    /// LSNs stamped during replay (and by DDL rewrites), but `Wal::open`
701    /// always resets `next_lsn` to 1. Without this, writes taken after a
702    /// crash-recovery would reuse LSNs at or below those stamped page LSNs,
703    /// and the next crash's replay would skip them as already-applied —
704    /// silent data loss. Never lowers the counter.
705    pub fn set_next_lsn_at_least(&mut self, lsn: u64) {
706        if lsn > self.next_lsn {
707            self.next_lsn = lsn;
708        }
709    }
710
711    /// Append a record to the WAL buffer. Auto-flushes when batch is full.
712    ///
713    /// In [`WalSyncMode::Off`] this is a zero-work no-op — see the enum's
714    /// doc for the durability contract.
715    pub fn append(
716        &mut self,
717        tx_id: u64,
718        record_type: WalRecordType,
719        data: &[u8],
720    ) -> io::Result<()> {
721        if matches!(self.sync_mode, WalSyncMode::Off) {
722            return Ok(());
723        }
724        let lsn = self.next_lsn;
725        self.next_lsn += 1;
726        let total_len = (WAL_HEADER_SIZE + data.len()) as u32;
727
728        // Compute CRC over tx_id + type + lsn + data
729        let mut crc_input = Vec::with_capacity(17 + data.len());
730        crc_input.extend_from_slice(&tx_id.to_le_bytes());
731        crc_input.push(record_type as u8);
732        crc_input.extend_from_slice(&lsn.to_le_bytes());
733        crc_input.extend_from_slice(data);
734        let crc = crc32fast::hash(&crc_input);
735
736        // Write: len + crc + tx_id + type + lsn + data
737        let writer = self
738            .writer
739            .as_mut()
740            .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
741        writer.write_all(&total_len.to_le_bytes())?;
742        writer.write_all(&crc.to_le_bytes())?;
743        writer.write_all(&tx_id.to_le_bytes())?;
744        writer.write_all(&[record_type as u8])?;
745        writer.write_all(&lsn.to_le_bytes())?;
746        writer.write_all(data)?;
747
748        self.pending += 1;
749        if self.pending >= self.batch_size {
750            self.flush()?;
751        }
752        Ok(())
753    }
754
755    /// Flush buffered records to disk (the group commit point).
756    ///
757    /// In `Full` mode the commit is durable when this returns: the buffered
758    /// bytes are pushed to the OS and an fsync covering them completes before
759    /// the call returns — unless durability deferral is active (see
760    /// [`Self::set_defer_sync`]), in which case the fsync obligation is
761    /// registered and handed to the caller via
762    /// [`Self::take_durability_ticket`]. Either way, concurrent committers'
763    /// fsyncs coalesce: one fsync covers every generation registered before
764    /// it started, and a lone committer fsyncs immediately (no batching
765    /// delay).
766    ///
767    /// No-op if nothing has been appended since the last flush. This makes
768    /// it safe for the executor to unconditionally call `sync_wal` at the
769    /// end of every statement — read queries pay zero fsync cost.
770    pub fn flush(&mut self) -> io::Result<()> {
771        let Some(gen) = self.flush_to_os()? else {
772            return Ok(());
773        };
774        // SQLite-style synchronous knob: only the fsync is gated on the mode.
775        // The flush-to-OS above always runs so a process crash still recovers
776        // cleanly via `read_all`. In `Full` the fsync happens here (or via
777        // the deferred ticket); in `Normal` the background flusher fsyncs off
778        // this path.
779        if matches!(self.sync_mode, WalSyncMode::Full) {
780            if self.defer_sync {
781                // Cumulative: the newest generation covers all earlier ones,
782                // so overwriting an untaken claim never loses coverage.
783                self.deferred_gen = Some(gen);
784            } else {
785                self.shared.sync_until(gen)?;
786            }
787        }
788        Ok(())
789    }
790
791    /// Push buffered records through to the OS file (no fsync) and register
792    /// the resulting dirty generation. Returns `Ok(None)` when there was
793    /// nothing pending or the WAL is `Off`.
794    fn flush_to_os(&mut self) -> io::Result<Option<u64>> {
795        let batch = self.pending;
796        if batch == 0 {
797            return Ok(None);
798        }
799        // Poisoned WALs take no new durable-intent bytes: in Normal mode the
800        // ack happens right after this flush-to-OS, and the mechanism that
801        // would have made it durable is permanently gone.
802        if !matches!(self.sync_mode, WalSyncMode::Off)
803            && self.shared.sync_poisoned.load(Ordering::Acquire)
804        {
805            return Err(io::Error::other(WAL_POISONED_MSG));
806        }
807        // Borrow the writer only for the I/O, then drop it before touching the
808        // generation counters (which borrow `self`).
809        let new_len = {
810            let writer = self
811                .writer
812                .as_mut()
813                .ok_or_else(|| io::Error::other("WAL writer unavailable"))?;
814            writer.flush()?;
815            writer.get_ref().metadata()?.len()
816        };
817        self.synced_len = new_len;
818        self.pending = 0;
819        if matches!(self.sync_mode, WalSyncMode::Off) {
820            return Ok(None);
821        }
822        // Registered only after the bytes are OS-visible, so any fsync issued
823        // from here on covers this generation.
824        let gen = self.shared.dirty_gen.fetch_add(1, Ordering::Release) + 1;
825        debug!(records = batch, "wal group commit");
826        Ok(Some(gen))
827    }
828
829    /// True when records have been appended to the in-memory WAL buffer
830    /// since the last durable flush.
831    #[inline]
832    pub fn has_pending(&self) -> bool {
833        self.pending > 0
834    }
835
836    /// Flush pending WAL bytes, then return the durable file length. Used as
837    /// an explicit-transaction rollback boundary.
838    pub fn synced_len(&mut self) -> io::Result<u64> {
839        self.flush()?;
840        Ok(self.synced_len)
841    }
842
843    /// Discard buffered (not-yet-flushed) WAL bytes and truncate the durable
844    /// log back to `len`. This is intentionally not implemented by dropping
845    /// the existing BufWriter: BufWriter's Drop attempts to flush buffered
846    /// bytes, which would resurrect rolled-back records.
847    pub fn discard_and_truncate_to(&mut self, len: u64) -> io::Result<()> {
848        if matches!(self.sync_mode, WalSyncMode::Off) {
849            self.pending = 0;
850            self.synced_len = len;
851            return Ok(());
852        }
853
854        if let Some(writer) = self.writer.take() {
855            let (_file, _buffer_result) = writer.into_parts();
856        }
857
858        let mut file = OpenOptions::new()
859            .create(true)
860            .read(true)
861            .append(true)
862            .open(&self.path)?;
863        file.set_len(len)?;
864        file.seek(SeekFrom::End(0))?;
865        file.sync_data()?;
866        let sync_fd = file.try_clone()?;
867        self.writer = Some(BufWriter::new(file));
868        // Everything that survived the truncation was just `sync_data`ed
869        // above, and everything past `len` is intentionally discarded; settle
870        // all registered generations and drop any deferred claim.
871        self.deferred_gen = None;
872        self.shared.replace_file(Some(sync_fd));
873        self.pending = 0;
874        self.synced_len = len;
875        Ok(())
876    }
877
878    /// Read all valid records from the WAL file.
879    pub fn read_all(&self) -> io::Result<Vec<WalRecord>> {
880        self.read_through_len(u64::MAX)
881    }
882
883    /// Read valid records up to a byte length boundary in the WAL file.
884    pub fn read_through_len(&self, max_len: u64) -> io::Result<Vec<WalRecord>> {
885        parse_wal_records(&self.path, max_len)
886    }
887}
888
889/// Parse valid WAL records from `path` up to `max_len` bytes, opening the file
890/// **read-only**. This is the single record-decoding loop shared by
891/// [`Wal::read_through_len`] and the read-only open path
892/// ([`read_records_at_path`]); neither ever writes to the file, so it is safe on
893/// a directory opened for read-only snapshot serving.
894fn parse_wal_records(path: &Path, max_len: u64) -> io::Result<Vec<WalRecord>> {
895    let mut file = File::open(path)?;
896    let file_len = file.metadata()?.len().min(max_len);
897    let mut file_for_header = File::open(path)?;
898    let mut pos = wal_records_start_readonly(&mut file_for_header)?;
899    let mut records = Vec::new();
900
901    while let Some((record, next_pos)) = parse_wal_record_at(&mut file, pos, file_len)? {
902        records.push(record);
903        pos = next_pos;
904    }
905
906    Ok(records)
907}
908
909/// Parse the single record starting at `pos`, returning it together with the
910/// position of the next record. Returns `Ok(None)` at end of file or at the
911/// first corrupted/truncated record (the same stop-on-corruption semantics
912/// replay has always had).
913fn parse_wal_record_at(
914    file: &mut File,
915    pos: u64,
916    file_len: u64,
917) -> io::Result<Option<(WalRecord, u64)>> {
918    if pos + WAL_HEADER_SIZE as u64 > file_len {
919        return Ok(None);
920    }
921    {
922        file.seek(SeekFrom::Start(pos))?;
923
924        let mut header = [0u8; WAL_HEADER_SIZE];
925        if file.read_exact(&mut header).is_err() {
926            return Ok(None);
927        }
928
929        // These slice-to-array conversions are infallible (fixed-size
930        // sub-slices of a 17-byte array) but we avoid `unwrap` to
931        // satisfy the project-wide zero-panic policy.
932        let total_len_bytes: [u8; 4] = match header[0..4].try_into() {
933            Ok(b) => b,
934            Err(_) => return Ok(None),
935        };
936        let total_len = u32::from_le_bytes(total_len_bytes) as usize;
937        let stored_crc_bytes: [u8; 4] = match header[4..8].try_into() {
938            Ok(b) => b,
939            Err(_) => return Ok(None),
940        };
941        let stored_crc = u32::from_le_bytes(stored_crc_bytes);
942        let tx_id_bytes: [u8; 8] = match header[8..16].try_into() {
943            Ok(b) => b,
944            Err(_) => return Ok(None),
945        };
946        let tx_id = u64::from_le_bytes(tx_id_bytes);
947        let record_type = match WalRecordType::from_u8(header[16]) {
948            Some(rt) => rt,
949            None => return Ok(None),
950        };
951        let lsn_bytes: [u8; 8] = match header[17..25].try_into() {
952            Ok(b) => b,
953            Err(_) => return Ok(None),
954        };
955        let lsn = u64::from_le_bytes(lsn_bytes);
956
957        // TASK-11: Verify the record fits within the file before
958        // allocating. Catches truncated writes without any allocation.
959        if pos + total_len as u64 > file_len {
960            return Ok(None); // Record extends beyond file: truncated write
961        }
962
963        // TASK-09: Use checked_sub to prevent integer underflow when
964        // a corrupted WAL has total_len < WAL_HEADER_SIZE.
965        let data_len = match total_len.checked_sub(WAL_HEADER_SIZE) {
966            Some(len) => len,
967            None => return Ok(None), // Corrupted record: stop replay
968        };
969
970        // TASK-10: Cap allocation size before reading data. A crafted
971        // WAL claiming a huge total_len would otherwise allocate
972        // gigabytes before the CRC check rejects the record.
973        if data_len > MAX_WAL_RECORD_SIZE {
974            return Ok(None); // Unreasonably large record: treat as corruption
975        }
976
977        let mut data = vec![0u8; data_len];
978        if data_len > 0 {
979            file.read_exact(&mut data)?;
980        }
981
982        // Verify CRC (includes lsn in the hash input)
983        let mut crc_input = Vec::with_capacity(17 + data.len());
984        crc_input.extend_from_slice(&tx_id.to_le_bytes());
985        crc_input.push(record_type as u8);
986        crc_input.extend_from_slice(&lsn.to_le_bytes());
987        crc_input.extend_from_slice(&data);
988        let computed_crc = crc32fast::hash(&crc_input);
989
990        if computed_crc != stored_crc {
991            return Ok(None); // Corrupted record: stop here
992        }
993
994        Ok(Some((
995            WalRecord {
996                tx_id,
997                record_type,
998                lsn,
999                data,
1000            },
1001            pos + total_len as u64,
1002        )))
1003    }
1004}
1005
1006/// Report whether `path` holds at least one committed WAL record, without
1007/// ever opening a writable handle. Returns `false` if the file does not
1008/// exist. Used by the read-only catalog open path to decide whether a
1009/// directory is quiescent before serving it, without creating, appending
1010/// to, or truncating the WAL the way [`Wal::open`] would. Only the first
1011/// record's header and payload are read; a valid first record is proof
1012/// enough that the directory needs recovery.
1013pub fn wal_has_committed_records(path: &Path) -> io::Result<bool> {
1014    if !path.exists() {
1015        return Ok(false);
1016    }
1017    let mut file = File::open(path)?;
1018    let file_len = file.metadata()?.len();
1019    let mut file_for_header = File::open(path)?;
1020    let pos = wal_records_start_readonly(&mut file_for_header)?;
1021    Ok(parse_wal_record_at(&mut file, pos, file_len)?.is_some())
1022}
1023
1024/// Locate the first record byte in a WAL file **without mutating it**. Mirrors
1025/// [`wal_records_start`] but never writes a header for an empty/headerless file
1026/// (the read-only path must not touch the directory): it returns the default
1027/// record start instead.
1028fn wal_records_start_readonly(file: &mut File) -> io::Result<u64> {
1029    let len = file.metadata()?.len();
1030    if len >= WAL_FILE_HEADER_SIZE {
1031        file.seek(SeekFrom::Start(0))?;
1032        let mut hdr = [0u8; WAL_FILE_HEADER_SIZE as usize];
1033        file.read_exact(&mut hdr)?;
1034        if &hdr[0..4] == WAL_MAGIC {
1035            let version = u16::from_le_bytes(hdr[4..6].try_into().expect("2-byte WAL version"));
1036            if version != WAL_FORMAT_VERSION {
1037                return Err(io::Error::new(
1038                    io::ErrorKind::InvalidData,
1039                    format!("unsupported WAL format version: {version}"),
1040                ));
1041            }
1042            return Ok(WAL_FILE_HEADER_SIZE);
1043        }
1044        // Legacy 0.4.x WAL: no file header; records start at byte 0.
1045        return Ok(0);
1046    }
1047    // Empty or sub-header file: nothing to read, and we must not write a header.
1048    Ok(WAL_FILE_HEADER_SIZE.min(len))
1049}
1050
1051impl Wal {
1052    /// Open a WAL handle that can never write, for the read-only snapshot-serving
1053    /// path. The file is opened read-only (no create, no append, no truncate) and
1054    /// the handle carries no writer; every append/flush/commit path is unreachable
1055    /// in read-only engine mode. Record reads still work because they reopen the
1056    /// file read-only on demand.
1057    pub fn open_read_only(path: &Path, batch_size: usize) -> io::Result<Self> {
1058        let records_start = if path.exists() {
1059            let mut f = File::open(path)?;
1060            wal_records_start_readonly(&mut f)?
1061        } else {
1062            WAL_FILE_HEADER_SIZE
1063        };
1064        Ok(Wal {
1065            path: path.to_path_buf(),
1066            writer: None,
1067            batch_size,
1068            pending: 0,
1069            sync_mode: WalSyncMode::Off,
1070            next_lsn: 1,
1071            records_start,
1072            synced_len: records_start,
1073            shared: Arc::new(WalSyncShared::new(None)),
1074            defer_sync: false,
1075            deferred_gen: None,
1076            flusher: None,
1077        })
1078    }
1079
1080    /// Truncate the WAL (after checkpoint).
1081    pub fn truncate(&mut self) -> io::Result<()> {
1082        // Settle any deferred durability claim before destroying the records
1083        // it covers: this keeps the "WAL records are durable before truncate"
1084        // ordering airtight even if a caller checkpoints while deferral is
1085        // active.
1086        if let Some(gen) = self.deferred_gen.take() {
1087            self.shared.sync_until(gen)?;
1088        }
1089        let mut file = OpenOptions::new()
1090            .write(true)
1091            .read(true)
1092            .truncate(true)
1093            .open(&self.path)?;
1094        write_wal_file_header(&mut file)?;
1095        let sync_fd = file.try_clone()?;
1096        self.writer = Some(BufWriter::new(file));
1097        // The old records are gone; settle their generations and swap the
1098        // fsync fd so outstanding tickets can never block on them.
1099        self.shared.replace_file(Some(sync_fd));
1100        self.records_start = WAL_FILE_HEADER_SIZE;
1101        self.pending = 0;
1102        self.synced_len = WAL_FILE_HEADER_SIZE;
1103        Ok(())
1104    }
1105
1106    /// Discard records appended since the last successful [`Self::flush`].
1107    ///
1108    /// This is intentionally different from `flush`: it must not flush the
1109    /// current `BufWriter`, because rollback uses it to abandon uncommitted
1110    /// transaction records. `BufWriter::into_parts` lets us drop the buffered
1111    /// bytes without writing them, then we truncate any large records that
1112    /// had already spilled through to the file back to the last synced
1113    /// boundary.
1114    pub fn discard_pending(&mut self) -> io::Result<()> {
1115        if matches!(self.sync_mode, WalSyncMode::Off) {
1116            self.pending = 0;
1117            return Ok(());
1118        }
1119
1120        if let Some(writer) = self.writer.take() {
1121            let (_file, _buffer) = writer.into_parts();
1122        }
1123
1124        let file = OpenOptions::new()
1125            .read(true)
1126            .append(true)
1127            .create(true)
1128            .truncate(false)
1129            .open(&self.path)?;
1130        file.set_len(self.synced_len)?;
1131        file.sync_data()?;
1132        let sync_fd = file.try_clone()?;
1133        self.writer = Some(BufWriter::new(file));
1134        // The surviving prefix was just `sync_data`ed; settle all registered
1135        // generations and drop any deferred claim over discarded bytes.
1136        self.deferred_gen = None;
1137        self.shared.replace_file(Some(sync_fd));
1138        self.pending = 0;
1139        self.synced_len = self.records_start;
1140        Ok(())
1141    }
1142}
1143
1144impl Drop for Wal {
1145    fn drop(&mut self) {
1146        // Clean shutdown must be durable regardless of mode: push any buffered
1147        // bytes to the OS and fsync, so a Normal-mode commit that hasn't yet
1148        // hit the background flusher's interval is still durable on a graceful
1149        // exit. (A process *crash* skips this — Normal's bounded-loss contract
1150        // only applies to OS-crash / power-loss, which this cannot help.)
1151        if !matches!(self.sync_mode, WalSyncMode::Off) {
1152            if let Some(writer) = self.writer.as_mut() {
1153                let _ = writer.flush();
1154                let _ = writer.get_ref().sync_data();
1155            }
1156        }
1157        self.stop_flusher();
1158        #[cfg(test)]
1159        self.disarm_fsync_failpoint();
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166
1167    fn temp_wal(name: &str) -> (Wal, PathBuf) {
1168        let path = std::env::temp_dir().join(format!("powdb_wal_{name}_{}", std::process::id()));
1169        let wal = Wal::create(&path, 4).unwrap();
1170        (wal, path)
1171    }
1172
1173    #[test]
1174    fn test_append_and_flush() {
1175        let (mut wal, path) = temp_wal("basic");
1176        wal.append(1, WalRecordType::Insert, b"row data 1").unwrap();
1177        wal.append(1, WalRecordType::Insert, b"row data 2").unwrap();
1178        wal.flush().unwrap();
1179
1180        let records = wal.read_all().unwrap();
1181        assert_eq!(records.len(), 2);
1182        assert_eq!(records[0].tx_id, 1);
1183        assert_eq!(records[0].data, b"row data 1");
1184        assert_eq!(records[1].data, b"row data 2");
1185        drop(wal);
1186        std::fs::remove_file(&path).ok();
1187    }
1188
1189    #[test]
1190    fn test_group_commit_auto_flush() {
1191        let (mut wal, path) = temp_wal("group");
1192        // Batch size is 4 — after 4 appends, should auto-flush
1193        for i in 0..4 {
1194            wal.append(1, WalRecordType::Insert, format!("row {i}").as_bytes())
1195                .unwrap();
1196        }
1197        // Should have flushed automatically
1198        let records = wal.read_all().unwrap();
1199        assert_eq!(records.len(), 4);
1200        drop(wal);
1201        std::fs::remove_file(&path).ok();
1202    }
1203
1204    #[test]
1205    fn test_normal_mode_persists_records_across_reopen() {
1206        // NORMAL durability: commits are acked after the buffered bytes reach
1207        // the OS (BufWriter::flush) without a per-commit fsync; a background
1208        // flusher + clean shutdown make them durable. Data must survive a
1209        // clean close + reopen.
1210        let path =
1211            std::env::temp_dir().join(format!("powdb_wal_normal_reopen_{}", std::process::id()));
1212        std::fs::remove_file(&path).ok();
1213        {
1214            let mut wal = Wal::create(&path, 4).unwrap();
1215            wal.set_sync_mode(WalSyncMode::Normal);
1216            assert_eq!(wal.sync_mode(), WalSyncMode::Normal);
1217            wal.append(1, WalRecordType::Insert, b"n1").unwrap();
1218            wal.append(1, WalRecordType::Insert, b"n2").unwrap();
1219            wal.flush().unwrap();
1220        } // drop: stop flusher + final fsync
1221        let wal = Wal::open(&path, 4).unwrap();
1222        let records = wal.read_all().unwrap();
1223        assert_eq!(records.len(), 2);
1224        assert_eq!(records[0].data, b"n1");
1225        assert_eq!(records[1].data, b"n2");
1226        std::fs::remove_file(&path).ok();
1227    }
1228
1229    #[test]
1230    fn test_normal_mode_background_flusher_syncs_off_commit_path() {
1231        // In NORMAL mode flush() must NOT fsync inline; the background flusher
1232        // fsyncs on its interval and advances the synced generation. Proves the
1233        // fsync is off the commit path (the latency win) yet still happens.
1234        let path = std::env::temp_dir().join(format!("powdb_wal_normal_bg_{}", std::process::id()));
1235        std::fs::remove_file(&path).ok();
1236        let mut wal = Wal::create(&path, 1000).unwrap(); // large batch: no auto-flush
1237        wal.set_sync_mode(WalSyncMode::Normal);
1238        wal.append(1, WalRecordType::Insert, b"bg1").unwrap();
1239        wal.flush().unwrap(); // buffers to OS + marks dirty; no inline fsync
1240                              // The background flusher fsyncs on its (~10 ms) interval. Poll
1241                              // rather than sleeping a fixed 80 ms: on loaded CI runners the
1242                              // flusher thread can be starved well past one interval, and the
1243                              // property under test is "it happens off the commit path", not
1244                              // "it happens within 80 ms".
1245        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1246        while wal.synced_generation() < 1 && std::time::Instant::now() < deadline {
1247            std::thread::sleep(std::time::Duration::from_millis(10));
1248        }
1249        assert!(
1250            wal.synced_generation() >= 1,
1251            "background flusher did not sync within 2s (synced_generation = {})",
1252            wal.synced_generation()
1253        );
1254        std::fs::remove_file(&path).ok();
1255    }
1256
1257    #[test]
1258    fn test_lone_committer_fsyncs_immediately_per_commit() {
1259        // Group commit must never delay a lone committer: with no other
1260        // waiters, every flush fsyncs immediately — exactly one fsync per
1261        // commit, no timers, no batching window.
1262        let (mut wal, path) = temp_wal("lone_committer");
1263        let base = wal.fsync_count();
1264        for i in 0..10u32 {
1265            wal.append(1, WalRecordType::Insert, format!("c{i}").as_bytes())
1266                .unwrap();
1267            wal.flush().unwrap();
1268        }
1269        assert_eq!(
1270            wal.fsync_count() - base,
1271            10,
1272            "a lone sequential committer must fsync exactly once per commit"
1273        );
1274        drop(wal);
1275        std::fs::remove_file(&path).ok();
1276    }
1277
1278    #[test]
1279    fn test_deferred_tickets_coalesce_one_fsync_for_two_commits() {
1280        // Two commits registered before either waits: the first wait's fsync
1281        // covers both generations, the second wait returns without an fsync.
1282        let path = std::env::temp_dir().join(format!(
1283            "powdb_wal_gc_coalesce2_{}_{}",
1284            std::process::id(),
1285            std::time::SystemTime::now()
1286                .duration_since(std::time::UNIX_EPOCH)
1287                .unwrap()
1288                .as_nanos()
1289        ));
1290        let mut wal = Wal::create(&path, 1024).unwrap();
1291        wal.set_defer_sync(true);
1292
1293        wal.append(1, WalRecordType::Insert, b"a").unwrap();
1294        wal.flush().unwrap();
1295        let t1 = wal.take_durability_ticket().expect("ticket for commit 1");
1296
1297        wal.append(2, WalRecordType::Insert, b"b").unwrap();
1298        wal.flush().unwrap();
1299        let t2 = wal.take_durability_ticket().expect("ticket for commit 2");
1300
1301        let base = wal.fsync_count();
1302        t2.wait().unwrap(); // leader — its fsync covers generation 1 too
1303        t1.wait().unwrap(); // already covered, no second fsync
1304        assert_eq!(
1305            wal.fsync_count() - base,
1306            1,
1307            "one fsync must cover both queued commits"
1308        );
1309        assert_eq!(wal.read_all().unwrap().len(), 2);
1310        drop(wal);
1311        std::fs::remove_file(&path).ok();
1312    }
1313
1314    #[test]
1315    fn test_concurrent_committers_share_one_fsync() {
1316        // Classic group commit: N committers append + register (serialized by
1317        // the writer lock), all reach the barrier before any of them waits,
1318        // then the first waiter's fsync covers every registered generation.
1319        use std::sync::Barrier;
1320
1321        let path = std::env::temp_dir().join(format!(
1322            "powdb_wal_gc_concurrent_{}_{}",
1323            std::process::id(),
1324            std::time::SystemTime::now()
1325                .duration_since(std::time::UNIX_EPOCH)
1326                .unwrap()
1327                .as_nanos()
1328        ));
1329        let wal = Arc::new(Mutex::new(Wal::create(&path, 1024).unwrap()));
1330        wal.lock().unwrap().set_defer_sync(true);
1331
1332        let n = 8;
1333        let barrier = Arc::new(Barrier::new(n));
1334        let mut handles = Vec::new();
1335        for t in 0..n {
1336            let wal = Arc::clone(&wal);
1337            let barrier = Arc::clone(&barrier);
1338            handles.push(std::thread::spawn(move || {
1339                let ticket = {
1340                    let mut w = wal.lock().unwrap();
1341                    w.append(t as u64 + 1, WalRecordType::Insert, b"row")
1342                        .unwrap();
1343                    w.flush().unwrap();
1344                    w.take_durability_ticket().expect("deferred ticket")
1345                };
1346                barrier.wait();
1347                ticket.wait().unwrap();
1348            }));
1349        }
1350        for h in handles {
1351            h.join().unwrap();
1352        }
1353
1354        let w = wal.lock().unwrap();
1355        assert_eq!(w.read_all().unwrap().len(), n);
1356        assert_eq!(
1357            w.fsync_count(),
1358            1,
1359            "all {n} overlapping commits must be covered by a single fsync"
1360        );
1361        drop(w);
1362        drop(wal);
1363        std::fs::remove_file(&path).ok();
1364    }
1365
1366    #[test]
1367    fn test_crc_integrity() {
1368        let (mut wal, path) = temp_wal("crc");
1369        wal.append(1, WalRecordType::Insert, b"important data")
1370            .unwrap();
1371        wal.flush().unwrap();
1372
1373        let records = wal.read_all().unwrap();
1374        assert_eq!(records.len(), 1);
1375        // CRC was validated during read_all — if we get here, integrity is good
1376        drop(wal);
1377        std::fs::remove_file(&path).ok();
1378    }
1379
1380    #[test]
1381    fn test_multiple_transactions() {
1382        let (mut wal, path) = temp_wal("multi_tx");
1383        wal.append(1, WalRecordType::Insert, b"tx1 op1").unwrap();
1384        wal.append(2, WalRecordType::Insert, b"tx2 op1").unwrap();
1385        wal.append(1, WalRecordType::Commit, b"").unwrap();
1386        wal.append(2, WalRecordType::Commit, b"").unwrap();
1387        wal.flush().unwrap();
1388
1389        let records = wal.read_all().unwrap();
1390        assert_eq!(records.len(), 4);
1391        assert_eq!(records[0].tx_id, 1);
1392        assert_eq!(records[2].tx_id, 1);
1393        assert_eq!(records[2].record_type, WalRecordType::Commit);
1394        drop(wal);
1395        std::fs::remove_file(&path).ok();
1396    }
1397
1398    #[test]
1399    fn test_overflow_record_types_roundtrip() {
1400        // Additive record types 11/12 append, flush, and read back with their
1401        // type + payload intact, alongside the existing Insert/Commit records.
1402        let (mut wal, path) = temp_wal("ovf_types");
1403        wal.append(1, WalRecordType::OverflowWrite, b"chunk-payload")
1404            .unwrap();
1405        wal.append(1, WalRecordType::OverflowFree, b"\x02\x00\x00\x00")
1406            .unwrap();
1407        wal.append(1, WalRecordType::Insert, b"stub-row").unwrap();
1408        wal.append(1, WalRecordType::Commit, b"").unwrap();
1409        wal.flush().unwrap();
1410
1411        let records = wal.read_all().unwrap();
1412        assert_eq!(records.len(), 4);
1413        assert_eq!(records[0].record_type, WalRecordType::OverflowWrite);
1414        assert_eq!(records[0].data, b"chunk-payload");
1415        assert_eq!(records[1].record_type, WalRecordType::OverflowFree);
1416        assert_eq!(records[2].record_type, WalRecordType::Insert);
1417        assert_eq!(records[3].record_type, WalRecordType::Commit);
1418        assert_eq!(
1419            WalRecordType::from_u8(11),
1420            Some(WalRecordType::OverflowWrite)
1421        );
1422        assert_eq!(
1423            WalRecordType::from_u8(12),
1424            Some(WalRecordType::OverflowFree)
1425        );
1426        drop(wal);
1427        std::fs::remove_file(&path).ok();
1428    }
1429
1430    #[test]
1431    fn test_truncate() {
1432        let (mut wal, path) = temp_wal("trunc");
1433        for i in 0..8 {
1434            wal.append(1, WalRecordType::Insert, format!("data {i}").as_bytes())
1435                .unwrap();
1436        }
1437        wal.flush().unwrap();
1438        assert_eq!(wal.read_all().unwrap().len(), 8);
1439
1440        wal.truncate().unwrap();
1441        assert_eq!(wal.read_all().unwrap().len(), 0);
1442        drop(wal);
1443        std::fs::remove_file(&path).ok();
1444    }
1445
1446    #[test]
1447    fn test_reopen_wal() {
1448        let path = std::env::temp_dir().join(format!("powdb_wal_reopen_{}", std::process::id()));
1449        {
1450            let mut wal = Wal::create(&path, 128).unwrap();
1451            wal.append(1, WalRecordType::Insert, b"persistent").unwrap();
1452            wal.append(1, WalRecordType::Commit, b"").unwrap();
1453            wal.flush().unwrap();
1454        }
1455        {
1456            let wal = Wal::open(&path, 128).unwrap();
1457            let records = wal.read_all().unwrap();
1458            assert_eq!(records.len(), 2);
1459            assert_eq!(records[0].data, b"persistent");
1460            assert_eq!(records[1].record_type, WalRecordType::Commit);
1461        }
1462        std::fs::remove_file(&path).ok();
1463    }
1464
1465    /// fsyncgate: after a failed fsync, the OS may have dropped the dirty
1466    /// pages and marked them clean — a RETRIED fsync then reports success
1467    /// without the data ever reaching stable storage. The only sound
1468    /// response is to poison the WAL: surface the failure once, then refuse
1469    /// every later durability claim until the process restarts and replays
1470    /// the on-disk log.
1471    #[test]
1472    fn a_failed_fsync_poisons_the_wal_and_is_never_retried() {
1473        let (mut wal, path) = temp_wal("fsync_poison");
1474        wal.set_sync_mode(WalSyncMode::Full);
1475        wal.append(1, WalRecordType::Insert, b"healthy").unwrap();
1476        wal.flush().unwrap();
1477        let healthy_fsyncs = wal.fsync_count();
1478        assert!(healthy_fsyncs >= 1, "baseline commit must fsync");
1479        assert!(!wal.is_sync_poisoned());
1480
1481        wal.arm_fsync_failpoint();
1482        wal.append(1, WalRecordType::Insert, b"doomed").unwrap();
1483        let err = wal
1484            .flush()
1485            .expect_err("the injected fsync failure must surface");
1486        assert!(
1487            err.to_string().contains("injected"),
1488            "first failure surfaces the real error, got: {err}"
1489        );
1490        assert!(wal.is_sync_poisoned());
1491
1492        // The failpoint disarmed itself, so a retried fsync WOULD report
1493        // success now — exactly the false durability this test forbids.
1494        wal.append(1, WalRecordType::Insert, b"after").unwrap();
1495        let err = wal
1496            .flush()
1497            .expect_err("a poisoned WAL must refuse further durability claims");
1498        assert!(
1499            err.to_string().contains("poisoned"),
1500            "later failures name the poisoned state, got: {err}"
1501        );
1502        assert_eq!(
1503            wal.fsync_count(),
1504            healthy_fsyncs,
1505            "no fsync may ever be issued after a failure"
1506        );
1507
1508        drop(wal);
1509        std::fs::remove_file(&path).ok();
1510    }
1511
1512    /// Same property through the Normal-mode background flusher: its fsync
1513    /// failing must poison the WAL (stopping the flusher) rather than retry
1514    /// on the next tick, and later writes must fail loudly instead of being
1515    /// acked against a durability mechanism that no longer exists.
1516    #[test]
1517    fn a_failed_background_fsync_poisons_normal_mode() {
1518        let (mut wal, path) = temp_wal("flusher_poison");
1519        wal.set_sync_mode(WalSyncMode::Normal);
1520
1521        wal.arm_fsync_failpoint();
1522        wal.append(1, WalRecordType::Insert, b"doomed").unwrap();
1523        wal.flush().unwrap(); // Normal: flush-to-OS only, fsync is the flusher's
1524
1525        // The flusher ticks every ~10ms; poll generously for the poison.
1526        let deadline = std::time::Instant::now() + Duration::from_secs(10);
1527        while !wal.is_sync_poisoned() && std::time::Instant::now() < deadline {
1528            std::thread::sleep(Duration::from_millis(5));
1529        }
1530        assert!(wal.is_sync_poisoned(), "flusher fsync failure must poison");
1531
1532        let synced_after_poison = wal.synced_generation();
1533        wal.append(1, WalRecordType::Insert, b"after").unwrap();
1534        let err = wal
1535            .flush()
1536            .expect_err("poisoned Normal-mode WAL must refuse further commits");
1537        assert!(err.to_string().contains("poisoned"), "got: {err}");
1538        // Disarmed failpoint + still-running flusher would falsely advance
1539        // the synced generation; the poisoned WAL must never move it again.
1540        std::thread::sleep(Duration::from_millis(50));
1541        assert_eq!(wal.synced_generation(), synced_after_poison);
1542
1543        drop(wal);
1544        std::fs::remove_file(&path).ok();
1545    }
1546
1547    /// Two tests arming their own WALs concurrently must not steal each
1548    /// other's injection: a single-slot failpoint let the second arm clobber
1549    /// the first, so the first WAL's fsync silently succeeded (the CI-only
1550    /// flake where both fsyncgate tests started in the same instant).
1551    #[test]
1552    fn arming_two_wals_keeps_both_failpoints_armed() {
1553        let (mut wal_a, path_a) = temp_wal("failpoint_two_a");
1554        let (mut wal_b, path_b) = temp_wal("failpoint_two_b");
1555        wal_a.set_sync_mode(WalSyncMode::Full);
1556        wal_b.set_sync_mode(WalSyncMode::Full);
1557
1558        wal_a.arm_fsync_failpoint();
1559        wal_b.arm_fsync_failpoint();
1560
1561        wal_a.append(1, WalRecordType::Insert, b"a").unwrap();
1562        wal_a
1563            .flush()
1564            .expect_err("the first-armed WAL must still see its injected failure");
1565        wal_b.append(1, WalRecordType::Insert, b"b").unwrap();
1566        wal_b
1567            .flush()
1568            .expect_err("the second-armed WAL must see its injected failure too");
1569
1570        drop(wal_a);
1571        drop(wal_b);
1572        std::fs::remove_file(&path_a).ok();
1573        std::fs::remove_file(&path_b).ok();
1574    }
1575
1576    /// A WAL that dies with its failpoint still armed must take the arm with
1577    /// it. A stale armed address would otherwise match a later WAL that the
1578    /// allocator happens to place at the same spot, injecting an fsync
1579    /// failure into an unrelated test.
1580    #[test]
1581    fn dropping_an_armed_wal_disarms_its_failpoint() {
1582        let (wal, path) = temp_wal("failpoint_drop");
1583        // Holding this clone keeps the shared allocation alive across the
1584        // drop, so no concurrent test's WAL can reoccupy the address and
1585        // re-arm it — the containment checks below race with nothing.
1586        let shared = Arc::clone(&wal.shared);
1587        let addr = Arc::as_ptr(&shared) as usize;
1588
1589        wal.arm_fsync_failpoint();
1590        assert!(WAL_FSYNC_FAILPOINTS.lock().unwrap().contains(&addr));
1591        drop(wal);
1592        assert!(
1593            !WAL_FSYNC_FAILPOINTS.lock().unwrap().contains(&addr),
1594            "dropping the WAL must remove its armed failpoint"
1595        );
1596        std::fs::remove_file(&path).ok();
1597    }
1598}