Skip to main content

nodedb_wal/mmap_reader/
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//!
19//! ## Encryption
20//!
21//! [`MmapWalReader::open`] is the replay constructor: it takes the key ring and
22//! hands out plaintext. [`MmapWalReader::open_raw`] is the opt-in structural
23//! constructor for code that only walks record framing.
24
25use std::path::Path;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28#[cfg(target_os = "linux")]
29use std::os::fd::AsRawFd;
30
31use memmap2::Mmap;
32
33use crate::crypto::KeyRing;
34use crate::error::{Result, WalError};
35use crate::preamble::{PREAMBLE_SIZE, SegmentPreamble, WAL_PREAMBLE_MAGIC, parse_leading_preamble};
36use crate::reader::StopReason;
37use crate::record::{
38    HEADER_SIZE, RecordHeader, RecordType, WAL_MAGIC, WalRecord, is_format_gap, may_be_format_gap,
39};
40use crate::segment::SegmentDecryptor;
41
42fn checked_range_end(start: usize, len: usize, limit: usize) -> Result<Option<usize>> {
43    let end = start.checked_add(len).ok_or_else(|| {
44        WalError::Io(std::io::Error::new(
45            std::io::ErrorKind::InvalidData,
46            "mmap WAL offset overflow",
47        ))
48    })?;
49    Ok((end <= limit).then_some(end))
50}
51
52/// Module-scoped atomic counters for observing mmap and fadvise behaviour in
53/// production. These counters are incremented by the live code paths (open,
54/// madvise, fadvise) and may be read from tests or from a metrics scrape.
55pub mod observability {
56    use super::{AtomicU64, Ordering};
57    pub(super) static SEGMENTS_OPENED: AtomicU64 = AtomicU64::new(0);
58    pub(super) static FADV_DONTNEED_COUNT: AtomicU64 = AtomicU64::new(0);
59    pub(super) static MADV_SEQUENTIAL_COUNT: AtomicU64 = AtomicU64::new(0);
60
61    pub fn segments_opened() -> u64 {
62        SEGMENTS_OPENED.load(Ordering::Relaxed)
63    }
64    pub fn fadv_dontneed_count() -> u64 {
65        FADV_DONTNEED_COUNT.load(Ordering::Relaxed)
66    }
67    pub fn madv_sequential_count() -> u64 {
68        MADV_SEQUENTIAL_COUNT.load(Ordering::Relaxed)
69    }
70}
71
72/// Call `posix_fadvise(POSIX_FADV_DONTNEED)` on an open WAL segment fd.
73///
74/// Once a segment has been iterated end-to-end during catchup, we don't
75/// need its pages in cache any longer. Release them back to the kernel so
76/// replay doesn't pin GiBs of page cache.
77fn fadv_dontneed(fd: &std::fs::File, len: usize, path: &Path) {
78    if len == 0 {
79        return;
80    }
81    #[cfg(target_os = "linux")]
82    {
83        let len = match libc::off_t::try_from(len) {
84            Ok(len) => len,
85            Err(_) => {
86                tracing::warn!(
87                    path = %path.display(),
88                    "WAL segment length does not fit posix_fadvise offset; skipping cache release",
89                );
90                return;
91            }
92        };
93        let rc = unsafe { libc::posix_fadvise(fd.as_raw_fd(), 0, len, libc::POSIX_FADV_DONTNEED) };
94        if rc == 0 {
95            observability::FADV_DONTNEED_COUNT.fetch_add(1, Ordering::Relaxed);
96        } else {
97            tracing::warn!(
98                path = %path.display(),
99                errno = rc,
100                "posix_fadvise(DONTNEED) failed on exhausted WAL segment",
101            );
102        }
103    }
104    #[cfg(not(target_os = "linux"))]
105    {
106        let _ = (fd, path);
107    }
108}
109
110/// Memory-mapped WAL segment reader.
111///
112/// Opens a sealed WAL segment file via mmap and provides zero-copy
113/// iteration over records. The mmap'd region is read-only and the
114/// kernel manages page residency — no application-level memory pinning.
115pub struct MmapWalReader {
116    mmap: Mmap,
117    offset: usize,
118    /// Offset of the segment's first record, past any preamble. The format
119    /// version is judged here and nowhere else: one writer never mixes formats
120    /// inside one segment.
121    records_start: usize,
122    /// Preamble mapped at offset 0 of this segment (present when encryption is
123    /// active). The epoch is part of the AAD used to decrypt payloads.
124    segment_preamble: Option<SegmentPreamble>,
125    file: std::fs::File,
126    path: std::path::PathBuf,
127    madvise_state: Option<libc::c_int>,
128    stop_reason: Option<StopReason>,
129    /// Applied to every record before it is surfaced. `None` only in raw
130    /// structural mode, where payloads may still be ciphertext.
131    decryptor: Option<SegmentDecryptor>,
132}
133
134impl MmapWalReader {
135    /// Open a WAL segment file for mmap'd replay.
136    ///
137    /// Every record is decrypted on the way out: what the caller receives never
138    /// has `ENCRYPTED_FLAG` set, and its checksum verifies against the
139    /// plaintext. `keys` must be the key ring the segment was written under, or
140    /// `None` for a segment that was never encrypted — an encrypted record met
141    /// with no key is [`WalError::EncryptedRecordWithoutKey`], never ciphertext
142    /// handed to the caller.
143    pub fn open(path: &Path, keys: Option<&KeyRing>) -> Result<Self> {
144        let mut reader = Self::open_raw(path)?;
145        reader.decryptor = Some(SegmentDecryptor::new(
146            reader.segment_preamble.as_ref(),
147            keys,
148        ));
149        Ok(reader)
150    }
151
152    /// Open a WAL segment file for structural scanning only.
153    ///
154    /// **Payloads may be ciphertext.** Records come out exactly as they are on
155    /// disk, `ENCRYPTED_FLAG` and all. Use it only for code that reads record
156    /// framing and never the payload; replay must use [`Self::open`].
157    ///
158    /// A leading `WALP` preamble is skipped so iteration begins at the first
159    /// real record; segments written without encryption have none and start at
160    /// offset 0.
161    pub fn open_raw(path: &Path) -> Result<Self> {
162        observability::SEGMENTS_OPENED.fetch_add(1, Ordering::Relaxed);
163        let file = std::fs::File::open(path)?;
164        // SAFETY: The file is a sealed WAL segment (not being written to).
165        // The Data Plane writes to the ACTIVE segment via O_DIRECT; sealed
166        // segments are immutable after rollover.
167        let mmap = unsafe { Mmap::map(&file)? };
168
169        // Catchup iterates forward through a segment. MADV_SEQUENTIAL
170        // doubles readahead and drops already-consumed pages eagerly so
171        // replay doesn't grow buff/cache by the full WAL size.
172        let mut madvise_state = None;
173        if !mmap.is_empty() {
174            let rc = unsafe {
175                libc::madvise(
176                    mmap.as_ptr() as *mut libc::c_void,
177                    mmap.len(),
178                    libc::MADV_SEQUENTIAL,
179                )
180            };
181            if rc == 0 {
182                madvise_state = Some(libc::MADV_SEQUENTIAL);
183                observability::MADV_SEQUENTIAL_COUNT.fetch_add(1, Ordering::Relaxed);
184            } else {
185                tracing::warn!(
186                    path = %path.display(),
187                    errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
188                    "madvise(MADV_SEQUENTIAL) failed on WAL segment; continuing",
189                );
190            }
191        }
192
193        let segment_preamble = parse_leading_preamble(&mmap, &WAL_PREAMBLE_MAGIC)?;
194        let offset = if segment_preamble.is_some() {
195            PREAMBLE_SIZE
196        } else {
197            0
198        };
199
200        Ok(Self {
201            mmap,
202            offset,
203            records_start: offset,
204            segment_preamble,
205            file,
206            path: path.to_path_buf(),
207            madvise_state,
208            stop_reason: None,
209            decryptor: None,
210        })
211    }
212
213    /// Hand a record to the caller, decrypting it first unless this reader was
214    /// opened in raw structural mode.
215    fn deliver(&self, record: WalRecord) -> Result<Option<WalRecord>> {
216        match &self.decryptor {
217            Some(decryptor) => decryptor.decrypt_record(record).map(Some),
218            None => Ok(Some(record)),
219        }
220    }
221
222    /// The preamble read from this segment file, if present.
223    ///
224    /// Returns `None` for unencrypted segments (no preamble written).
225    pub fn segment_preamble(&self) -> Option<&SegmentPreamble> {
226        self.segment_preamble.as_ref()
227    }
228
229    /// Why iteration stopped, or `None` while records are still being read.
230    ///
231    /// Feed this to [`crate::torn_tail::verify_committed_prefix`] to tell an
232    /// unfsynced tail apart from a hole with committed records behind it.
233    pub fn stop_reason(&self) -> Option<StopReason> {
234        self.stop_reason
235    }
236
237    fn stop(&mut self, reason: StopReason) -> Result<Option<WalRecord>> {
238        self.stop_reason = Some(reason);
239        Ok(None)
240    }
241
242    /// The madvise hint applied to the mapped segment (if any).
243    pub fn madvise_state(&self) -> Option<libc::c_int> {
244        self.madvise_state
245    }
246
247    /// Hint to the kernel that pages for this segment can be dropped from
248    /// cache. Call this after a segment has been iterated end-to-end.
249    pub fn release_pages(&self) {
250        fadv_dontneed(&self.file, self.mmap.len(), &self.path);
251    }
252
253    /// Read the next record from the mmap'd region.
254    ///
255    /// Returns `None` at EOF or at the first corruption point. Returns `Err`
256    /// for a format version this build cannot read at the segment's first
257    /// record, and the caller must stop at that `Err`.
258    /// Header parsing avoids extra copies, but the payload is copied out of
259    /// the mmap'd region into an owned `Vec<u8>` on `WalRecord`: records must
260    /// outlive the reader (they cross thread boundaries in parallel replay
261    /// and get mutated in place during decryption), which a borrow of the
262    /// mapping cannot support.
263    pub fn next_record(&mut self) -> Result<Option<WalRecord>> {
264        let data = &self.mmap[..];
265
266        loop {
267            // Check if we have enough bytes for a header without unchecked
268            // offset arithmetic. A short trailing header is a torn tail.
269            let record_offset = self.offset;
270            let Some(header_end) = checked_range_end(self.offset, HEADER_SIZE, data.len())? else {
271                let reason = if record_offset == data.len() {
272                    StopReason::Eof
273                } else {
274                    StopReason::Corruption {
275                        offset: record_offset as u64,
276                    }
277                };
278                return self.stop(reason);
279            };
280
281            // Parse header.
282            let header_bytes: &[u8; HEADER_SIZE] = data
283                .get(self.offset..header_end)
284                .and_then(|bytes| bytes.try_into().ok())
285                .ok_or_else(|| {
286                    WalError::Io(std::io::Error::new(
287                        std::io::ErrorKind::InvalidData,
288                        "header slice conversion failed",
289                    ))
290                })?;
291            let header = RecordHeader::from_bytes(header_bytes);
292
293            // Validate magic — corruption or end of valid data.
294            if header.magic != WAL_MAGIC {
295                return self.stop(StopReason::Corruption {
296                    offset: record_offset as u64,
297                });
298            }
299
300            // Oversized declarations are explicit errors before allocation;
301            // ordinary malformed headers preserve committed-prefix semantics.
302            let header_offset = u64::try_from(self.offset).map_err(|_| {
303                WalError::Io(std::io::Error::new(
304                    std::io::ErrorKind::InvalidData,
305                    "mmap WAL offset does not fit u64",
306                ))
307            })?;
308            match header.validate(header_offset) {
309                Ok(()) => {}
310                Err(error @ WalError::PayloadTooLarge { .. }) => return Err(error),
311                // Another build's format travels out so the refusal can name
312                // the version. A damaged header in this build's format is a
313                // stop for `torn_tail` to classify, like any damage.
314                Err(error @ WalError::UnsupportedVersion { .. })
315                    if may_be_format_gap(&header, self.offset == self.records_start) =>
316                {
317                    let payload = usize::try_from(header.payload_len)
318                        .ok()
319                        .filter(|len| *len <= crate::record::MAX_WAL_PAYLOAD_SIZE)
320                        .and_then(|len| data.get(header_end..header_end.checked_add(len)?));
321                    if is_format_gap(&header, payload) {
322                        return Err(error);
323                    }
324                    return self.stop(StopReason::Corruption {
325                        offset: header_offset,
326                    });
327                }
328                Err(_) => {
329                    return self.stop(StopReason::Corruption {
330                        offset: header_offset,
331                    });
332                }
333            }
334
335            let payload_len =
336                usize::try_from(header.payload_len).map_err(|_| WalError::PayloadTooLarge {
337                    size: usize::MAX,
338                    max: crate::record::MAX_WAL_PAYLOAD_SIZE,
339                })?;
340            let Some(record_end) = checked_range_end(header_end, payload_len, data.len())? else {
341                // The header promised more payload than the segment holds.
342                return self.stop(StopReason::Corruption {
343                    offset: header_offset,
344                });
345            };
346
347            // Extract payload only after the bounded header validation above.
348            let payload = data
349                .get(header_end..record_end)
350                .ok_or_else(|| {
351                    WalError::Io(std::io::Error::new(
352                        std::io::ErrorKind::InvalidData,
353                        "payload slice conversion failed",
354                    ))
355                })?
356                .to_vec();
357            self.offset = record_end;
358
359            let record = WalRecord { header, payload };
360
361            // Verify checksum.
362            if record.verify_checksum().is_err() {
363                return self.stop(StopReason::Corruption {
364                    offset: header_offset,
365                });
366            }
367
368            // Check record type.
369            let logical_type = record.logical_record_type();
370            let Some(kind) = RecordType::from_raw(logical_type) else {
371                if RecordType::is_required(logical_type) {
372                    return Err(WalError::UnknownRequiredRecordType {
373                        record_type: header.record_type,
374                        lsn: header.lsn,
375                    });
376                }
377                // Unknown optional record — skip and continue loop.
378                continue;
379            };
380
381            // Alignment padding is framing, not data — never surface it.
382            if kind == RecordType::Noop {
383                continue;
384            }
385
386            return self.deliver(record);
387        }
388    }
389
390    /// Iterator over all valid records in the mmap'd segment.
391    pub fn records(self) -> MmapRecordIter {
392        MmapRecordIter { reader: self }
393    }
394
395    /// Current read offset.
396    pub fn offset(&self) -> usize {
397        self.offset
398    }
399
400    /// Total size of the mmap'd region.
401    pub fn len(&self) -> usize {
402        self.mmap.len()
403    }
404
405    /// Whether the mmap'd region is empty.
406    pub fn is_empty(&self) -> bool {
407        self.mmap.is_empty()
408    }
409}
410
411/// Iterator over records in a mmap'd WAL segment.
412pub struct MmapRecordIter {
413    reader: MmapWalReader,
414}
415
416impl Iterator for MmapRecordIter {
417    type Item = Result<WalRecord>;
418
419    fn next(&mut self) -> Option<Self::Item> {
420        match self.reader.next_record() {
421            Ok(Some(record)) => Some(Ok(record)),
422            Ok(None) => None,
423            Err(e) => Some(Err(e)),
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::record::RecordType;
432    use crate::writer::{WalWriter, WalWriterConfig};
433
434    fn test_writer(path: &Path) -> WalWriter {
435        let config = WalWriterConfig {
436            use_direct_io: false, // Tests run without O_DIRECT.
437            ..Default::default()
438        };
439        WalWriter::open(path, config).unwrap()
440    }
441
442    #[test]
443    fn mmap_reader_basic() {
444        let dir = tempfile::tempdir().unwrap();
445        let path = dir.path().join("test.wal");
446
447        // Write some records with the standard writer.
448        {
449            let mut writer = test_writer(&path);
450            writer
451                .append(RecordType::Put as u32, 1, 0, 0, b"hello")
452                .unwrap();
453            writer
454                .append(RecordType::Put as u32, 1, 0, 0, b"world")
455                .unwrap();
456            writer.sync().unwrap();
457        }
458
459        // Read back with mmap reader.
460        let reader = MmapWalReader::open(&path, None).unwrap();
461        assert!(
462            reader.segment_preamble().is_none(),
463            "unencrypted segments carry no preamble"
464        );
465        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
466
467        assert_eq!(records.len(), 2);
468        assert_eq!(records[0].payload, b"hello");
469        assert_eq!(records[1].payload, b"world");
470    }
471
472    #[test]
473    fn mmap_reader_empty_file() {
474        let dir = tempfile::tempdir().unwrap();
475        let path = dir.path().join("empty.wal");
476        std::fs::write(&path, []).unwrap();
477
478        let reader = MmapWalReader::open(&path, None).unwrap();
479        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
480        assert!(records.is_empty());
481    }
482
483    #[test]
484    fn mmap_reader_truncated_header() {
485        let dir = tempfile::tempdir().unwrap();
486        let path = dir.path().join("truncated.wal");
487        // Write 10 bytes — not enough for a header (30 bytes).
488        std::fs::write(&path, [0u8; 10]).unwrap();
489
490        let reader = MmapWalReader::open(&path, None).unwrap();
491        let records: Vec<WalRecord> = reader.records().collect::<Result<Vec<_>>>().unwrap();
492        assert!(records.is_empty());
493    }
494
495    #[test]
496    fn mmap_reader_truncated_payload_stops_at_committed_prefix() {
497        let dir = tempfile::tempdir().unwrap();
498        let path = dir.path().join("truncated-payload.wal");
499        let header = RecordHeader {
500            magic: WAL_MAGIC,
501            format_version: crate::record::WAL_FORMAT_VERSION,
502            record_type: RecordType::Put as u32,
503            lsn: 1,
504            tenant_id: 1,
505            vshard_id: 0,
506            payload_len: 1,
507            database_id: 0,
508            apply_key: 0,
509            event_source: crate::record::NO_EVENT_SOURCE,
510            commit_hlc: 0,
511            crc32c: 0,
512        };
513        std::fs::write(&path, header.to_bytes()).unwrap();
514
515        let mut reader = MmapWalReader::open(&path, None).unwrap();
516        assert!(reader.next_record().unwrap().is_none());
517    }
518
519    #[test]
520    fn oversized_payload_header_is_a_typed_error_before_allocation() {
521        let dir = tempfile::tempdir().unwrap();
522        let path = dir.path().join("oversized.wal");
523        let header = RecordHeader {
524            magic: WAL_MAGIC,
525            format_version: crate::record::WAL_FORMAT_VERSION,
526            record_type: RecordType::Put as u32,
527            lsn: 1,
528            tenant_id: 1,
529            vshard_id: 0,
530            payload_len: (crate::record::MAX_WAL_PAYLOAD_SIZE + 1) as u32,
531            database_id: 0,
532            apply_key: 0,
533            event_source: crate::record::NO_EVENT_SOURCE,
534            commit_hlc: 0,
535            crc32c: 0,
536        };
537        std::fs::write(&path, header.to_bytes()).unwrap();
538
539        let mut reader = MmapWalReader::open(&path, None).unwrap();
540        assert!(matches!(
541            reader.next_record(),
542            Err(WalError::PayloadTooLarge { .. })
543        ));
544    }
545
546    /// A version this build cannot read at the segment's first record is a
547    /// typed error that names the version.
548    #[test]
549    fn an_unknown_format_version_at_the_first_record_is_an_error() {
550        let dir = tempfile::tempdir().unwrap();
551        let path = dir.path().join("unknown.wal");
552        {
553            let mut writer = test_writer(&path);
554            writer
555                .append(RecordType::Put as u32, 1, 0, 0, b"x")
556                .unwrap();
557            writer.sync().unwrap();
558        }
559
560        let mut bytes = std::fs::read(&path).unwrap();
561        crate::record::foreign_version::reseal_with_version(
562            &mut bytes,
563            0,
564            crate::record::WAL_FORMAT_VERSION + 1,
565        );
566        std::fs::write(&path, &bytes).unwrap();
567
568        let mut reader = MmapWalReader::open(&path, None).unwrap();
569        match reader.next_record() {
570            Err(WalError::UnsupportedVersion { version, supported }) => {
571                assert_eq!(version, crate::record::WAL_FORMAT_VERSION + 1);
572                assert_eq!(supported, crate::record::WAL_FORMAT_VERSION);
573            }
574            other => panic!("expected UnsupportedVersion, got {other:?}"),
575        }
576    }
577
578    /// A version this build cannot read, found after an intact record, is
579    /// damage: the stream stops there as corruption, not as a format gap.
580    #[test]
581    fn a_version_mismatch_after_the_first_record_is_damage() {
582        let dir = tempfile::tempdir().unwrap();
583        let path = dir.path().join("two-records.wal");
584        {
585            let mut writer = test_writer(&path);
586            writer
587                .append(RecordType::Put as u32, 1, 0, 0, b"first")
588                .unwrap();
589            writer
590                .append(RecordType::Put as u32, 1, 0, 0, b"second")
591                .unwrap();
592            writer.sync().unwrap();
593        }
594
595        let second_at = {
596            let mut reader = MmapWalReader::open(&path, None).unwrap();
597            reader
598                .next_record()
599                .unwrap()
600                .expect("the first record reads");
601            reader.offset()
602        };
603
604        let mut bytes = std::fs::read(&path).unwrap();
605        let version_at = second_at + 4;
606        bytes[version_at..version_at + 2]
607            .copy_from_slice(&(crate::record::WAL_FORMAT_VERSION + 1).to_le_bytes());
608        std::fs::write(&path, &bytes).unwrap();
609
610        let mut reader = MmapWalReader::open(&path, None).unwrap();
611        let first = reader
612            .next_record()
613            .unwrap()
614            .expect("the first record is still readable");
615        assert_eq!(first.header.lsn, 1);
616        assert!(
617            reader.next_record().unwrap().is_none(),
618            "the mismatch stops the stream instead of failing the read"
619        );
620        let second_at = u64::try_from(second_at).unwrap();
621        assert_eq!(
622            reader.stop_reason(),
623            Some(StopReason::Corruption { offset: second_at }),
624            "a mismatch after the first record is damage, not a format gap"
625        );
626    }
627
628    #[test]
629    fn checked_range_end_rejects_overflow_and_short_ranges() {
630        assert!(checked_range_end(usize::MAX, 1, usize::MAX).is_err());
631        assert_eq!(checked_range_end(5, 2, 6).unwrap(), None);
632    }
633}