krabka_raft/kraft/controller/checkpoint.rs
1//! The KIP-630 `.checkpoint` artifacts under the controller's metadata
2//! directory: their filename encoding, atomic write, id ordering, and the
3//! two-snapshot retention the engine keeps the directory at.
4
5use {
6 crate::{error::RaftError, kraft::snapshot_fetch::SnapshotId},
7 std::{
8 fs::{self, File},
9 io::{self, Error, Read, Seek, SeekFrom},
10 path::Path,
11 },
12};
13
14/// Kafka's `Snapshots.BOOTSTRAP_SNAPSHOT_ID`: the id of the bootstrap
15/// checkpoint that a dynamic format writes. No replica replicates it, and no
16/// replica applies its metadata records to its image. The leader writes those
17/// records to the log instead.
18pub(crate) const BOOTSTRAP_SNAPSHOT_ID: SnapshotId = (0, 0);
19
20/// Write a KIP-630 `.checkpoint` artifact (bytes only) atomically and
21/// durably: the temporary file is synced before the rename and the directory
22/// after it, as Kafka's `FileRawSnapshotWriter.freeze` does.
23///
24/// # Errors
25///
26/// Returns an error when the directory cannot be created, or the temporary
27/// file cannot be written, synced or renamed into place.
28pub fn write_checkpoint(
29 dir: &Path,
30 end_offset: i64,
31 epoch: i32,
32 bytes: &[u8],
33) -> Result<(), RaftError> {
34 fs::create_dir_all(dir).map_err(krabka_log::LogError::Io)?;
35 let name = checkpoint_name(end_offset, epoch);
36 let path = dir.join(name);
37 krabka_log::write_file_atomic(&path.with_extension("tmp"), &path, bytes)
38 .map_err(krabka_log::LogError::Io)?;
39 Ok(())
40}
41
42/// Scan `dir` for `<end_offset>-<epoch>.checkpoint` artifacts, pick the highest
43/// `(end_offset, epoch)`, and return its raw bytes.
44///
45/// Returns `None` when the directory is absent or holds no checkpoint. Unlike
46/// `snapshot::load_latest`, this reads only the `.checkpoint` (the `.meta`
47/// sidecar is gone in this engine — the durable epoch lives in the quorum-state
48/// file).
49///
50/// # Errors
51///
52/// Returns an error when the selected checkpoint file cannot be read.
53pub fn load_latest_checkpoint(dir: &Path) -> Result<Option<Vec<u8>>, RaftError> {
54 let Some((end_offset, epoch)) = latest_checkpoint_id(dir) else {
55 return Ok(None);
56 };
57 let bytes =
58 fs::read(dir.join(checkpoint_name(end_offset, epoch))).map_err(krabka_log::LogError::Io)?;
59 Ok(Some(bytes))
60}
61
62pub(crate) fn checkpoint_name(end_offset: i64, epoch: i32) -> String {
63 format!("{end_offset:020}-{epoch:010}.checkpoint")
64}
65
66pub(crate) fn parse_checkpoint_name(name: &str) -> Option<SnapshotId> {
67 let stem = name.strip_suffix(".checkpoint")?;
68 let (off, ep) = stem.split_once('-')?;
69 let id = (off.parse().ok()?, ep.parse().ok()?);
70 if id.0 < 0 || id.1 < 0 {
71 return None;
72 }
73 (checkpoint_name(id.0, id.1) == name).then_some(id)
74}
75
76/// Scan `dir` for `<end_offset>-<epoch>.checkpoint` artifacts and return the
77/// highest `(end_offset, epoch)` id, or `None` when the directory is absent or
78/// holds no checkpoint.
79#[must_use]
80pub fn latest_checkpoint_id(dir: &Path) -> Option<SnapshotId> {
81 let ids = checkpoint_ids(dir);
82 let index = krabka_verified::latest_checkpoint_index(&ids)?;
83 ids.get(index).copied()
84}
85
86/// Every parseable `(end_offset, epoch)` id in `dir`, in directory order. An
87/// absent directory and one holding no checkpoint are both empty.
88pub(crate) fn checkpoint_ids(dir: &Path) -> Vec<SnapshotId> {
89 let Ok(entries) = fs::read_dir(dir) else {
90 return Vec::new();
91 };
92 entries
93 .flatten()
94 .filter_map(|entry| parse_checkpoint_name(entry.file_name().to_str()?))
95 .collect()
96}
97
98/// The two newest checkpoint ids in a directory.
99struct RecentCheckpointIds {
100 latest: SnapshotId,
101 previous: SnapshotId,
102}
103
104/// The two newest ids in `dir`: the latest and the one before it. A directory
105/// holding a single checkpoint returns that id twice, which is what
106/// [`krabka_verified::checkpoint_id_retained`] expects for "there is no
107/// runner-up".
108fn recent_checkpoint_ids(dir: &Path) -> Option<RecentCheckpointIds> {
109 let mut ids = checkpoint_ids(dir);
110 let latest = ids.swap_remove(krabka_verified::latest_checkpoint_index(&ids)?);
111 let previous = krabka_verified::latest_checkpoint_index(&ids)
112 .and_then(|index| ids.get(index).copied())
113 .unwrap_or(latest);
114 Some(RecentCheckpointIds { latest, previous })
115}
116
117#[must_use]
118pub fn checkpoint_id_is_newer(candidate: SnapshotId, current: SnapshotId) -> bool {
119 krabka_verified::checkpoint_id_newer(candidate.0, candidate.1, current.0, current.1)
120}
121
122/// Delete every `.checkpoint` in `dir` except the two newest
123/// `(end_offset, epoch)` ids, after the controller's snapshot+prune or an
124/// install. Best-effort: read/remove errors are ignored.
125///
126/// The previous checkpoint stays because a `FetchSnapshot` reader is chunking
127/// a snapshot id byte range by byte range: deleting the id it started on the
128/// moment a newer one lands answers its next chunk with `SNAPSHOT_NOT_FOUND`
129/// and sends it back to position 0 against the newer id, which a follower
130/// slower than one snapshot interval never escapes. Kafka keeps the previous
131/// snapshot the same way, through retention alone rather than by tracking
132/// in-flight readers.
133pub fn retain_recent_checkpoints(dir: &Path) {
134 let Some(RecentCheckpointIds { latest, previous }) = recent_checkpoint_ids(dir) else {
135 return;
136 };
137 retain_checkpoints(dir, latest, previous);
138}
139
140/// Delete every `.checkpoint` in `dir` except the latest `(end_offset, epoch)`.
141///
142/// This is the rule for a directory no peer fetches from — the broker
143/// observer's own resume store, which only its own restart reads — where the
144/// second copy [`retain_recent_checkpoints`] keeps for an in-flight
145/// `FetchSnapshot` reader would buy nothing.
146pub fn retain_latest_checkpoint(dir: &Path) {
147 let Some(latest) = latest_checkpoint_id(dir) else {
148 return;
149 };
150 retain_checkpoints(dir, latest, latest);
151}
152
153/// Delete every `.checkpoint` in `dir` outside the two retained ids. Passing
154/// one id as both keeps exactly that one.
155fn retain_checkpoints(dir: &Path, latest: SnapshotId, previous: SnapshotId) {
156 let Ok(entries) = fs::read_dir(dir) else {
157 return;
158 };
159 for entry in entries.flatten() {
160 let name = entry.file_name();
161 let Some(name) = name.to_str() else { continue };
162 let Some((off, ep)) = parse_checkpoint_name(name) else {
163 continue;
164 };
165 if !krabka_verified::checkpoint_id_retained(
166 off, ep, latest.0, latest.1, previous.0, previous.1,
167 ) {
168 let _ = fs::remove_file(entry.path());
169 }
170 }
171}
172
173/// Read a specific checkpoint `<end_offset>-<epoch>.checkpoint` by id, or `None`
174/// if it is absent (the leader's `FetchSnapshot` serve path).
175#[must_use]
176pub fn load_checkpoint_by_id(dir: &Path, end_offset: i64, epoch: i32) -> Option<Vec<u8>> {
177 fs::read(dir.join(checkpoint_name(end_offset, epoch))).ok()
178}
179
180/// An open `.checkpoint`, for serving one KIP-630 `FetchSnapshot` chunk
181/// without reading the rest of the file.
182///
183/// The length is the one the open handle reports, so it and every byte read
184/// through the handle come from the same file. A checkpoint is never written
185/// in place: [`write_checkpoint`] renames a finished file into place and
186/// retention unlinks it. An id that retention deletes after the open is
187/// therefore still read in full through the open handle, and one deleted
188/// before the open does not open.
189pub(crate) struct CheckpointFile<R> {
190 reader: R,
191 len: u64,
192}
193
194/// What [`CheckpointFile::read_chunk`] found at a requested position.
195#[derive(Debug, PartialEq, Eq)]
196pub(crate) enum CheckpointChunk {
197 /// `position < 0` or `position >= len`, which Kafka's
198 /// `handleFetchSnapshotRequest` answers with `POSITION_OUT_OF_RANGE`.
199 OutOfRange,
200 /// The bytes `[position, position + min(max_bytes, len - position))`.
201 Bytes(Vec<u8>),
202}
203
204/// Open the checkpoint `<end_offset>-<epoch>.checkpoint`, or `None` if it is
205/// absent or cannot be opened (the leader's `FetchSnapshot` serve path).
206pub(crate) fn open_checkpoint(
207 dir: &Path,
208 end_offset: i64,
209 epoch: i32,
210) -> Option<CheckpointFile<File>> {
211 let file = File::open(dir.join(checkpoint_name(end_offset, epoch))).ok()?;
212 let len = file.metadata().ok()?.len();
213 Some(CheckpointFile::new(file, len))
214}
215
216impl<R: Read + Seek> CheckpointFile<R> {
217 pub(crate) fn new(reader: R, len: u64) -> Self { Self { reader, len } }
218
219 /// The checkpoint's length in bytes, the `FetchSnapshot` `Size`.
220 pub(crate) fn len(&self) -> u64 { self.len }
221
222 #[cfg(test)]
223 pub(crate) fn reader(&self) -> &R { &self.reader }
224
225 /// Read the chunk at `position`, at most `max_bytes` long. Both stay the
226 /// raw KIP-630 `int64` and `int32` off the wire. A negative `max_bytes`
227 /// reads nothing. Only the returned bytes are read and allocated.
228 ///
229 /// # Errors
230 ///
231 /// Returns an error when the seek or the read fails.
232 pub(crate) fn read_chunk(
233 &mut self,
234 position: i64,
235 max_bytes: i32,
236 ) -> io::Result<CheckpointChunk> {
237 let Some(start) = u64::try_from(position)
238 .ok()
239 .filter(|start| *start < self.len)
240 else {
241 return Ok(CheckpointChunk::OutOfRange);
242 };
243 let max = u64::try_from(max_bytes).unwrap_or(0);
244 let len = usize::try_from(max.min(self.len - start)).map_err(Error::other)?;
245 let mut bytes = vec![0; len];
246 self.reader.seek(SeekFrom::Start(start))?;
247 self.reader.read_exact(&mut bytes)?;
248 Ok(CheckpointChunk::Bytes(bytes))
249 }
250}