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