Skip to main content

nodedb_wal/segment/
continuity.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Cross-segment LSN continuity for multi-segment replay.
4//!
5//! Within a segment, a stop point with committed records behind it is caught by
6//! [`crate::torn_tail::verify_committed_prefix`]. The same class of silent data
7//! loss exists one level up: replay concatenates the records of every segment
8//! in the directory, so a segment file that has gone missing from the middle of
9//! the log produces a record stream with a hole in it and no error anywhere.
10//!
11//! Segments are written in strictly increasing LSN order, so the boundary is
12//! checkable: the next segment's declared first LSN must not be above the LSN
13//! that follows the last record of the previous one.
14//!
15//! Two things are deliberately *not* holes:
16//!
17//! - **A missing prefix.** Checkpoint truncation deletes whole segments below
18//!   the checkpoint by design, so the first surviving segment may start
19//!   anywhere. Only boundaries between surviving segments are checked.
20//! - **A segment with no records.** A rollover that was never written to says
21//!   nothing about continuity, so the boundary carries over from the last
22//!   segment that actually held records.
23
24use std::path::PathBuf;
25
26use crate::error::{Result, WalError};
27
28use super::meta::SegmentMeta;
29
30/// Running LSN boundary between the segments replayed so far.
31#[derive(Debug, Default)]
32pub struct SegmentContinuity {
33    /// Path and highest LSN of the most recent segment that was read to
34    /// completion and contained at least one record.
35    previous: Option<(PathBuf, u64)>,
36}
37
38impl SegmentContinuity {
39    /// Start a fresh check with no established boundary.
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Verify that `segment` continues the log where the previous completed
45    /// segment left off. Call this before reading `segment`.
46    pub fn check(&self, segment: &SegmentMeta) -> Result<()> {
47        let Some((previous_path, previous_last_lsn)) = &self.previous else {
48            return Ok(());
49        };
50
51        let expected_lsn = previous_last_lsn.saturating_add(1);
52        if segment.first_lsn > expected_lsn {
53            let err = WalError::SegmentLsnGap {
54                path: segment.path.display().to_string(),
55                previous_path: previous_path.display().to_string(),
56                previous_last_lsn: *previous_last_lsn,
57                expected_lsn,
58                found_lsn: segment.first_lsn,
59            };
60            // The boundary check is the only place that can see the hole: a
61            // single segment says nothing about the one that should precede it.
62            crate::diag::segment_lsn_gap(
63                &err,
64                &segment.path,
65                previous_path,
66                *previous_last_lsn,
67                expected_lsn,
68                segment.first_lsn,
69            );
70            return Err(err);
71        }
72
73        Ok(())
74    }
75
76    /// Record that `segment` was read all the way through, ending at
77    /// `last_lsn`. Call this only after the segment has been fully consumed —
78    /// a replay that stopped early (a record limit) has learned nothing about
79    /// where the segment ends.
80    ///
81    /// `last_lsn` must be the highest LSN the segment *contains*, not the
82    /// highest one the caller kept: LSN filtering changes what is returned, not
83    /// what is on disk. A `last_lsn` of 0 means the segment held no records.
84    pub fn completed(&mut self, segment: &SegmentMeta, last_lsn: u64) {
85        if last_lsn != 0 {
86            self.previous = Some((segment.path.clone(), last_lsn));
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::path::Path;
95
96    fn meta(first_lsn: u64) -> SegmentMeta {
97        SegmentMeta {
98            path: Path::new("/wal").join(super::super::segment_filename(first_lsn)),
99            first_lsn,
100            file_size: 0,
101        }
102    }
103
104    #[test]
105    fn first_segment_may_start_anywhere() {
106        let continuity = SegmentContinuity::new();
107        assert!(continuity.check(&meta(5000)).is_ok());
108    }
109
110    #[test]
111    fn contiguous_segments_pass() {
112        let mut continuity = SegmentContinuity::new();
113        let first = meta(1);
114        continuity.check(&first).unwrap();
115        continuity.completed(&first, 42);
116        assert!(continuity.check(&meta(43)).is_ok());
117    }
118
119    #[test]
120    fn gap_between_segments_is_rejected() {
121        let mut continuity = SegmentContinuity::new();
122        let first = meta(1);
123        continuity.completed(&first, 42);
124        match continuity.check(&meta(90)) {
125            Err(WalError::SegmentLsnGap {
126                expected_lsn,
127                found_lsn,
128                ..
129            }) => {
130                assert_eq!(expected_lsn, 43);
131                assert_eq!(found_lsn, 90);
132            }
133            other => panic!("expected a segment LSN gap, got {other:?}"),
134        }
135    }
136
137    #[test]
138    fn empty_segment_keeps_the_previous_boundary() {
139        let mut continuity = SegmentContinuity::new();
140        let first = meta(1);
141        continuity.completed(&first, 42);
142        let empty = meta(43);
143        continuity.check(&empty).unwrap();
144        continuity.completed(&empty, 0);
145        assert!(continuity.check(&meta(43)).is_ok());
146        assert!(continuity.check(&meta(90)).is_err());
147    }
148}