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