Skip to main content

powdb_storage/
wal.rs

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