Skip to main content

nodedb_wal/
lazy_reader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Lazy WAL reader: reads headers without payload for selective replay.
4//!
5//! Unlike `WalReader` which reads every payload into a `Vec<u8>`, this
6//! reader reads only the 30-byte header first. The caller inspects the
7//! header (record_type, vshard_id, lsn) and decides whether to read or
8//! skip the payload.
9//!
10//! This is critical for startup replay performance: with 100M timeseries
11//! records, a vector core can skip TS payloads (potentially GBs) by
12//! seeking forward instead of allocating and reading.
13//!
14//! ## Usage
15//!
16//! ```text
17//! let mut reader = LazyWalReader::open(path, keys)?;
18//! while let Some(header) = reader.next_header()? {
19//!     if header.record_type == RecordType::VectorPut as u32 {
20//!         let payload = reader.read_payload(&header)?;
21//!         // process vector record — always plaintext
22//!     } else {
23//!         reader.skip_payload(&header)?;
24//!     }
25//! }
26//! ```
27//!
28//! ## Encryption
29//!
30//! The key ring is supplied at `open` time and applied inside `read_payload`,
31//! so a caller that reads a payload can never observe ciphertext: an encrypted
32//! record opened without a ring is a hard error, not a passthrough.
33
34use std::fs::File;
35use std::io::{Read, Seek, SeekFrom};
36use std::path::Path;
37
38use crate::crypto::KeyRing;
39use crate::error::{Result, WalError};
40use crate::preamble::{SegmentPreamble, WAL_PREAMBLE_MAGIC, read_leading_preamble};
41use crate::reader::StopReason;
42use crate::record::foreign_version::read_declared_payload;
43use crate::record::{
44    HEADER_SIZE, MAX_WAL_PAYLOAD_SIZE, RecordHeader, RecordType, WalRecord, is_format_gap,
45    may_be_format_gap,
46};
47use crate::segment::SegmentDecryptor;
48
49fn checked_offset_add(offset: u64, len: u64) -> Result<u64> {
50    offset.checked_add(len).ok_or_else(|| {
51        WalError::Io(std::io::Error::new(
52            std::io::ErrorKind::InvalidData,
53            "WAL lazy-reader offset overflow",
54        ))
55    })
56}
57
58/// Lazy WAL reader that separates header reading from payload reading.
59pub struct LazyWalReader {
60    file: File,
61    offset: u64,
62    /// Offset of the segment's first record, past any preamble. The format
63    /// version is judged here and nowhere else: one writer never mixes formats
64    /// inside one segment.
65    records_start: u64,
66    /// Preamble read from offset 0 of this segment (present when encryption is
67    /// active). The epoch is part of the AAD used to decrypt payloads.
68    segment_preamble: Option<SegmentPreamble>,
69    double_write: Option<crate::double_write::DoubleWriteBuffer>,
70    stop_reason: Option<StopReason>,
71    /// Turns encrypted payloads back into plaintext. Built once at open so the
72    /// reader carries no borrow of the caller's ring, and so the per-segment
73    /// AAD is derived once rather than on every payload read.
74    decryptor: SegmentDecryptor,
75}
76
77impl LazyWalReader {
78    /// Open a WAL file for lazy reading.
79    ///
80    /// A leading `WALP` preamble is consumed so headers are read from the first
81    /// real record; segments written without encryption have none and start at
82    /// offset 0.
83    ///
84    /// `keys` must be the key ring this segment was written under, or `None`
85    /// for a segment that was never encrypted.
86    pub fn open(path: &Path, keys: Option<&KeyRing>) -> Result<Self> {
87        let mut file = File::open(path)?;
88        let dwb_path = path.with_extension("dwb");
89        let double_write = if dwb_path.exists() {
90            crate::double_write::DoubleWriteBuffer::open(
91                &dwb_path,
92                crate::double_write::DwbMode::Buffered,
93            )
94            .ok()
95        } else {
96            None
97        };
98        let (segment_preamble, start_offset) =
99            read_leading_preamble(&mut file, &WAL_PREAMBLE_MAGIC)?;
100        let decryptor = SegmentDecryptor::new(segment_preamble.as_ref(), keys);
101
102        Ok(Self {
103            file,
104            offset: start_offset,
105            records_start: start_offset,
106            segment_preamble,
107            double_write,
108            stop_reason: None,
109            decryptor,
110        })
111    }
112
113    /// The preamble read from this segment file, if present.
114    ///
115    /// Returns `None` for unencrypted segments (no preamble written).
116    pub fn segment_preamble(&self) -> Option<&SegmentPreamble> {
117        self.segment_preamble.as_ref()
118    }
119
120    /// Why iteration stopped, or `None` while headers are still being read.
121    ///
122    /// Feed this to [`crate::torn_tail::verify_committed_prefix`] to tell an
123    /// unfsynced tail apart from a hole with committed records behind it.
124    pub fn stop_reason(&self) -> Option<StopReason> {
125        self.stop_reason
126    }
127
128    fn stop(&mut self, reason: StopReason) -> Result<Option<RecordHeader>> {
129        self.stop_reason = Some(reason);
130        Ok(None)
131    }
132
133    /// Read the next record header without reading the payload.
134    ///
135    /// Returns `None` at EOF or first corruption. Returns `Err` for a format
136    /// version this build cannot read at the segment's first record, and the
137    /// caller must stop at that `Err`. After a header, use either
138    /// `read_payload()` to get the payload or `skip_payload()` to seek past it.
139    ///
140    /// Alignment padding records are consumed internally — the caller only
141    /// ever sees real records.
142    pub fn next_header(&mut self) -> Result<Option<RecordHeader>> {
143        loop {
144            let record_offset = self.offset;
145            let mut header_buf = [0u8; HEADER_SIZE];
146            match self.read_exact(&mut header_buf) {
147                Ok(()) => {}
148                Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
149                    let reason = if record_offset == self.file.metadata()?.len() {
150                        StopReason::Eof
151                    } else {
152                        StopReason::Corruption {
153                            offset: record_offset,
154                        }
155                    };
156                    return self.stop(reason);
157                }
158                Err(e) => return Err(e),
159            }
160
161            let header = RecordHeader::from_bytes(&header_buf);
162
163            match header.validate(record_offset) {
164                Ok(()) => {}
165                Err(error @ WalError::PayloadTooLarge { .. }) => return Err(error),
166                // Another build's format travels out so the refusal can name
167                // the version. A damaged header in this build's format is a
168                // stop for `torn_tail` to classify, like any damage.
169                Err(error @ WalError::UnsupportedVersion { .. })
170                    if may_be_format_gap(&header, record_offset == self.records_start) =>
171                {
172                    let payload = read_declared_payload(&mut self.file, &header, self.offset)?;
173                    if is_format_gap(&header, payload.as_deref()) {
174                        return Err(error);
175                    }
176                    return self.stop(StopReason::Corruption {
177                        offset: record_offset,
178                    });
179                }
180                Err(_) => {
181                    return self.stop(StopReason::Corruption {
182                        offset: record_offset,
183                    });
184                }
185            }
186
187            // Check for unknown required record types.
188            let logical_type = header.logical_record_type();
189            match RecordType::from_raw(logical_type) {
190                // Alignment padding is framing, not data: step over it and
191                // keep going so batches after the first stay reachable.
192                Some(RecordType::Noop) => {
193                    self.skip_payload(&header)?;
194                    continue;
195                }
196                Some(_) => {}
197                None if RecordType::is_required(logical_type) => {
198                    return Err(WalError::UnknownRequiredRecordType {
199                        record_type: header.record_type,
200                        lsn: header.lsn,
201                    });
202                }
203                None => {}
204            }
205
206            return Ok(Some(header));
207        }
208    }
209
210    /// Read the payload for a header that was just returned by `next_header()`,
211    /// decrypting it if the record is encrypted.
212    ///
213    /// Must be called exactly once after `next_header()` returns `Some`,
214    /// and before calling `next_header()` again (unless `skip_payload()`
215    /// was called instead).
216    pub fn read_payload(&mut self, header: &RecordHeader) -> Result<Vec<u8>> {
217        let raw = self.read_raw_payload(header)?;
218        self.decryptor.decrypt_payload(header, raw)
219    }
220
221    /// Read the on-disk payload bytes, repairing a torn write from the
222    /// double-write buffer when possible. The result is still ciphertext for an
223    /// encrypted record — only [`Self::read_payload`] is public so no caller
224    /// can stop here.
225    fn read_raw_payload(&mut self, header: &RecordHeader) -> Result<Vec<u8>> {
226        header.validate(self.offset)?;
227        let payload_len =
228            usize::try_from(header.payload_len).map_err(|_| WalError::PayloadTooLarge {
229                size: usize::MAX,
230                max: MAX_WAL_PAYLOAD_SIZE,
231            })?;
232        let mut payload = vec![0u8; payload_len];
233        if !payload.is_empty() {
234            match self.read_exact(&mut payload) {
235                Ok(()) => {}
236                Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
237                    return Err(WalError::Io(std::io::Error::new(
238                        std::io::ErrorKind::UnexpectedEof,
239                        "torn write: incomplete payload",
240                    )));
241                }
242                Err(e) => return Err(e),
243            }
244        }
245
246        // Verify checksum without cloning the payload: constructing a WalRecord
247        // just to call verify_checksum would allocate a duplicate buffer that
248        // is thrown away immediately after.
249        let checksum_ok = header.crc32c == header.compute_checksum(&payload);
250        if !checksum_ok {
251            // Try double-write buffer recovery.
252            if let Some(dwb) = &mut self.double_write
253                && let Ok(Some(recovered)) = dwb.recover_record(header.lsn)
254            {
255                // The torn header's `payload_len` decided how far the read
256                // above advanced, and it cannot be trusted. Re-anchor on the
257                // DWB's authoritative length so the next header is read from
258                // the right place instead of from inside a record body.
259                // `payload_len` and `recovered.payload.len()` are `usize`,
260                // which always fits `u64` on every supported target, so this
261                // widening conversion cannot fail.
262                let payload_start =
263                    self.offset.checked_sub(payload_len as u64).ok_or_else(|| {
264                        WalError::Io(std::io::Error::new(
265                            std::io::ErrorKind::InvalidData,
266                            "WAL lazy-reader payload offset underflow",
267                        ))
268                    })?;
269                let resume = checked_offset_add(payload_start, recovered.payload.len() as u64)?;
270                self.file.seek(SeekFrom::Start(resume))?;
271                self.offset = resume;
272                return Ok(recovered.payload);
273            }
274            return Err(WalError::Io(std::io::Error::new(
275                std::io::ErrorKind::InvalidData,
276                "checksum mismatch",
277            )));
278        }
279
280        Ok(payload)
281    }
282
283    /// Read the payload and return a full plaintext WalRecord.
284    ///
285    /// The returned record is indistinguishable from one written without
286    /// encryption: the flag is cleared and the CRC is recomputed, so downstream
287    /// `logical_record_type()` dispatch and `verify_checksum()` both hold.
288    pub fn read_record(&mut self, header: &RecordHeader) -> Result<WalRecord> {
289        let raw = self.read_raw_payload(header)?;
290        let record = WalRecord {
291            header: *header,
292            payload: raw,
293        };
294        self.decryptor.decrypt_record(record)
295    }
296
297    /// Skip the payload for a header, seeking forward without reading.
298    ///
299    /// This is the key optimization: non-matching records skip I/O entirely.
300    pub fn skip_payload(&mut self, header: &RecordHeader) -> Result<()> {
301        header.validate(self.offset)?;
302        let len = u64::from(header.payload_len);
303        let target = checked_offset_add(self.offset, len)?;
304        let file_len = self.file.metadata()?.len();
305        if target > file_len {
306            return Err(WalError::Io(std::io::Error::new(
307                std::io::ErrorKind::UnexpectedEof,
308                "torn write: incomplete payload while skipping",
309            )));
310        }
311        if len > 0 {
312            let signed_len = i64::try_from(len).map_err(|_| {
313                WalError::Io(std::io::Error::new(
314                    std::io::ErrorKind::InvalidData,
315                    "WAL lazy-reader skip length does not fit i64",
316                ))
317            })?;
318            let position = self.file.seek(SeekFrom::Current(signed_len))?;
319            if position != target {
320                return Err(WalError::Io(std::io::Error::new(
321                    std::io::ErrorKind::InvalidData,
322                    "WAL lazy-reader seek did not reach bounded target",
323                )));
324            }
325            self.offset = target;
326        }
327        Ok(())
328    }
329
330    /// Current file offset.
331    pub fn offset(&self) -> u64 {
332        self.offset
333    }
334
335    fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
336        let len = u64::try_from(buf.len()).map_err(|_| {
337            WalError::Io(std::io::Error::new(
338                std::io::ErrorKind::InvalidData,
339                "WAL lazy-reader read length does not fit u64",
340            ))
341        })?;
342        let next_offset = checked_offset_add(self.offset, len)?;
343        self.file.read_exact(buf)?;
344        self.offset = next_offset;
345        Ok(())
346    }
347}
348
349/// Open a WAL segment for lazy reading and iterate with a callback.
350///
351/// Convenience function for single-pass replay: the callback receives each
352/// header and decides whether to read or skip the payload. Payloads the
353/// callback reads are plaintext; see [`LazyWalReader::open`] for `keys`.
354pub fn replay_segment_lazy<F>(path: &Path, keys: Option<&KeyRing>, handler: F) -> Result<()>
355where
356    F: FnMut(&mut LazyWalReader, &RecordHeader) -> Result<()>,
357{
358    replay_one_segment(path, keys, handler).map(|_| ())
359}
360
361/// Replay a single segment and report the highest LSN it contains.
362///
363/// A reader stops at the first byte it cannot parse. That is the legal end of
364/// the log only when nothing committed lies behind it, so the stop point is
365/// classified before the segment is accepted as complete.
366fn replay_one_segment<F>(path: &Path, keys: Option<&KeyRing>, mut handler: F) -> Result<u64>
367where
368    F: FnMut(&mut LazyWalReader, &RecordHeader) -> Result<()>,
369{
370    let mut reader = LazyWalReader::open(path, keys)?;
371    let mut last_lsn = 0u64;
372    while let Some(header) = reader
373        .next_header()
374        .map_err(|error| error.with_segment_path(path))?
375    {
376        last_lsn = header.lsn;
377        handler(&mut reader, &header)?;
378    }
379    crate::torn_tail::verify_committed_prefix(path, reader.stop_reason(), last_lsn)?;
380    Ok(last_lsn)
381}
382
383/// Replay all WAL segments in a directory with lazy reading.
384///
385/// Segments are read in LSN order. The callback decides per-record whether
386/// to read or skip the payload; payloads it reads are plaintext.
387pub fn replay_all_segments_lazy<F>(
388    wal_dir: &Path,
389    keys: Option<&KeyRing>,
390    mut handler: F,
391) -> Result<()>
392where
393    F: FnMut(&mut LazyWalReader, &RecordHeader) -> Result<()>,
394{
395    let segments = crate::segment::discover_segments(wal_dir)?;
396    let mut continuity = crate::segment::SegmentContinuity::new();
397    for seg in &segments {
398        continuity.check(seg)?;
399        let last_lsn = replay_one_segment(&seg.path, keys, &mut handler)?;
400        continuity.completed(seg, last_lsn);
401    }
402    Ok(())
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::record::RecordType;
409    use crate::writer::WalWriter;
410
411    #[test]
412    fn lazy_read_all_payloads() {
413        let dir = tempfile::tempdir().unwrap();
414        let path = dir.path().join("test.wal");
415
416        {
417            let mut w = WalWriter::open_without_direct_io(&path).unwrap();
418            w.append(RecordType::Put as u32, 1, 0, 0, b"hello").unwrap();
419            w.append(RecordType::VectorPut as u32, 1, 0, 0, b"vector-data")
420                .unwrap();
421            w.append(RecordType::Put as u32, 2, 1, 0, b"world").unwrap();
422            w.sync().unwrap();
423        }
424
425        let mut reader = LazyWalReader::open(&path, None).unwrap();
426        let mut records = Vec::new();
427        while let Some(header) = reader.next_header().unwrap() {
428            let payload = reader.read_payload(&header).unwrap();
429            records.push((header, payload));
430        }
431        assert_eq!(records.len(), 3);
432        assert_eq!(records[0].1, b"hello");
433        assert_eq!(records[1].1, b"vector-data");
434        assert_eq!(records[2].1, b"world");
435    }
436
437    #[test]
438    fn lazy_skip_non_matching() {
439        let dir = tempfile::tempdir().unwrap();
440        let path = dir.path().join("test.wal");
441
442        {
443            let mut w = WalWriter::open_without_direct_io(&path).unwrap();
444            // 3 big TS records, 1 small vector record.
445            w.append(RecordType::TimeseriesBatch as u32, 1, 0, 0, &[0u8; 10000])
446                .unwrap();
447            w.append(RecordType::TimeseriesBatch as u32, 1, 0, 0, &[0u8; 10000])
448                .unwrap();
449            w.append(RecordType::VectorPut as u32, 1, 0, 0, b"small-vec")
450                .unwrap();
451            w.append(RecordType::TimeseriesBatch as u32, 1, 0, 0, &[0u8; 10000])
452                .unwrap();
453            w.sync().unwrap();
454        }
455
456        // A "vector core" reads only VectorPut, skips TimeseriesBatch.
457        let mut reader = LazyWalReader::open(&path, None).unwrap();
458        let mut vector_payloads = Vec::new();
459        let mut skipped = 0;
460
461        while let Some(header) = reader.next_header().unwrap() {
462            let rt = RecordType::from_raw(header.record_type);
463            if rt == Some(RecordType::VectorPut) {
464                let payload = reader.read_payload(&header).unwrap();
465                vector_payloads.push(payload);
466            } else {
467                reader.skip_payload(&header).unwrap();
468                skipped += 1;
469            }
470        }
471
472        assert_eq!(vector_payloads.len(), 1);
473        assert_eq!(vector_payloads[0], b"small-vec");
474        assert_eq!(skipped, 3);
475    }
476
477    #[test]
478    fn oversized_payload_header_is_a_typed_error_before_skip_or_allocation() {
479        let dir = tempfile::tempdir().unwrap();
480        let path = dir.path().join("oversized.wal");
481        let header = RecordHeader {
482            magic: crate::record::WAL_MAGIC,
483            format_version: crate::record::WAL_FORMAT_VERSION,
484            record_type: RecordType::Put as u32,
485            lsn: 1,
486            tenant_id: 1,
487            vshard_id: 0,
488            payload_len: (MAX_WAL_PAYLOAD_SIZE + 1) as u32,
489            database_id: 0,
490            apply_key: 0,
491            event_source: crate::record::NO_EVENT_SOURCE,
492            commit_hlc: 0,
493            crc32c: 0,
494        };
495        std::fs::write(&path, header.to_bytes()).unwrap();
496
497        let mut reader = LazyWalReader::open(&path, None).unwrap();
498        assert!(matches!(
499            reader.next_header(),
500            Err(WalError::PayloadTooLarge { .. })
501        ));
502    }
503
504    #[test]
505    fn skip_payload_rejects_truncated_tail_without_seeking_beyond_eof() {
506        let dir = tempfile::tempdir().unwrap();
507        let path = dir.path().join("truncated.wal");
508        let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
509        writer
510            .append(RecordType::Put as u32, 1, 0, 0, b"payload")
511            .unwrap();
512        writer.sync().unwrap();
513        drop(writer);
514        let length = std::fs::metadata(&path).unwrap().len();
515        std::fs::OpenOptions::new()
516            .write(true)
517            .open(&path)
518            .unwrap()
519            .set_len(length - 1)
520            .unwrap();
521
522        let mut reader = LazyWalReader::open(&path, None).unwrap();
523        let header = reader.next_header().unwrap().expect("header");
524        assert!(
525            matches!(reader.skip_payload(&header), Err(WalError::Io(error)) if error.kind() == std::io::ErrorKind::UnexpectedEof)
526        );
527        assert_eq!(reader.offset(), HEADER_SIZE as u64);
528    }
529
530    #[test]
531    fn checked_offset_add_rejects_overflow() {
532        assert!(checked_offset_add(u64::MAX, 1).is_err());
533    }
534
535    #[test]
536    fn replay_all_segments_lazy_works() {
537        let dir = tempfile::tempdir().unwrap();
538        // Use proper segment filename pattern: wal-{lsn:020}.seg
539        let path = dir.path().join("wal-00000000000000000001.seg");
540
541        {
542            let mut w = WalWriter::open_without_direct_io(&path).unwrap();
543            w.append(RecordType::Put as u32, 1, 0, 0, b"a").unwrap();
544            w.append(RecordType::Put as u32, 1, 0, 0, b"b").unwrap();
545            w.sync().unwrap();
546        }
547
548        let mut count = 0;
549        replay_all_segments_lazy(dir.path(), None, |reader, header| {
550            reader.skip_payload(header)?;
551            count += 1;
552            Ok(())
553        })
554        .unwrap();
555        assert_eq!(count, 2);
556    }
557
558    #[test]
559    fn empty_wal_no_records() {
560        let dir = tempfile::tempdir().unwrap();
561        let path = dir.path().join("empty.wal");
562
563        {
564            let mut w = WalWriter::open_without_direct_io(&path).unwrap();
565            w.sync().unwrap();
566        }
567
568        let mut reader = LazyWalReader::open(&path, None).unwrap();
569        assert!(reader.next_header().unwrap().is_none());
570    }
571
572    /// A version this build cannot read at the segment's first record is a
573    /// typed error that names the version.
574    #[test]
575    fn an_unknown_format_version_at_the_first_record_is_an_error() {
576        let dir = tempfile::tempdir().unwrap();
577        let path = dir.path().join("unknown.wal");
578        {
579            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
580            writer
581                .append(RecordType::Put as u32, 1, 0, 0, b"x")
582                .unwrap();
583            writer.sync().unwrap();
584        }
585
586        let mut bytes = std::fs::read(&path).unwrap();
587        crate::record::foreign_version::reseal_with_version(
588            &mut bytes,
589            0,
590            crate::record::WAL_FORMAT_VERSION + 1,
591        );
592        std::fs::write(&path, &bytes).unwrap();
593
594        let mut reader = LazyWalReader::open(&path, None).unwrap();
595        match reader.next_header() {
596            Err(WalError::UnsupportedVersion { version, supported }) => {
597                assert_eq!(version, crate::record::WAL_FORMAT_VERSION + 1);
598                assert_eq!(supported, crate::record::WAL_FORMAT_VERSION);
599            }
600            other => panic!("expected UnsupportedVersion, got {other:?}"),
601        }
602    }
603
604    /// A version this build cannot read, found after an intact record, is
605    /// damage: the stream stops there as corruption, not as a format gap.
606    #[test]
607    fn a_version_mismatch_after_the_first_record_is_damage() {
608        let dir = tempfile::tempdir().unwrap();
609        let path = dir.path().join("two-records.wal");
610        {
611            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
612            writer
613                .append(RecordType::Put as u32, 1, 0, 0, b"first")
614                .unwrap();
615            writer
616                .append(RecordType::Put as u32, 1, 0, 0, b"second")
617                .unwrap();
618            writer.sync().unwrap();
619        }
620
621        let mut reader = LazyWalReader::open(&path, None).unwrap();
622        let first = reader.next_header().unwrap().expect("the first header");
623        reader.skip_payload(&first).unwrap();
624        let second_at = reader.offset();
625
626        let mut bytes = std::fs::read(&path).unwrap();
627        let version_at = usize::try_from(second_at).unwrap() + 4;
628        bytes[version_at..version_at + 2]
629            .copy_from_slice(&(crate::record::WAL_FORMAT_VERSION + 1).to_le_bytes());
630        std::fs::write(&path, &bytes).unwrap();
631
632        let mut reader = LazyWalReader::open(&path, None).unwrap();
633        let first = reader
634            .next_header()
635            .unwrap()
636            .expect("the first record is still readable");
637        assert_eq!(first.lsn, 1);
638        reader.skip_payload(&first).unwrap();
639        assert!(
640            reader.next_header().unwrap().is_none(),
641            "the mismatch stops the stream instead of failing the read"
642        );
643        assert_eq!(
644            reader.stop_reason(),
645            Some(StopReason::Corruption { offset: second_at }),
646            "a mismatch after the first record is damage, not a format gap"
647        );
648    }
649}