Skip to main content

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}