Skip to main content

reddb_server/storage/wal/
writer.rs

1use super::record::WalRecord;
2use std::fs::{File, OpenOptions};
3use std::io::{self, BufWriter, Seek, SeekFrom, Write};
4use std::path::Path;
5use std::sync::Arc;
6
7/// User-space buffer size for the WAL writer.
8///
9/// Chosen so that ~5 000 small records (Begin/Commit ≈ 21 bytes,
10/// small PageWrite ≈ 34 bytes) coalesce into a single `write` syscall
11/// before the next `sync()` drains the buffer. Tunable; reflects the
12/// postgres XLOG block size (8 KiB) scaled up because we batch
13/// record-level rather than page-level.
14pub const WAL_BUFFER_BYTES: usize = 64 * 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum WalSyncMethod {
18    Data,
19    All,
20}
21
22pub(crate) struct WalGroupSync {
23    target_lsn: u64,
24    sync_handle: Arc<File>,
25    method: WalSyncMethod,
26}
27
28impl WalGroupSync {
29    pub(crate) fn target_lsn(&self) -> u64 {
30        self.target_lsn
31    }
32
33    pub(crate) fn sync(&self) -> io::Result<()> {
34        match self.method {
35            WalSyncMethod::Data => self.sync_handle.sync_data(),
36            WalSyncMethod::All => self.sync_handle.sync_all(),
37        }
38    }
39}
40
41/// Reserve disk blocks for `[offset, offset + len)` **without** growing the
42/// file's logical length (`FALLOC_FL_KEEP_SIZE`).
43///
44/// Pinning `i_size` is the whole trick that makes preallocation invisible to
45/// crash recovery: the WAL's logical end stays equal to its real data length,
46/// so [`WalReader`](super::reader::WalReader)'s EOF scan never walks into a
47/// zero-filled reserved tail (a `0x00` type byte would otherwise decode to an
48/// "Invalid record type" error and abort recovery). This is why we cannot use
49/// `fs2::allocate` here — it calls `posix_fallocate`, which *extends* `i_size`.
50///
51/// Linux-only; other targets return [`io::ErrorKind::Unsupported`] so the
52/// caller disables the optimization silently.
53#[cfg(target_os = "linux")]
54fn reserve_wal_blocks(file: &File, offset: u64, len: u64) -> io::Result<()> {
55    use std::os::unix::io::AsRawFd;
56    if len == 0 {
57        return Ok(());
58    }
59    // SAFETY: `file` owns a valid fd for the duration of the call; fallocate
60    // only mutates block reservations for that fd, never process memory.
61    let ret = unsafe {
62        libc::fallocate(
63            file.as_raw_fd(),
64            libc::FALLOC_FL_KEEP_SIZE,
65            offset as libc::off_t,
66            len as libc::off_t,
67        )
68    };
69    if ret == 0 {
70        Ok(())
71    } else {
72        Err(io::Error::last_os_error())
73    }
74}
75
76#[cfg(not(target_os = "linux"))]
77fn reserve_wal_blocks(_file: &File, _offset: u64, _len: u64) -> io::Result<()> {
78    Err(io::Error::new(
79        io::ErrorKind::Unsupported,
80        "WAL preallocation is only implemented on linux",
81    ))
82}
83
84/// Whether a `fallocate` failure means "this filesystem can't preallocate"
85/// (tmpfs, overlayfs, many network filesystems) rather than a real I/O error.
86/// Those are soft failures that flip the feature off; anything else is left to
87/// the normal write path to surface (e.g. a genuine `ENOSPC`).
88fn fallocate_unsupported(err: &io::Error) -> bool {
89    if err.kind() == io::ErrorKind::Unsupported {
90        return true;
91    }
92    #[cfg(target_os = "linux")]
93    {
94        matches!(
95            err.raw_os_error(),
96            Some(libc::EOPNOTSUPP) | Some(libc::ENOSYS) | Some(libc::EINVAL)
97        )
98    }
99    #[cfg(not(target_os = "linux"))]
100    {
101        false
102    }
103}
104
105/// Writer for the Write-Ahead Log
106///
107/// Wraps the underlying file in a [`BufWriter`] so each `append` does
108/// not pay a write syscall — bytes accumulate in a 64 KiB user-space
109/// buffer until `sync()` (or `flush_until()`) drains them and then
110/// calls `sync_data()`/`sync_all()` on the raw file. This is how postgres turns
111/// per-record append cost from ~500 ns down to ~5 ns; reddb's previous
112/// per-append `write_all` directly to the file paid the syscall on
113/// every record.
114///
115/// **Critical contract:** every code path that syncs the underlying
116/// file *must* drain the [`BufWriter`] first via
117/// `BufWriter::flush()`. Otherwise the bytes in user-space never reach
118/// the kernel before fsync, and durability is silently broken.
119pub struct WalWriter {
120    file: BufWriter<File>,
121    /// Cloned file descriptor for `sync_all()` outside the writer
122    /// mutex. Both this and `file`'s inner `File` point at the same
123    /// kernel inode; calling `sync_all()` on either flushes ALL
124    /// pending bytes for that inode. This is the trick that lets
125    /// the group-commit leader release the WAL writer lock during
126    /// the expensive fsync — see [`WalWriter::drain_for_group_sync`].
127    ///
128    /// Without this clone, a leader holding the writer mutex during
129    /// `sync_all()` blocks every other writer from appending,
130    /// defeating the entire purpose of group commit.
131    sync_handle: Arc<File>,
132    /// Log Sequence Number — byte offset of the next record. Advances
133    /// every `append`; survives across restarts via `seek(End)`.
134    current_lsn: u64,
135    /// Highest LSN that has been `sync_all()`'d to disk. The WAL-first
136    /// flush invariant relies on this: a page with `header.lsn = L` may
137    /// only be written to its data file once `durable_lsn >= L`.
138    /// See `src/storage/cache/README.md` § Invariant 2 and the Target 3
139    /// section of `PLAN.md`.
140    durable_lsn: u64,
141    /// WAL byte frontier covered by the last full file sync. Appends that stay
142    /// inside this synced preallocation range can use `sync_data()`; crossing
143    /// it, or syncing after fresh preallocation metadata, falls back to
144    /// `sync_all()`.
145    last_synced_size: u64,
146    /// Exclusive byte offset up to which disk blocks are pre-reserved via
147    /// `fallocate(FALLOC_FL_KEEP_SIZE)`. Advances one
148    /// [`reddb_file::MAIN_WAL_SEGMENT_BYTES`]
149    /// segment at a time as `current_lsn` approaches it (issue #893). Reset to
150    /// `0` on [`truncate`](Self::truncate) — which frees the blocks — and
151    /// immediately re-extended (the checkpoint re-extend path).
152    preallocated_to: u64,
153    /// Cleared the first time `fallocate` reports the backing filesystem can't
154    /// preallocate (tmpfs/overlay/NFS → `EOPNOTSUPP`/`ENOSYS`, or any non-Linux
155    /// target) so we stop issuing syscalls that will always fail. Preallocation
156    /// is a best-effort optimization; clearing this never affects correctness.
157    prealloc_supported: bool,
158    /// Set when `fallocate(FALLOC_FL_KEEP_SIZE)` successfully reserved a new
159    /// range and that allocation metadata has not yet been covered by a full
160    /// sync.
161    prealloc_metadata_dirty: bool,
162    #[cfg(test)]
163    last_sync_method: Option<WalSyncMethod>,
164}
165
166impl WalWriter {
167    /// Open a WAL file for writing. Creates it if it doesn't exist.
168    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
169        let exists = path.as_ref().exists();
170
171        // We do all initial bookkeeping (write header, seek to EOF) on
172        // the raw `File` BEFORE wrapping in a BufWriter so we don't
173        // have to worry about flush ordering during construction.
174        let mut raw = OpenOptions::new()
175            .read(true)
176            .create(true)
177            .append(true)
178            .open(path)?;
179
180        let current_lsn = if !exists || raw.metadata()?.len() == 0 {
181            raw.write_all(&reddb_file::encode_wal_file_header())?;
182            raw.sync_all()?;
183            reddb_file::WAL_FILE_HEADER_BYTES as u64
184        } else {
185            // Existing file, set LSN to current end. Append-mode files
186            // ignore this seek for *writes*, but we use the returned
187            // position as our LSN counter.
188            raw.seek(SeekFrom::End(0))?
189        };
190
191        // Clone the file handle BEFORE wrapping in BufWriter. The
192        // clone shares the same kernel file description, so
193        // sync_all() on either descriptor flushes the whole inode.
194        // The BufWriter owns the original; the Arc<File> is shared
195        // with the group-commit leader.
196        let sync_handle = Arc::new(raw.try_clone()?);
197        let file = BufWriter::with_capacity(WAL_BUFFER_BYTES, raw);
198
199        // On open, every byte already on disk is by definition durable
200        // (any pre-crash unflushed tail was lost when the OS dropped
201        // page cache). Initialise `durable_lsn` to `current_lsn`.
202        let mut writer = Self {
203            file,
204            sync_handle,
205            current_lsn,
206            durable_lsn: current_lsn,
207            last_synced_size: current_lsn,
208            preallocated_to: 0,
209            prealloc_supported: true,
210            prealloc_metadata_dirty: false,
211            #[cfg(test)]
212            last_sync_method: None,
213        };
214        // Reserve the first segment up front so the very first appends land in
215        // contiguous extents rather than growing the file page-by-page.
216        writer.ensure_preallocated()?;
217        Ok(writer)
218    }
219
220    /// Ensure disk blocks are reserved at least up to the next segment
221    /// boundary above the current write frontier (`current_lsn`).
222    ///
223    /// Cheap (pure arithmetic) until the frontier crosses a
224    /// [`reddb_file::MAIN_WAL_SEGMENT_BYTES`] boundary, at which point it issues a single
225    /// `fallocate`. Best-effort: a filesystem that can't preallocate disables
226    /// the feature; a transient error is swallowed so a write never fails
227    /// because preallocation hiccuped (the write path surfaces a genuine
228    /// `ENOSPC` on its own). Never grows the file's logical length, so it is
229    /// invisible to crash recovery.
230    fn ensure_preallocated(&mut self) -> io::Result<()> {
231        if !self.prealloc_supported {
232            return Ok(());
233        }
234        let target = reddb_file::next_main_wal_segment_boundary(self.current_lsn);
235        if target <= self.preallocated_to {
236            return Ok(());
237        }
238        let from = self.preallocated_to;
239        match reserve_wal_blocks(self.file.get_ref(), from, target - from) {
240            Ok(()) => {
241                self.preallocated_to = target;
242                self.prealloc_metadata_dirty = true;
243            }
244            Err(ref e) if fallocate_unsupported(e) => self.prealloc_supported = false,
245            Err(_) => {
246                // Best-effort: leave `preallocated_to` as-is and retry at the
247                // next boundary. Never propagate.
248            }
249        }
250        Ok(())
251    }
252
253    /// Append a record to the WAL.
254    ///
255    /// Bytes go into the BufWriter — they are NOT durable on disk
256    /// after this call returns. Callers that need durability must
257    /// follow up with [`WalWriter::sync`] or
258    /// [`WalWriter::flush_until`].
259    ///
260    /// Returns the LSN (Log Sequence Number) of the record.
261    pub fn append(&mut self, record: &WalRecord) -> io::Result<u64> {
262        let bytes = record.encode();
263        self.file.write_all(&bytes)?;
264
265        let record_lsn = self.current_lsn;
266        self.current_lsn += bytes.len() as u64;
267
268        self.ensure_preallocated()?;
269        Ok(record_lsn)
270    }
271
272    /// Write already-encoded bytes and advance the LSN counter to
273    /// match. Used by the lock-free append path: writers encode +
274    /// atomically reserve an LSN range outside this writer, the
275    /// group-commit coordinator drains the pending queue in LSN
276    /// order, then calls `append_bytes` for each batch.
277    ///
278    /// The bytes MUST be a valid `WalRecord::encode()` payload (or a
279    /// concatenation of such) — no structural validation happens
280    /// here. The caller is responsible for keeping the on-disk
281    /// byte offset synchronised with the externally-tracked LSN
282    /// counter; this method just appends and advances.
283    pub fn append_bytes(&mut self, bytes: &[u8]) -> io::Result<u64> {
284        self.file.write_all(bytes)?;
285        let record_lsn = self.current_lsn;
286        self.current_lsn += bytes.len() as u64;
287        self.ensure_preallocated()?;
288        Ok(record_lsn)
289    }
290
291    /// Rewind the writer's LSN counter to a specific value. Used
292    /// by the lock-free append path to resync the writer with the
293    /// externally-tracked `next_lsn` after a drain batch; the
294    /// coordinator knows the exact byte offset it just wrote to
295    /// and needs `current_lsn` to match so subsequent direct
296    /// callers of `append` stay consistent.
297    pub fn set_current_lsn(&mut self, lsn: u64) {
298        self.current_lsn = lsn;
299    }
300
301    /// Force sync to disk.
302    ///
303    /// Drains the user-space [`BufWriter`] first, then calls
304    /// `sync_all()` on the underlying file so every byte appended
305    /// since the last sync is durable. Updates `durable_lsn` so
306    /// subsequent `flush_until` calls become no-ops up to
307    /// `current_lsn`.
308    pub fn sync(&mut self) -> io::Result<()> {
309        self.file.flush()?;
310        self.sync_flushed_file()?;
311        self.durable_lsn = self.current_lsn;
312        Ok(())
313    }
314
315    /// Ensure the WAL is durable on disk at least up to byte offset
316    /// `target`. No-op when `target <= durable_lsn`.
317    ///
318    /// This is the postgres `XLogFlush(LSN)` analogue. Pager flush
319    /// paths call this with `max(dirty.header.lsn)` before writing
320    /// any data page so the WAL record describing the change is
321    /// guaranteed to be on disk before the page itself.
322    pub fn flush_until(&mut self, target: u64) -> io::Result<()> {
323        if self.durable_lsn >= target {
324            return Ok(());
325        }
326        self.file.flush()?;
327        self.sync_flushed_file()?;
328        self.durable_lsn = self.current_lsn;
329        Ok(())
330    }
331
332    fn sync_flushed_file(&mut self) -> io::Result<()> {
333        let method = self.next_sync_method();
334        match method {
335            WalSyncMethod::Data => self.file.get_ref().sync_data()?,
336            WalSyncMethod::All => self.file.get_ref().sync_all()?,
337        }
338        self.mark_sync_complete(method, self.current_lsn);
339        Ok(())
340    }
341
342    fn next_sync_method(&self) -> WalSyncMethod {
343        if !self.prealloc_metadata_dirty && self.current_lsn <= self.last_synced_size {
344            WalSyncMethod::Data
345        } else {
346            WalSyncMethod::All
347        }
348    }
349
350    fn mark_sync_complete(&mut self, method: WalSyncMethod, lsn: u64) {
351        match method {
352            WalSyncMethod::Data => {}
353            WalSyncMethod::All => {
354                self.last_synced_size = self.preallocated_to.max(lsn);
355                self.prealloc_metadata_dirty = false;
356            }
357        }
358        #[cfg(test)]
359        {
360            self.last_sync_method = Some(method);
361        }
362    }
363
364    /// Highest byte offset that is durable on disk. Used by the pager
365    /// to decide whether a `flush_until` call would actually need a
366    /// `fsync`.
367    pub fn durable_lsn(&self) -> u64 {
368        self.durable_lsn
369    }
370
371    /// Get current LSN (end of file offset)
372    pub fn current_lsn(&self) -> u64 {
373        self.current_lsn
374    }
375
376    /// Drain the BufWriter into the kernel and return the captured
377    /// LSN plus a cloned file handle and sync method for the caller
378    /// **without holding the WAL writer mutex**.
379    ///
380    /// Used by the group-commit leader path. The flow is:
381    ///
382    /// 1. Take the WAL writer mutex.
383    /// 2. Call this method — drains user-space buffer to the kernel
384    ///    and captures a size-aware sync plan.
385    /// 3. Release the WAL writer mutex.
386    /// 4. Execute the sync plan — this is the expensive ~100 µs syscall,
387    ///    and other writers can keep appending while it runs.
388    /// 5. Take the WAL writer mutex briefly and call
389    ///    [`WalWriter::mark_durable`] to publish the new durable position.
390    ///
391    /// The cloned `sync_handle` shares the same kernel inode with
392    /// the writer's `file`, so syncing the clone flushes bytes that
393    /// have reached the kernel for that file.
394    /// This is the coalescing window that makes group commit win.
395    pub(crate) fn drain_for_group_sync(&mut self) -> io::Result<WalGroupSync> {
396        // Drain user-space buffer into the kernel.
397        self.file.flush()?;
398        Ok(WalGroupSync {
399            target_lsn: self.current_lsn,
400            sync_handle: Arc::clone(&self.sync_handle),
401            method: self.next_sync_method(),
402        })
403    }
404
405    /// Manually advance `durable_lsn` after a successful out-of-lock
406    /// sync performed via [`WalWriter::drain_for_group_sync`].
407    ///
408    /// Monotonic — never lowers `durable_lsn`. Safe to call with a
409    /// stale `lsn`; just becomes a no-op.
410    pub(crate) fn mark_durable(&mut self, sync: &WalGroupSync) {
411        let lsn = sync.target_lsn;
412        if lsn > self.durable_lsn {
413            self.durable_lsn = lsn;
414        }
415        self.mark_sync_complete(sync.method, lsn);
416    }
417
418    /// Truncate the WAL (usually after checkpoint).
419    ///
420    /// Drains the BufWriter first so no pending bytes hit the file
421    /// after the truncate. Then resets the underlying file, rewrites
422    /// the header through the buffered writer (header is small; the
423    /// followup `flush + sync_all` makes it durable), and resets
424    /// LSN bookkeeping.
425    pub fn truncate(&mut self) -> io::Result<()> {
426        // Drop any pending bytes BEFORE the truncate; otherwise the
427        // BufWriter would flush them to a re-shrunken file in
428        // confused order.
429        self.file.flush()?;
430
431        {
432            let raw = self.file.get_mut();
433            raw.set_len(0)?;
434            raw.seek(SeekFrom::Start(0))?;
435        }
436
437        // Rewrite header through the BufWriter then drain.
438        self.file.write_all(&reddb_file::encode_wal_file_header())?;
439        self.file.flush()?;
440        self.file.get_ref().sync_all()?;
441
442        self.current_lsn = reddb_file::WAL_FILE_HEADER_BYTES as u64;
443        self.durable_lsn = reddb_file::WAL_FILE_HEADER_BYTES as u64;
444        self.last_synced_size = reddb_file::WAL_FILE_HEADER_BYTES as u64;
445        self.prealloc_metadata_dirty = false;
446        #[cfg(test)]
447        {
448            self.last_sync_method = Some(WalSyncMethod::All);
449        }
450
451        // `set_len(0)` freed every reserved block, so the WAL would otherwise
452        // grow page-by-page again from here. Re-extend a fresh segment now —
453        // this is the "truncate/re-extend on checkpoint" half of issue #893.
454        self.preallocated_to = 0;
455        self.ensure_preallocated()?;
456        Ok(())
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use std::path::PathBuf;
464
465    struct FileGuard {
466        path: PathBuf,
467    }
468
469    impl Drop for FileGuard {
470        fn drop(&mut self) {
471            let _ = std::fs::remove_file(&self.path);
472        }
473    }
474
475    fn temp_wal(name: &str) -> (FileGuard, PathBuf) {
476        let path = reddb_file::layout::wal_component_temp_path(
477            &std::env::temp_dir(),
478            "writer",
479            name,
480            std::process::id(),
481        );
482        let guard = FileGuard { path: path.clone() };
483        let _ = std::fs::remove_file(&path);
484        (guard, path)
485    }
486
487    #[test]
488    fn test_create_new_wal() {
489        let (_guard, path) = temp_wal("create");
490        let writer = WalWriter::open(&path).expect("open() should succeed");
491
492        // Should start at LSN 8 (after 8-byte header)
493        assert_eq!(writer.current_lsn(), 8);
494        assert!(path.exists());
495    }
496
497    #[test]
498    fn test_append_record() {
499        let (_guard, path) = temp_wal("append");
500        let mut writer = WalWriter::open(&path).expect("open() should succeed");
501
502        let record = WalRecord::Begin { tx_id: 42 };
503        let lsn = writer.append(&record).expect("append() should succeed");
504
505        // First record starts at LSN 8
506        assert_eq!(lsn, 8);
507
508        // Next record should start after encoded size
509        // Begin record (v3 framing): 1 (type) + 8 (term) + 8 (ownership_epoch)
510        //   + 8 (tx_id) + 4 (checksum) = 29 bytes
511        assert_eq!(writer.current_lsn(), 8 + 29);
512    }
513
514    #[test]
515    fn test_append_multiple_records() {
516        let (_guard, path) = temp_wal("multi");
517        let mut writer = WalWriter::open(&path).expect("open() should succeed");
518
519        let lsn1 = writer
520            .append(&WalRecord::Begin { tx_id: 1 })
521            .expect("append() should succeed");
522        let lsn2 = writer
523            .append(&WalRecord::Begin { tx_id: 2 })
524            .expect("append() should succeed");
525        let lsn3 = writer
526            .append(&WalRecord::Commit { tx_id: 1 })
527            .expect("append() should succeed");
528
529        assert_eq!(lsn1, 8);
530        assert_eq!(lsn2, 8 + 29);
531        assert_eq!(lsn3, 8 + 29 + 29);
532    }
533
534    #[test]
535    fn test_page_write_lsn() {
536        let (_guard, path) = temp_wal("pagewrite");
537        let mut writer = WalWriter::open(&path).expect("open() should succeed");
538
539        // First record
540        let lsn1 = writer
541            .append(&WalRecord::Begin { tx_id: 1 })
542            .expect("append() should succeed");
543        assert_eq!(lsn1, 8);
544
545        // PageWrite record (v3 framing): +8 bytes for ownership_epoch vs v2
546        let data = vec![1, 2, 3, 4, 5];
547        let lsn2 = writer
548            .append(&WalRecord::PageWrite {
549                tx_id: 1,
550                page_id: 100,
551                data: data.clone(),
552            })
553            .expect("value is present");
554
555        assert_eq!(lsn2, 8 + 29); // after Begin
556
557        // Next LSN = lsn2 + (1 + 8 + 8 + 8 + 4 + 4 + 5 + 4) = lsn2 + 42
558        assert_eq!(writer.current_lsn(), 8 + 29 + 42);
559    }
560
561    #[test]
562    fn test_sync() {
563        let (_guard, path) = temp_wal("sync");
564        let mut writer = WalWriter::open(&path).expect("open() should succeed");
565
566        writer
567            .append(&WalRecord::Begin { tx_id: 1 })
568            .expect("append() should succeed");
569        writer.sync().expect("sync() should succeed");
570
571        // File should be synced, just verify no error
572        assert!(path.exists());
573    }
574
575    #[test]
576    fn test_truncate() {
577        let (_guard, path) = temp_wal("truncate");
578        let mut writer = WalWriter::open(&path).expect("open() should succeed");
579
580        // Write some records
581        writer
582            .append(&WalRecord::Begin { tx_id: 1 })
583            .expect("append() should succeed");
584        writer
585            .append(&WalRecord::PageWrite {
586                tx_id: 1,
587                page_id: 0,
588                data: vec![0; 100],
589            })
590            .expect("value is present");
591        writer
592            .append(&WalRecord::Commit { tx_id: 1 })
593            .expect("append() should succeed");
594
595        let lsn_before = writer.current_lsn();
596        assert!(lsn_before > 8);
597
598        // Truncate
599        writer.truncate().expect("truncate() should succeed");
600
601        // LSN should be back to 8
602        assert_eq!(writer.current_lsn(), 8);
603
604        // File should be 8 bytes (just header)
605        let len = std::fs::metadata(&path)
606            .expect("metadata() should succeed")
607            .len();
608        assert_eq!(len, 8);
609    }
610
611    #[test]
612    fn test_reopen_existing() {
613        let (_guard, path) = temp_wal("reopen");
614
615        // Create and write
616        let lsn_after_write;
617        {
618            let mut writer = WalWriter::open(&path).expect("open() should succeed");
619            writer
620                .append(&WalRecord::Begin { tx_id: 1 })
621                .expect("append() should succeed");
622            writer
623                .append(&WalRecord::Commit { tx_id: 1 })
624                .expect("append() should succeed");
625            lsn_after_write = writer.current_lsn();
626        }
627
628        // Reopen
629        {
630            let writer = WalWriter::open(&path).expect("open() should succeed");
631            // Should continue from where we left off
632            assert_eq!(writer.current_lsn(), lsn_after_write);
633        }
634    }
635
636    #[test]
637    fn test_checkpoint_record() {
638        let (_guard, path) = temp_wal("checkpoint");
639        let mut writer = WalWriter::open(&path).expect("open() should succeed");
640
641        // Checkpoint is same size as Begin (v3: 1 + 8 + 8 + 8 + 4 = 29)
642        let lsn = writer
643            .append(&WalRecord::Checkpoint { lsn: 12345 })
644            .expect("value is present");
645        assert_eq!(lsn, 8);
646        assert_eq!(writer.current_lsn(), 8 + 29);
647    }
648
649    // -----------------------------------------------------------------
650    // Target 3: durable_lsn / flush_until tests
651    // -----------------------------------------------------------------
652
653    #[test]
654    fn fresh_wal_has_durable_lsn_at_header_end() {
655        let (_guard, path) = temp_wal("durable_init");
656        let writer = WalWriter::open(&path).expect("open() should succeed");
657        assert_eq!(writer.durable_lsn(), 8);
658        assert_eq!(writer.current_lsn(), 8);
659    }
660
661    #[test]
662    fn flush_until_below_durable_is_noop() {
663        let (_guard, path) = temp_wal("flush_noop");
664        let mut writer = WalWriter::open(&path).expect("open() should succeed");
665        // After open, durable_lsn == 8.
666        let before = writer.durable_lsn();
667        writer.flush_until(0).expect("flush_until() should succeed");
668        writer.flush_until(8).expect("flush_until() should succeed");
669        assert_eq!(writer.durable_lsn(), before);
670    }
671
672    #[test]
673    fn flush_until_advances_durable_to_current() {
674        let (_guard, path) = temp_wal("flush_advance");
675        let mut writer = WalWriter::open(&path).expect("open() should succeed");
676        writer
677            .append(&WalRecord::Begin { tx_id: 7 })
678            .expect("append() should succeed");
679        writer
680            .append(&WalRecord::Commit { tx_id: 7 })
681            .expect("append() should succeed");
682        let target = writer.current_lsn();
683        // Before flush_until, durable still at the header.
684        assert_eq!(writer.durable_lsn(), 8);
685        writer
686            .flush_until(target)
687            .expect("flush_until() should succeed");
688        assert_eq!(writer.durable_lsn(), target);
689    }
690
691    #[test]
692    fn flush_until_is_monotonic() {
693        let (_guard, path) = temp_wal("flush_monotonic");
694        let mut writer = WalWriter::open(&path).expect("open() should succeed");
695        writer
696            .append(&WalRecord::Begin { tx_id: 1 })
697            .expect("append() should succeed");
698        let lo = writer.current_lsn();
699        writer
700            .flush_until(lo)
701            .expect("flush_until() should succeed");
702        let durable_after_lo = writer.durable_lsn();
703        writer
704            .append(&WalRecord::Commit { tx_id: 1 })
705            .expect("append() should succeed");
706        let hi = writer.current_lsn();
707        writer
708            .flush_until(hi)
709            .expect("flush_until() should succeed");
710        assert!(writer.durable_lsn() >= durable_after_lo);
711        // Calling flush_until(lo) after flush_until(hi) is a no-op.
712        writer
713            .flush_until(lo)
714            .expect("flush_until() should succeed");
715        assert_eq!(writer.durable_lsn(), hi);
716    }
717
718    #[test]
719    fn sync_advances_durable_lsn_too() {
720        let (_guard, path) = temp_wal("sync_durable");
721        let mut writer = WalWriter::open(&path).expect("open() should succeed");
722        writer
723            .append(&WalRecord::Begin { tx_id: 9 })
724            .expect("append() should succeed");
725        let before = writer.durable_lsn();
726        let after_append = writer.current_lsn();
727        assert!(after_append > before);
728        writer.sync().expect("sync() should succeed");
729        assert_eq!(writer.durable_lsn(), after_append);
730    }
731
732    #[test]
733    fn sync_all_is_used_when_wal_size_grew() {
734        let (_guard, path) = temp_wal("sync_all_grew");
735        let mut writer = WalWriter::open(&path).expect("open() should succeed");
736
737        writer
738            .append(&WalRecord::Begin { tx_id: 1 })
739            .expect("append() should succeed");
740        writer.sync().expect("sync() should succeed");
741
742        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::All));
743        assert!(writer.last_synced_size >= writer.current_lsn());
744        assert!(!writer.prealloc_metadata_dirty);
745    }
746
747    #[test]
748    fn sync_all_is_used_for_metadata_only_preallocation() {
749        let (_guard, path) = temp_wal("sync_all_prealloc_metadata");
750        let mut writer = WalWriter::open(&path).expect("open() should succeed");
751        if !writer.prealloc_supported {
752            return;
753        }
754
755        assert_eq!(writer.current_lsn(), 8);
756        assert!(writer.prealloc_metadata_dirty);
757
758        writer.sync().expect("sync() should succeed");
759
760        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::All));
761        assert_eq!(writer.last_synced_size, writer.preallocated_to);
762        assert!(!writer.prealloc_metadata_dirty);
763    }
764
765    #[test]
766    fn sync_data_is_used_when_wal_size_is_unchanged() {
767        let (_guard, path) = temp_wal("sync_data_unchanged");
768        let mut writer = WalWriter::open(&path).expect("open() should succeed");
769
770        writer
771            .append(&WalRecord::Begin { tx_id: 1 })
772            .expect("append() should succeed");
773        writer.sync().expect("sync() should succeed");
774        let synced_size = writer.last_synced_size;
775        writer.sync().expect("sync() should succeed");
776
777        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::Data));
778        assert_eq!(writer.last_synced_size, synced_size);
779        assert_eq!(writer.durable_lsn(), writer.current_lsn());
780    }
781
782    #[test]
783    fn sync_data_is_used_for_appends_within_synced_preallocation() {
784        let (_guard, path) = temp_wal("sync_data_preallocated_append");
785        let mut writer = WalWriter::open(&path).expect("open() should succeed");
786        if !writer.prealloc_supported {
787            return;
788        }
789
790        writer
791            .append(&WalRecord::Begin { tx_id: 1 })
792            .expect("append() should succeed");
793        writer.sync().expect("sync() should succeed");
794        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::All));
795
796        writer
797            .append(&WalRecord::Commit { tx_id: 1 })
798            .expect("append() should succeed");
799        writer.sync().expect("sync() should succeed");
800
801        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::Data));
802        assert_eq!(writer.durable_lsn(), writer.current_lsn());
803        assert!(writer.current_lsn() <= writer.last_synced_size);
804    }
805
806    #[test]
807    fn group_sync_uses_sync_data_within_synced_preallocation() {
808        let (_guard, path) = temp_wal("group_sync_data_preallocated_append");
809        let mut writer = WalWriter::open(&path).expect("open() should succeed");
810        if !writer.prealloc_supported {
811            return;
812        }
813
814        writer
815            .append(&WalRecord::Begin { tx_id: 1 })
816            .expect("append() should succeed");
817        writer.sync().expect("sync() should succeed");
818        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::All));
819
820        writer
821            .append(&WalRecord::Commit { tx_id: 1 })
822            .expect("append() should succeed");
823        let sync = writer
824            .drain_for_group_sync()
825            .expect("drain_for_group_sync() should succeed");
826        assert_eq!(sync.method, WalSyncMethod::Data);
827        sync.sync().expect("sync() should succeed");
828        writer.mark_durable(&sync);
829
830        assert_eq!(writer.last_sync_method, Some(WalSyncMethod::Data));
831        assert_eq!(writer.durable_lsn(), writer.current_lsn());
832    }
833
834    #[test]
835    fn truncate_resets_durable_lsn() {
836        let (_guard, path) = temp_wal("truncate_durable");
837        let mut writer = WalWriter::open(&path).expect("open() should succeed");
838        writer
839            .append(&WalRecord::Begin { tx_id: 1 })
840            .expect("append() should succeed");
841        writer.sync().expect("sync() should succeed");
842        assert!(writer.durable_lsn() > 8);
843        writer.truncate().expect("truncate() should succeed");
844        assert_eq!(writer.durable_lsn(), 8);
845        assert_eq!(writer.current_lsn(), 8);
846    }
847
848    #[test]
849    fn reopen_initialises_durable_to_current() {
850        let (_guard, path) = temp_wal("reopen_durable");
851        {
852            let mut writer = WalWriter::open(&path).expect("open() should succeed");
853            writer
854                .append(&WalRecord::Begin { tx_id: 1 })
855                .expect("append() should succeed");
856            writer.sync().expect("sync() should succeed");
857        }
858        let writer = WalWriter::open(&path).expect("open() should succeed");
859        // After reopen, every byte on disk is durable by definition.
860        assert_eq!(writer.durable_lsn(), writer.current_lsn());
861    }
862
863    // -----------------------------------------------------------------
864    // Perf 1.1: BufWriter coalesces small appends until sync
865    // -----------------------------------------------------------------
866
867    #[test]
868    fn bufwriter_coalesces_until_sync() {
869        // Append 100 small records but DO NOT sync. The on-disk file
870        // size must still equal the header (8 bytes) because the
871        // bytes are sitting in the BufWriter, not in the kernel.
872        let (_guard, path) = temp_wal("bufwriter_coalesce");
873        let mut writer = WalWriter::open(&path).expect("open() should succeed");
874        for tx in 0..100u64 {
875            writer
876                .append(&WalRecord::Begin { tx_id: tx })
877                .expect("append() should succeed");
878        }
879        // current_lsn reflects the in-buffer position. Each v3 Begin record is
880        // 29 bytes: 1 (type) + 8 (term) + 8 (ownership_epoch) + 8 (tx_id)
881        //   + 4 (checksum).
882        assert_eq!(writer.current_lsn(), 8 + 100 * 29);
883        // But the file on disk only has the header.
884        let on_disk = std::fs::metadata(&path)
885            .expect("metadata() should succeed")
886            .len();
887        assert_eq!(on_disk, 8, "BufWriter leaked bytes to disk before sync");
888    }
889
890    #[test]
891    fn sync_drains_bufwriter_before_fsync() {
892        // After sync(), the file size must equal current_lsn — the
893        // BufWriter has been flushed and sync_all has hit the kernel.
894        let (_guard, path) = temp_wal("sync_drains");
895        let mut writer = WalWriter::open(&path).expect("open() should succeed");
896        for tx in 0..50u64 {
897            writer
898                .append(&WalRecord::Begin { tx_id: tx })
899                .expect("append() should succeed");
900        }
901        writer.sync().expect("sync() should succeed");
902        let on_disk = std::fs::metadata(&path)
903            .expect("metadata() should succeed")
904            .len();
905        assert_eq!(on_disk, writer.current_lsn());
906        assert_eq!(writer.durable_lsn(), writer.current_lsn());
907    }
908
909    #[test]
910    fn flush_until_drains_bufwriter_too() {
911        // flush_until must drain the BufWriter before calling
912        // sync_all on the underlying file — otherwise pending bytes
913        // never become durable.
914        let (_guard, path) = temp_wal("flush_until_drains");
915        let mut writer = WalWriter::open(&path).expect("open() should succeed");
916        for tx in 0..30u64 {
917            writer
918                .append(&WalRecord::Begin { tx_id: tx })
919                .expect("append() should succeed");
920        }
921        let target = writer.current_lsn();
922        writer
923            .flush_until(target)
924            .expect("flush_until() should succeed");
925        let on_disk = std::fs::metadata(&path)
926            .expect("metadata() should succeed")
927            .len();
928        assert_eq!(on_disk, target);
929        assert_eq!(writer.durable_lsn(), target);
930    }
931
932    #[test]
933    fn truncate_drains_pending_bufwriter_bytes_first() {
934        // If truncate did NOT drain BufWriter first, the pending bytes
935        // would either land in the post-truncate file (corrupting it
936        // with stale records) or be lost. Verify the resulting file
937        // contains only a fresh header.
938        let (_guard, path) = temp_wal("truncate_drain");
939        let mut writer = WalWriter::open(&path).expect("open() should succeed");
940        // Write enough small records to fill some of the 64 KiB buffer
941        // but stay below the auto-flush threshold.
942        for tx in 0..200u64 {
943            writer
944                .append(&WalRecord::Begin { tx_id: tx })
945                .expect("append() should succeed");
946        }
947        // Sanity: bytes are buffered.
948        assert_eq!(
949            std::fs::metadata(&path)
950                .expect("metadata() should succeed")
951                .len(),
952            8
953        );
954
955        writer.truncate().expect("truncate() should succeed");
956        // After truncate the file is just the header again.
957        let on_disk = std::fs::metadata(&path)
958            .expect("metadata() should succeed")
959            .len();
960        assert_eq!(on_disk, 8);
961        assert_eq!(writer.current_lsn(), 8);
962        assert_eq!(writer.durable_lsn(), 8);
963
964        // And we can append again successfully.
965        writer
966            .append(&WalRecord::Begin { tx_id: 99 })
967            .expect("append() should succeed");
968        writer.sync().expect("sync() should succeed");
969        assert_eq!(
970            std::fs::metadata(&path)
971                .expect("metadata() should succeed")
972                .len(),
973            8 + 29
974        );
975    }
976
977    #[test]
978    fn reopen_sees_only_synced_records() {
979        // Records that were appended but never sync'd must NOT
980        // survive a reopen — they lived in the BufWriter, never made
981        // it to the kernel, and the previous WalWriter went out of
982        // scope. The new WalWriter reopens the file and reads from
983        // EOF, which reflects only the bytes that hit disk.
984        //
985        // We sync some records, then drop the writer mid-buffer, and
986        // assert the reopen LSN matches only the synced prefix.
987        let (_guard, path) = temp_wal("reopen_synced_only");
988        let synced_lsn;
989        {
990            let mut writer = WalWriter::open(&path).expect("open() should succeed");
991            writer
992                .append(&WalRecord::Begin { tx_id: 1 })
993                .expect("append() should succeed");
994            writer.sync().expect("sync() should succeed");
995            synced_lsn = writer.current_lsn();
996            // These records are never sync'd before drop. Drop runs
997            // BufWriter::flush which DOES write them — see note below.
998            for tx in 100..120u64 {
999                writer
1000                    .append(&WalRecord::Begin { tx_id: tx })
1001                    .expect("append() should succeed");
1002            }
1003            // Without a sync, the in-buffer bytes are still pending.
1004            // BufWriter's Drop impl does flush to the file but does
1005            // not call sync_all. For reopen-LSN purposes, on-disk
1006            // bytes count regardless of fsync, so the reopened LSN
1007            // will reflect the dropped writes too.
1008        }
1009        let writer = WalWriter::open(&path).expect("open() should succeed");
1010        // The reopen LSN reflects what's physically on disk after
1011        // BufWriter::Drop flushes its buffer. That may or may not
1012        // include the unsync'd records depending on platform; the
1013        // contract we care about is that durable_lsn ≥ synced_lsn.
1014        assert!(writer.durable_lsn() >= synced_lsn);
1015    }
1016
1017    // -----------------------------------------------------------------
1018    // Issue #893: fallocate-based WAL segment preallocation
1019    // -----------------------------------------------------------------
1020
1021    /// On-disk blocks reserved by `fallocate`, in bytes. Returns the
1022    /// allocated size (st_blocks × 512), independent of the logical length.
1023    fn allocated_bytes(path: &std::path::Path) -> u64 {
1024        use fs2::FileExt;
1025        let f = std::fs::File::open(path).expect("open() should succeed");
1026        f.allocated_size().expect("allocated_size() should succeed")
1027    }
1028
1029    #[test]
1030    fn segment_boundary_rounds_strictly_up() {
1031        // Always lands one boundary ahead so the reservation stays in front
1032        // of the write frontier.
1033        assert_eq!(
1034            reddb_file::next_main_wal_segment_boundary(0),
1035            reddb_file::MAIN_WAL_SEGMENT_BYTES
1036        );
1037        assert_eq!(
1038            reddb_file::next_main_wal_segment_boundary(8),
1039            reddb_file::MAIN_WAL_SEGMENT_BYTES
1040        );
1041        assert_eq!(
1042            reddb_file::next_main_wal_segment_boundary(reddb_file::MAIN_WAL_SEGMENT_BYTES - 1),
1043            reddb_file::MAIN_WAL_SEGMENT_BYTES
1044        );
1045        // Exactly on a boundary still advances to the next one.
1046        assert_eq!(
1047            reddb_file::next_main_wal_segment_boundary(reddb_file::MAIN_WAL_SEGMENT_BYTES),
1048            2 * reddb_file::MAIN_WAL_SEGMENT_BYTES
1049        );
1050        assert_eq!(
1051            reddb_file::next_main_wal_segment_boundary(reddb_file::MAIN_WAL_SEGMENT_BYTES + 1),
1052            2 * reddb_file::MAIN_WAL_SEGMENT_BYTES
1053        );
1054    }
1055
1056    #[test]
1057    fn open_preallocates_first_segment() {
1058        // A freshly opened WAL must reserve a whole segment up front instead
1059        // of growing incrementally (acceptance #1).
1060        let (_guard, path) = temp_wal("prealloc_open");
1061        let writer = WalWriter::open(&path).expect("open() should succeed");
1062        if !writer.prealloc_supported {
1063            return; // filesystem without fallocate — feature is a no-op.
1064        }
1065        assert_eq!(writer.preallocated_to, reddb_file::MAIN_WAL_SEGMENT_BYTES);
1066        // The reservation is real on disk, yet the logical file is still just
1067        // the 8-byte header.
1068        assert!(allocated_bytes(&path) >= reddb_file::MAIN_WAL_SEGMENT_BYTES);
1069        assert_eq!(
1070            std::fs::metadata(&path)
1071                .expect("metadata() should succeed")
1072                .len(),
1073            8
1074        );
1075    }
1076
1077    #[test]
1078    fn preallocation_does_not_grow_logical_length() {
1079        // The load-bearing invariant for crash recovery: appending records
1080        // must NOT inflate the logical file size beyond the real data, or the
1081        // EOF scan in WalReader would walk into the reserved tail. Holds on
1082        // every filesystem (fallocate keeps i_size pinned; absent fallocate
1083        // there is no reservation at all).
1084        let (_guard, path) = temp_wal("prealloc_logical");
1085        let mut writer = WalWriter::open(&path).expect("open() should succeed");
1086        for tx in 0..50u64 {
1087            writer
1088                .append(&WalRecord::Begin { tx_id: tx })
1089                .expect("append() should succeed");
1090        }
1091        writer.sync().expect("sync() should succeed");
1092        let logical = std::fs::metadata(&path)
1093            .expect("metadata() should succeed")
1094            .len();
1095        assert_eq!(logical, 8 + 50 * 29, "preallocation inflated i_size");
1096        assert_eq!(writer.current_lsn(), logical);
1097    }
1098
1099    #[test]
1100    fn truncate_re_extends_a_fresh_segment() {
1101        // After checkpoint truncation the WAL must re-extend rather than grow
1102        // unbounded page-by-page (acceptance #2).
1103        let (_guard, path) = temp_wal("prealloc_truncate");
1104        let mut writer = WalWriter::open(&path).expect("open() should succeed");
1105        writer
1106            .append(&WalRecord::Begin { tx_id: 1 })
1107            .expect("append() should succeed");
1108        writer.sync().expect("sync() should succeed");
1109
1110        writer.truncate().expect("truncate() should succeed");
1111
1112        assert_eq!(writer.current_lsn(), 8);
1113        assert_eq!(
1114            std::fs::metadata(&path)
1115                .expect("metadata() should succeed")
1116                .len(),
1117            8
1118        );
1119        if writer.prealloc_supported {
1120            assert_eq!(writer.preallocated_to, reddb_file::MAIN_WAL_SEGMENT_BYTES);
1121            assert!(allocated_bytes(&path) >= reddb_file::MAIN_WAL_SEGMENT_BYTES);
1122        }
1123    }
1124
1125    #[test]
1126    fn preallocated_wal_recovers_records_without_trailing_garbage() {
1127        // End-to-end: a preallocated WAL must read back exactly the records
1128        // written — the reserved (unwritten) tail must be invisible to the
1129        // reader, proving crash-recovery is unchanged (acceptance #3).
1130        use super::super::reader::WalReader;
1131        let (_guard, path) = temp_wal("prealloc_recover");
1132        {
1133            let mut writer = WalWriter::open(&path).expect("open() should succeed");
1134            writer
1135                .append(&WalRecord::Begin { tx_id: 1 })
1136                .expect("append() should succeed");
1137            writer
1138                .append(&WalRecord::PageWrite {
1139                    tx_id: 1,
1140                    page_id: 7,
1141                    data: vec![1, 2, 3, 4],
1142                })
1143                .expect("value is present");
1144            writer
1145                .append(&WalRecord::Commit { tx_id: 1 })
1146                .expect("append() should succeed");
1147            writer.sync().expect("sync() should succeed");
1148        }
1149        let records: Vec<_> = WalReader::open(&path)
1150            .expect("value is present")
1151            .iter()
1152            .collect::<Result<_, _>>()
1153            .expect("reader must stop cleanly at real EOF, not in reserved tail");
1154        assert_eq!(records.len(), 3);
1155        assert_eq!(records[0].1, WalRecord::Begin { tx_id: 1 });
1156        assert_eq!(records[2].1, WalRecord::Commit { tx_id: 1 });
1157    }
1158}