Skip to main content

nodedb_wal/
mmap_reader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Memory-mapped WAL segment reader for Event Plane catchup.
4//!
5//! Unlike the standard `WalReader` (which uses sequential `read_exact`),
6//! this reader maps sealed WAL segments into the process address space via
7//! `mmap`. The kernel manages the page cache — no slab allocator memory is
8//! pinned, and mmap reads from page cache don't contend with the Data Plane's
9//! O_DIRECT WAL append path (O_DIRECT bypasses page cache entirely).
10//!
11//! **Tier progression:**
12//! 1. In-memory Arc slabs (hot, zero-copy from ring buffer)
13//! 2. Mmap WAL segment reads (warm, kernel-managed pages)
14//! 3. Shed consumer + cold WAL replay (last resort)
15//!
16//! This reader is used in tier 2: when the Event Plane enters WAL Catchup
17//! Mode, it mmap's the relevant sealed segments and iterates records.
18
19use std::path::Path;
20use std::sync::atomic::{AtomicU64, Ordering};
21
22#[cfg(target_os = "linux")]
23use std::os::fd::AsRawFd;
24
25use memmap2::Mmap;
26
27use crate::error::{Result, WalError};
28use crate::record::{HEADER_SIZE, RecordHeader, RecordType, WAL_MAGIC, WalRecord};
29
30/// Module-scoped atomic counters for observing mmap and fadvise behaviour in
31/// production. These counters are incremented by the live code paths (open,
32/// madvise, fadvise) and may be read from tests or from a metrics scrape.
33pub mod observability {
34    use super::{AtomicU64, Ordering};
35    pub(super) static SEGMENTS_OPENED: AtomicU64 = AtomicU64::new(0);
36    pub(super) static FADV_DONTNEED_COUNT: AtomicU64 = AtomicU64::new(0);
37    pub(super) static MADV_SEQUENTIAL_COUNT: AtomicU64 = AtomicU64::new(0);
38
39    pub fn segments_opened() -> u64 {
40        SEGMENTS_OPENED.load(Ordering::Relaxed)
41    }
42    pub fn fadv_dontneed_count() -> u64 {
43        FADV_DONTNEED_COUNT.load(Ordering::Relaxed)
44    }
45    pub fn madv_sequential_count() -> u64 {
46        MADV_SEQUENTIAL_COUNT.load(Ordering::Relaxed)
47    }
48}
49
50/// Call `posix_fadvise(POSIX_FADV_DONTNEED)` on an open WAL segment fd.
51///
52/// Once a segment has been iterated end-to-end during catchup, we don't
53/// need its pages in cache any longer. Release them back to the kernel so
54/// replay doesn't pin GiBs of page cache.
55fn fadv_dontneed(fd: &std::fs::File, len: usize, path: &Path) {
56    if len == 0 {
57        return;
58    }
59    #[cfg(target_os = "linux")]
60    {
61        let rc = unsafe {
62            libc::posix_fadvise(
63                fd.as_raw_fd(),
64                0,
65                len as libc::off_t,
66                libc::POSIX_FADV_DONTNEED,
67            )
68        };
69        if rc == 0 {
70            observability::FADV_DONTNEED_COUNT.fetch_add(1, Ordering::Relaxed);
71        } else {
72            tracing::warn!(
73                path = %path.display(),
74                errno = rc,
75                "posix_fadvise(DONTNEED) failed on exhausted WAL segment",
76            );
77        }
78    }
79    #[cfg(not(target_os = "linux"))]
80    {
81        let _ = (fd, path);
82    }
83}
84
85/// Memory-mapped WAL segment reader.
86///
87/// Opens a sealed WAL segment file via mmap and provides zero-copy
88/// iteration over records. The mmap'd region is read-only and the
89/// kernel manages page residency — no application-level memory pinning.
90pub struct MmapWalReader {
91    mmap: Mmap,
92    offset: usize,
93    file: std::fs::File,
94    path: std::path::PathBuf,
95    madvise_state: Option<libc::c_int>,
96}
97
98impl MmapWalReader {
99    /// Open a WAL segment file for mmap'd reading.
100    pub fn open(path: &Path) -> Result<Self> {
101        observability::SEGMENTS_OPENED.fetch_add(1, Ordering::Relaxed);
102        let file = std::fs::File::open(path)?;
103        // SAFETY: The file is a sealed WAL segment (not being written to).
104        // The Data Plane writes to the ACTIVE segment via O_DIRECT; sealed
105        // segments are immutable after rollover.
106        let mmap = unsafe { Mmap::map(&file)? };
107
108        // Catchup iterates forward through a segment. MADV_SEQUENTIAL
109        // doubles readahead and drops already-consumed pages eagerly so
110        // replay doesn't grow buff/cache by the full WAL size.
111        let mut madvise_state = None;
112        if !mmap.is_empty() {
113            let rc = unsafe {
114                libc::madvise(
115                    mmap.as_ptr() as *mut libc::c_void,
116                    mmap.len(),
117                    libc::MADV_SEQUENTIAL,
118                )
119            };
120            if rc == 0 {
121                madvise_state = Some(libc::MADV_SEQUENTIAL);
122                observability::MADV_SEQUENTIAL_COUNT.fetch_add(1, Ordering::Relaxed);
123            } else {
124                tracing::warn!(
125                    path = %path.display(),
126                    errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
127                    "madvise(MADV_SEQUENTIAL) failed on WAL segment; continuing",
128                );
129            }
130        }
131
132        Ok(Self {
133            mmap,
134            offset: 0,
135            file,
136            path: path.to_path_buf(),
137            madvise_state,
138        })
139    }
140
141    /// The madvise hint applied to the mapped segment (if any).
142    pub fn madvise_state(&self) -> Option<libc::c_int> {
143        self.madvise_state
144    }
145
146    /// Hint to the kernel that pages for this segment can be dropped from
147    /// cache. Call this after a segment has been iterated end-to-end.
148    pub fn release_pages(&self) {
149        fadv_dontneed(&self.file, self.mmap.len(), &self.path);
150    }
151
152    /// Read the next record from the mmap'd region.
153    ///
154    /// Returns `None` at EOF or at the first corruption point.
155    /// Zero-copy: payload bytes reference the mmap'd region directly.
156    pub fn next_record(&mut self) -> Result<Option<WalRecord>> {
157        let data = &self.mmap[..];
158
159        loop {
160            // Check if we have enough bytes for a header.
161            if self.offset + HEADER_SIZE > data.len() {
162                return Ok(None);
163            }
164
165            // Parse header.
166            let header_bytes: &[u8; HEADER_SIZE] = data[self.offset..self.offset + HEADER_SIZE]
167                .try_into()
168                .map_err(|_| {
169                    WalError::Io(std::io::Error::new(
170                        std::io::ErrorKind::InvalidData,
171                        "header slice conversion failed",
172                    ))
173                })?;
174            let header = RecordHeader::from_bytes(header_bytes);
175
176            // Validate magic — corruption or end of valid data.
177            if header.magic != WAL_MAGIC {
178                return Ok(None);
179            }
180
181            // Validate version.
182            if header.validate(self.offset as u64).is_err() {
183                return Ok(None);
184            }
185
186            let payload_len = header.payload_len as usize;
187            let record_end = self.offset + HEADER_SIZE + payload_len;
188
189            // Check if payload is fully within the mmap'd region.
190            if record_end > data.len() {
191                return Ok(None); // Torn write at segment end.
192            }
193
194            // Extract payload (copies from mmap to owned Vec).
195            let payload = data[self.offset + HEADER_SIZE..record_end].to_vec();
196            self.offset = record_end;
197
198            let record = WalRecord { header, payload };
199
200            // Verify checksum.
201            if record.verify_checksum().is_err() {
202                return Ok(None); // Corruption — end of committed prefix.
203            }
204
205            // Check record type.
206            let logical_type = record.logical_record_type();
207            if RecordType::from_raw(logical_type).is_none() {
208                if RecordType::is_required(logical_type) {
209                    return Err(WalError::UnknownRequiredRecordType {
210                        record_type: header.record_type,
211                        lsn: header.lsn,
212                    });
213                }
214                // Unknown optional record — skip and continue loop.
215                continue;
216            }
217
218            return Ok(Some(record));
219        }
220    }
221
222    /// Iterator over all valid records in the mmap'd segment.
223    pub fn records(self) -> MmapRecordIter {
224        MmapRecordIter { reader: self }
225    }
226
227    /// Current read offset.
228    pub fn offset(&self) -> usize {
229        self.offset
230    }
231
232    /// Total size of the mmap'd region.
233    pub fn len(&self) -> usize {
234        self.mmap.len()
235    }
236
237    /// Whether the mmap'd region is empty.
238    pub fn is_empty(&self) -> bool {
239        self.mmap.is_empty()
240    }
241}
242
243/// Iterator over records in a mmap'd WAL segment.
244pub struct MmapRecordIter {
245    reader: MmapWalReader,
246}
247
248impl Iterator for MmapRecordIter {
249    type Item = Result<WalRecord>;
250
251    fn next(&mut self) -> Option<Self::Item> {
252        match self.reader.next_record() {
253            Ok(Some(record)) => Some(Ok(record)),
254            Ok(None) => None,
255            Err(e) => Some(Err(e)),
256        }
257    }
258}
259
260/// Minimum number of segments to justify parallel replay overhead.
261const PARALLEL_SEGMENT_THRESHOLD: usize = 4;
262
263/// Replay WAL segments from a directory using mmap, starting from `from_lsn`.
264///
265/// Discovers all sealed segments, mmap's each, and returns records with
266/// LSN >= `from_lsn`. This is the Event Plane's tier-2 catchup path.
267///
268/// When 4+ segments need scanning, uses `std::thread::scope` to read
269/// segments in parallel (one thread per segment). Each thread mmap's its
270/// segment and filters records independently; results are merged in
271/// segment order (already LSN-sorted since segments are monotonic).
272pub fn replay_segments_mmap(wal_dir: &Path, from_lsn: u64) -> Result<Vec<WalRecord>> {
273    let segments = crate::segment::discover_segments(wal_dir)?;
274    let live = filter_segments_by_lsn(&segments, from_lsn);
275
276    if live.len() < PARALLEL_SEGMENT_THRESHOLD {
277        return replay_segments_sequential(live, from_lsn);
278    }
279
280    replay_segments_parallel(live, from_lsn)
281}
282
283/// Return the slice of `segments` whose LSN range may contain records with
284/// lsn >= `from_lsn`. A segment at index `i` is skippable iff the next
285/// segment's `first_lsn` is `<= from_lsn` — meaning segment `i`'s entire
286/// range is strictly below the cutoff. The last segment is never skipped
287/// on this criterion because its upper bound is unknown.
288fn filter_segments_by_lsn(
289    segments: &[crate::segment::SegmentMeta],
290    from_lsn: u64,
291) -> &[crate::segment::SegmentMeta] {
292    // Find the first segment whose next-segment first_lsn > from_lsn, OR
293    // the last segment (always live). Since segments are LSN-sorted, the
294    // live tail starts at the largest i such that segments[i].first_lsn
295    // <= from_lsn.
296    let mut start = 0;
297    for i in 0..segments.len() {
298        // Segment i covers [first_lsn_i, first_lsn_{i+1}).
299        let upper = segments.get(i + 1).map(|s| s.first_lsn).unwrap_or(u64::MAX);
300        if upper > from_lsn {
301            start = i;
302            break;
303        }
304        start = i + 1;
305    }
306    if start >= segments.len() {
307        // All segments strictly below from_lsn; nothing to replay.
308        return &[];
309    }
310    &segments[start..]
311}
312
313/// Sequential segment replay (used for small segment counts).
314fn replay_segments_sequential(
315    segments: &[crate::segment::SegmentMeta],
316    from_lsn: u64,
317) -> Result<Vec<WalRecord>> {
318    let mut records = Vec::new();
319    for seg in segments {
320        let mut reader = MmapWalReader::open(&seg.path)?;
321        while let Some(record) = reader.next_record()? {
322            if record.header.lsn >= from_lsn {
323                records.push(record);
324            }
325        }
326        reader.release_pages();
327    }
328    Ok(records)
329}
330
331/// Parallel segment replay using scoped threads.
332///
333/// Each segment is read in its own thread via mmap. Since segments are
334/// monotonically ordered by LSN, concatenating per-segment results in
335/// segment order produces a globally LSN-ordered result.
336fn replay_segments_parallel(
337    segments: &[crate::segment::SegmentMeta],
338    from_lsn: u64,
339) -> Result<Vec<WalRecord>> {
340    // Collect per-segment results. Index corresponds to segment order.
341    let mut per_segment: Vec<Result<Vec<WalRecord>>> = Vec::with_capacity(segments.len());
342
343    std::thread::scope(|scope| {
344        let handles: Vec<_> = segments
345            .iter()
346            .map(|seg| {
347                scope.spawn(move || -> Result<Vec<WalRecord>> {
348                    let mut reader = MmapWalReader::open(&seg.path)?;
349                    let mut seg_records = Vec::new();
350                    while let Some(record) = reader.next_record()? {
351                        if record.header.lsn >= from_lsn {
352                            seg_records.push(record);
353                        }
354                    }
355                    reader.release_pages();
356                    Ok(seg_records)
357                })
358            })
359            .collect();
360
361        for handle in handles {
362            per_segment.push(handle.join().unwrap_or_else(|_| {
363                Err(WalError::Io(std::io::Error::other(
364                    "segment replay thread panicked",
365                )))
366            }));
367        }
368    });
369
370    // Merge in segment order (preserves LSN ordering).
371    let total_estimate: usize = per_segment
372        .iter()
373        .map(|r| r.as_ref().map(|v| v.len()).unwrap_or(0))
374        .sum();
375    let mut records = Vec::with_capacity(total_estimate);
376    for seg_result in per_segment {
377        records.extend(seg_result?);
378    }
379
380    Ok(records)
381}
382
383/// Paginated mmap replay: reads at most `max_records` from `from_lsn`.
384///
385/// Returns `(records, has_more)` where `has_more` is `true` if the limit
386/// was reached before all segments were exhausted. This bounds memory
387/// usage per catch-up cycle to O(max_records) instead of O(all WAL data).
388///
389/// Always uses sequential reading (no parallel threads) since the bounded
390/// record count makes parallel overhead unnecessary.
391pub fn replay_segments_mmap_limit(
392    wal_dir: &Path,
393    from_lsn: u64,
394    max_records: usize,
395) -> Result<(Vec<WalRecord>, bool)> {
396    let segments = crate::segment::discover_segments(wal_dir)?;
397    let live = filter_segments_by_lsn(&segments, from_lsn);
398    let mut records = Vec::with_capacity(max_records.min(4096));
399
400    for seg in live {
401        let mut reader = MmapWalReader::open(&seg.path)?;
402        while let Some(record) = reader.next_record()? {
403            if record.header.lsn >= from_lsn {
404                records.push(record);
405                if records.len() >= max_records {
406                    // Partial scan — don't release pages for a segment
407                    // we'll likely re-open on the next catchup cycle.
408                    return Ok((records, true));
409                }
410            }
411        }
412        reader.release_pages();
413    }
414
415    Ok((records, false))
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::record::RecordType;
422    use crate::writer::{WalWriter, WalWriterConfig};
423
424    fn test_writer(path: &Path) -> WalWriter {
425        let config = WalWriterConfig {
426            use_direct_io: false, // Tests run without O_DIRECT.
427            ..Default::default()
428        };
429        WalWriter::open(path, config).unwrap()
430    }
431
432    #[test]
433    fn mmap_reader_basic() {
434        let dir = tempfile::tempdir().unwrap();
435        let path = dir.path().join("test.wal");
436
437        // Write some records with the standard writer.
438        {
439            let mut writer = test_writer(&path);
440            writer
441                .append(RecordType::Put as u32, 1, 0, 0, b"hello")
442                .unwrap();
443            writer
444                .append(RecordType::Put as u32, 1, 0, 0, b"world")
445                .unwrap();
446            writer.sync().unwrap();
447        }
448
449        // Read back with mmap reader.
450        let reader = MmapWalReader::open(&path).unwrap();
451        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
452
453        assert_eq!(records.len(), 2);
454        assert_eq!(records[0].payload, b"hello");
455        assert_eq!(records[1].payload, b"world");
456    }
457
458    #[test]
459    fn mmap_reader_empty_file() {
460        let dir = tempfile::tempdir().unwrap();
461        let path = dir.path().join("empty.wal");
462        std::fs::write(&path, []).unwrap();
463
464        let reader = MmapWalReader::open(&path).unwrap();
465        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
466        assert!(records.is_empty());
467    }
468
469    #[test]
470    fn mmap_reader_truncated_header() {
471        let dir = tempfile::tempdir().unwrap();
472        let path = dir.path().join("truncated.wal");
473        // Write 10 bytes — not enough for a header (30 bytes).
474        std::fs::write(&path, [0u8; 10]).unwrap();
475
476        let reader = MmapWalReader::open(&path).unwrap();
477        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
478        assert!(records.is_empty());
479    }
480
481    #[test]
482    fn replay_mmap_from_lsn() {
483        let dir = tempfile::tempdir().unwrap();
484        let wal_dir = dir.path().join("wal");
485        std::fs::create_dir_all(&wal_dir).unwrap();
486
487        let config = crate::segmented::SegmentedWalConfig::for_testing(wal_dir.clone());
488        let mut wal = crate::segmented::SegmentedWal::open(config).unwrap();
489
490        let lsn1 = wal.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
491        let lsn2 = wal.append(RecordType::Put as u32, 1, 0, 0, b"b").unwrap();
492        let lsn3 = wal.append(RecordType::Put as u32, 1, 0, 0, b"c").unwrap();
493        wal.sync().unwrap();
494
495        // Replay from lsn2 — should get records b and c.
496        let records = replay_segments_mmap(&wal_dir, lsn2).unwrap();
497        assert_eq!(records.len(), 2);
498        assert_eq!(records[0].header.lsn, lsn2);
499        assert_eq!(records[1].header.lsn, lsn3);
500
501        // Replay from lsn1 — all 3.
502        let all = replay_segments_mmap(&wal_dir, lsn1).unwrap();
503        assert_eq!(all.len(), 3);
504    }
505}