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