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