Skip to main content

powdb_storage/
wal.rs

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