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