Skip to main content

krabka_raft/controller/
checkpoint.rs

1//! The on-disk KIP-630 metadata checkpoints: the `FetchSnapshot` answer that
2//! the engine gives a broker listener, the byte window of the newest
3//! `<end_offset>-<epoch>.checkpoint`, the read of that file, and the
4//! manual snapshot trigger. The checkpoint file layout is read straight from
5//! disk here rather than through the engine, so it is kept apart from the rest
6//! of the handle.
7
8use {
9    super::ControllerHandle,
10    crate::{
11        error::RaftError,
12        kraft::{
13            controller::checkpoint::{
14                CheckpointChunk, latest_checkpoint_id, load_checkpoint_by_id, open_checkpoint,
15            },
16            snapshot_fetch::SnapshotId,
17            transport::Inbound,
18        },
19    },
20    std::path::Path,
21    tokio::sync::oneshot,
22};
23
24/// A contiguous byte window of the latest metadata `.checkpoint`, returned by
25/// [`ControllerHandle::read_snapshot_range`].
26#[derive(Debug, PartialEq, Eq)]
27pub struct SnapshotSlice {
28    pub end_offset: i64,
29    pub epoch: i32,
30    pub total_size: i64,
31    pub bytes: bytes::Bytes,
32}
33
34/// Outcome of [`ControllerHandle::read_snapshot_range`].
35pub enum SnapshotRange {
36    /// No `.checkpoint` exists yet.
37    NoSnapshot,
38    /// `position` is at or past the snapshot's end byte, which Kafka's
39    /// `handleFetchSnapshotRequest` answers with `POSITION_OUT_OF_RANGE`.
40    OutOfRange,
41    /// The requested byte window.
42    Slice(SnapshotSlice),
43}
44
45impl ControllerHandle {
46    /// Read up to `max_bytes` of the latest metadata snapshot starting at
47    /// `position`. Reads the engine's `.checkpoint` artifacts directly (the
48    /// engine writes a bare KIP-630 checkpoint, no `.meta` sidecar).
49    ///
50    /// `position` and `max_bytes` stay the raw KIP-595 `FetchSnapshot` `int64`
51    /// and `int32`: both are byte offsets into an on-disk checkpoint that the
52    /// broker's handler forwards straight off the wire, so there is no domain
53    /// layer between the decode and the slice index for a quantity to occupy.
54    #[must_use]
55    pub fn read_snapshot_range(&self, position: i64, max_bytes: i32) -> SnapshotRange {
56        let Some(id) = latest_checkpoint_id(&self.data_dir) else {
57            return SnapshotRange::NoSnapshot;
58        };
59        // Retention can delete the id between the scan and the open, which
60        // reads as no checkpoint, as it did before the scan saw it.
61        let Some(mut checkpoint) = open_checkpoint(&self.data_dir, id.0, id.1) else {
62            return SnapshotRange::NoSnapshot;
63        };
64        match checkpoint.read_chunk(position, max_bytes) {
65            Ok(CheckpointChunk::Bytes(bytes)) => SnapshotRange::Slice(SnapshotSlice {
66                end_offset: id.0,
67                epoch: id.1,
68                total_size: i64::try_from(checkpoint.len()).unwrap_or(i64::MAX),
69                bytes: bytes::Bytes::from(bytes),
70            }),
71            Ok(CheckpointChunk::OutOfRange) => SnapshotRange::OutOfRange,
72            Err(_) => SnapshotRange::NoSnapshot,
73        }
74    }
75
76    /// Answer one KIP-630 `FetchSnapshot` request through the engine, as the
77    /// controller listener answers it.
78    ///
79    /// `body` is the request body at `version`, and the answer is the response
80    /// body at that version. The engine follows Kafka's
81    /// `KafkaRaftClient.handleFetchSnapshotRequest`: it serves exactly the
82    /// snapshot the request names, and refuses another topic or partition, a
83    /// stale or unknown `CurrentLeaderEpoch`, another cluster id, and a
84    /// position outside the snapshot. It also fills `CurrentLeader` and the
85    /// leader's endpoint. A broker listener that answered from the checkpoint
86    /// directly would answer the same request differently.
87    ///
88    /// # Errors
89    /// Returns [`RaftError::Shutdown`] if the engine task is gone, or if it
90    /// dropped the request because the body does not decode.
91    pub async fn fetch_snapshot(
92        &self,
93        version: i16,
94        body: bytes::Bytes,
95    ) -> Result<bytes::Bytes, RaftError> {
96        let (reply, answer) = oneshot::channel();
97        self.engine
98            .deliver(Inbound::FetchSnapshot {
99                req: body,
100                version,
101                reply,
102            })
103            .await?;
104        answer.await.map_err(|_| RaftError::Shutdown)
105    }
106
107    /// Manually trigger a metadata snapshot (KIP-630 checkpoint) on this node.
108    ///
109    /// # Errors
110    /// Returns [`RaftError`] if serialization or the file write fails.
111    pub async fn trigger_snapshot(&self) -> Result<(), RaftError> {
112        self.engine.trigger_snapshot().await
113    }
114}
115
116/// The highest `(end_offset, epoch)` `.checkpoint` in `dir` plus its raw
117/// bytes. Matches the bare-checkpoint format the engine writes (no `.meta`
118/// sidecar).
119pub(super) fn load_latest_checkpoint(dir: &Path) -> Option<(SnapshotId, Vec<u8>)> {
120    let id = latest_checkpoint_id(dir)?;
121    let bytes = load_checkpoint_by_id(dir, id.0, id.1)?;
122    Some((id, bytes))
123}
124
125#[cfg(test)]
126mod tests {
127    use {
128        super::*,
129        crate::{controller::test_support::joining_controller, metadata_partition_dir},
130        std::fs,
131        tempfile::TempDir,
132    };
133
134    #[test]
135    fn load_latest_checkpoint_picks_highest_offset_then_epoch() {
136        let dir = TempDir::new().unwrap();
137        fs::write(
138            dir.path()
139                .join("00000000000000000001-0000000009.checkpoint"),
140            b"old-offset",
141        )
142        .unwrap();
143        fs::write(
144            dir.path()
145                .join("00000000000000000002-0000000001.checkpoint"),
146            b"old-epoch",
147        )
148        .unwrap();
149        fs::write(
150            dir.path()
151                .join("00000000000000000002-0000000003.checkpoint"),
152            b"best",
153        )
154        .unwrap();
155        fs::write(dir.path().join("9-9.txt"), b"ignored suffix").unwrap();
156        fs::write(
157            dir.path().join("not-a-checkpoint.checkpoint"),
158            b"ignored name",
159        )
160        .unwrap();
161
162        let latest = load_latest_checkpoint(dir.path()).expect("checkpoint");
163        assert2::assert!(latest == ((2, 3), b"best".to_vec()));
164    }
165
166    #[yare::parameterized(
167        zero = { 0, Some(b"abc".as_slice()) },
168        last_byte = { 2, Some(b"c".as_slice()) },
169        exact_end = { 3, None },
170        past_end = { 4, None },
171        negative = { -1, None },
172    )]
173    #[test_macro(tokio::test)]
174    async fn read_snapshot_range_rejects_the_end_and_everything_past_it(
175        position: i64,
176        expected: Option<&'static [u8]>,
177    ) {
178        let (dir, ctrl) = joining_controller("join start").await;
179        let checkpoint_dir = metadata_partition_dir(dir.path());
180        fs::create_dir_all(&checkpoint_dir).unwrap();
181        fs::write(
182            checkpoint_dir.join("00000000000000000010-0000000004.checkpoint"),
183            b"abc",
184        )
185        .unwrap();
186
187        // Kafka's `handleFetchSnapshotRequest` refuses `position < 0` and
188        // `position >= size`, so the end of the snapshot is not a valid position.
189        let read = |position| match ctrl.read_snapshot_range(position, 10) {
190            SnapshotRange::Slice(slice) => Some(slice),
191            SnapshotRange::OutOfRange => None,
192            SnapshotRange::NoSnapshot => panic!("the checkpoint was written"),
193        };
194        let window = |bytes: &'static [u8]| SnapshotSlice {
195            end_offset: 10,
196            epoch: 4,
197            total_size: 3,
198            bytes: bytes::Bytes::from_static(bytes),
199        };
200        assert2::assert!(
201            read(position) == expected.map(window),
202            "position {position}"
203        );
204        ctrl.shutdown().await;
205    }
206}