Skip to main content

krabka_raft/kraft/
log.rs

1//! `KraftLog`: the replicated metadata log behind the `LogView` seam.
2//!
3//! It is a thin facade over `krabka_log::Log` that adds high-watermark
4//! tracking, committed-read filtering for KIP-595 `Fetch`, and divergence
5//! lookup. The controller uses it as the metadata log.
6
7use {
8    crate::{
9        config::MetadataLogConfig,
10        error::{PersistedFormatError, RaftError},
11        kraft::types::{Epoch, LogOffsetMetadata, LogView},
12    },
13    krabka_ids::{LeaderEpoch, Offset},
14    krabka_log::{FailedDeletion, Log, LogConfig, RawRead},
15    krabka_protocol::{Decode as _, records::RecordBatch},
16    krabka_units::{prelude, prelude::ByteSize},
17    krabka_verified::raft,
18    std::{
19        fs,
20        io::{Error, ErrorKind},
21        path::{Path, PathBuf},
22        str,
23        thread::{self, JoinHandle},
24        time::SystemTime,
25    },
26};
27
28pub struct KraftLog {
29    log: Log,
30    /// Highest committed offset. This is consensus state, and krabka-log does
31    /// not track it.
32    hwm: Offset,
33    hwm_path: PathBuf,
34    /// The first I/O error a write to the metadata log directory returned.
35    /// It stays set: Kafka treats a failed metadata log directory as fatal
36    /// (KIP-858), and the engine stops the controller over it.
37    failure: Option<String>,
38    /// The retired files being deleted off the engine, if a deletion is
39    /// running. See [`Self::reap_retired_files`].
40    reaping: Option<JoinHandle<Result<(), FailedDeletion>>>,
41}
42
43/// Read budget [`KraftLog::timestamp_below`] starts from. It only ever needs
44/// one batch, and `read_decoded` grows the window until one fits, so this is
45/// sized to make that the common case rather than to bound the result.
46const TIMESTAMP_READ_WINDOW: ByteSize = prelude::kibibytes(64);
47
48/// The file the committed offset survives a restart in. Kafka keeps no such
49/// file, so the name is krabka's own. It sits beside the KIP-630 snapshots in
50/// the metadata partition directory, so it must not end in `.checkpoint`: a
51/// snapshot scan, krabka's or Kafka's, and a `*.checkpoint` glob must not take
52/// it for a snapshot.
53const HIGH_WATERMARK_FILE: &str = "high-watermark";
54
55/// The file a new high watermark is written to before it is renamed over
56/// [`HIGH_WATERMARK_FILE`], so a reader never sees a torn write.
57const HIGH_WATERMARK_TMP_FILE: &str = "high-watermark.tmp";
58
59/// The version of the [`HIGH_WATERMARK_FILE`] layout: a text file whose first
60/// line is this number and whose second line is the committed offset in
61/// decimal, each ended by a newline, as Kafka's checkpoint files put their
62/// version first. It is part of the 1.x on-disk contract: a later 1.x build
63/// reads this layout, and a layout change takes a new number.
64pub(crate) const HIGH_WATERMARK_FILE_VERSION: i16 = 0;
65
66/// The text of the [`HIGH_WATERMARK_FILE`] that records `hwm`.
67fn encode_high_watermark(hwm: Offset) -> String {
68    format!("{HIGH_WATERMARK_FILE_VERSION}\n{}\n", hwm.0)
69}
70
71/// Reads the text of a [`HIGH_WATERMARK_FILE`].
72///
73/// A single decimal line with no version line is the layout a build before 1.0
74/// wrote, and is [`PersistedFormatError::MissingVersion`]. A version line
75/// other than [`HIGH_WATERMARK_FILE_VERSION`] is
76/// [`PersistedFormatError::UnsupportedVersion`]. Anything else that is not the
77/// current layout is [`PersistedFormatError::Malformed`].
78fn decode_high_watermark(text: &str) -> Result<Offset, PersistedFormatError> {
79    let malformed = || PersistedFormatError::Malformed(format!("{text:?}"));
80    let Some((version, rest)) = text.split_once('\n') else {
81        return Err(if text.trim().parse::<i64>().is_ok() {
82            PersistedFormatError::MissingVersion
83        } else {
84            malformed()
85        });
86    };
87    let version = version.parse::<i64>().map_err(|_| malformed())?;
88    if version != i64::from(HIGH_WATERMARK_FILE_VERSION) {
89        return Err(PersistedFormatError::UnsupportedVersion {
90            found: version,
91            min: i64::from(HIGH_WATERMARK_FILE_VERSION),
92            max: i64::from(HIGH_WATERMARK_FILE_VERSION),
93        });
94    }
95    rest.strip_suffix('\n')
96        .and_then(|offset| offset.parse::<i64>().ok())
97        .filter(|offset| *offset >= 0)
98        .map(Offset)
99        .ok_or_else(malformed)
100}
101
102/// The high watermark [`KraftLog::open`] starts from: the one `hwm_path`
103/// records, or `log_start` when there is none.
104///
105/// Kafka keeps no high-watermark file. A restarted Kafka replica knows no
106/// committed offset until the leader's `Fetch` responses tell it, or, as the
107/// leader, until it commits a record of its own epoch. Krabka's file only
108/// saves the replay of the committed prefix on restart, and a high watermark
109/// below the true one is safe: the controller waits for the quorum to raise it
110/// again. So the file is a cache of state the quorum recomputes. A file that
111/// cannot be read, or that does not parse, is dropped with a warning and the
112/// log start is used, as Kafka starts with no high watermark at all.
113///
114/// A version marker this build does not read is not damage to a cache: it is
115/// a later build's file, or a 0.x data directory that the 1.x contract does
116/// not cover. Both are a hard error, so a node never runs on a format it
117/// cannot read.
118fn recover_high_watermark(hwm_path: &Path, log_start: Offset) -> Result<Offset, RaftError> {
119    let bytes = match fs::read(hwm_path) {
120        Ok(bytes) => bytes,
121        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(log_start),
122        Err(error) => {
123            tracing::warn!(?error, path = %hwm_path.display(), "kraft: high watermark unreadable; starting from the log start");
124            return Ok(log_start);
125        }
126    };
127    let decoded = str::from_utf8(&bytes)
128        .map_err(|error| PersistedFormatError::Malformed(error.to_string()))
129        .and_then(decode_high_watermark);
130    match decoded {
131        Ok(hwm) => Ok(hwm),
132        Err(PersistedFormatError::Malformed(reason)) => {
133            tracing::warn!(%reason, path = %hwm_path.display(), "kraft: high watermark does not parse; starting from the log start");
134            Ok(log_start)
135        }
136        Err(problem) => Err(RaftError::PersistedFormat {
137            artifact: HIGH_WATERMARK_FILE,
138            path: hwm_path.to_path_buf(),
139            problem,
140        }),
141    }
142}
143
144/// The `krabka_log` configuration of the metadata log, as Kafka's
145/// `KafkaRaftLog.createLog` builds it: the segments roll at
146/// `metadata.log.segment.bytes` and `metadata.log.segment.ms`, and time and
147/// size retention are off. Only a snapshot moves the log start, so only the
148/// metadata log's own cleaning deletes a segment.
149fn metadata_log_config(config: &MetadataLogConfig) -> LogConfig {
150    LogConfig {
151        segment_size: config.segment_size,
152        segment_roll_interval: config.segment_roll_interval,
153        retention: None,
154        retention_size: None,
155        ..LogConfig::default()
156    }
157}
158
159impl KraftLog {
160    /// Opens or creates the metadata log in `dir`, the metadata partition
161    /// directory `__cluster_metadata-0`. The segments roll as `config` says.
162    ///
163    /// # Errors
164    /// Returns [`RaftError`] if the log directory cannot be created or the
165    /// underlying `krabka_log::Log` fails to open, and
166    /// [`RaftError::PersistedFormat`] if the high-watermark file has a
167    /// version this build does not read or has no version marker.
168    pub fn open(dir: impl AsRef<Path>, config: &MetadataLogConfig) -> Result<Self, RaftError> {
169        let log_dir = dir.as_ref();
170        let hwm_path = log_dir.join(HIGH_WATERMARK_FILE);
171        fs::create_dir_all(log_dir).map_err(krabka_log::LogError::Io)?;
172        // `krabka_log::Log` checkpoints its own log start, so a prune that
173        // advanced inside the active segment is already restored here.
174        let log = Log::open(log_dir, metadata_log_config(config))?;
175        let hwm = recover_high_watermark(&hwm_path, log.log_start_offset())?
176            .max(log.log_start_offset())
177            .min(log.log_end_offset());
178        Ok(Self {
179            log,
180            hwm,
181            hwm_path,
182            failure: None,
183            reaping: None,
184        })
185    }
186
187    /// The first I/O error a write to the metadata log directory returned, or
188    /// `None` while every write has succeeded.
189    #[must_use]
190    pub fn failure(&self) -> Option<&str> { self.failure.as_deref() }
191
192    /// Records the error of a failed write when it is an I/O error, and
193    /// passes `result` on.
194    fn checked<T>(&mut self, result: Result<T, krabka_log::LogError>) -> Result<T, RaftError> {
195        if let Err(krabka_log::LogError::Io(error)) = &result {
196            self.note_failure(error);
197        }
198        Ok(result?)
199    }
200
201    fn note_failure(&mut self, error: &Error) {
202        if self.failure.is_none() {
203            self.failure = Some(error.to_string());
204        }
205    }
206
207    #[must_use]
208    pub fn log_start_offset(&self) -> Offset { self.log.log_start_offset() }
209
210    #[must_use]
211    pub fn log_end_offset(&self) -> Offset { self.log.log_end_offset() }
212
213    #[must_use]
214    pub fn hwm(&self) -> Offset { self.hwm }
215
216    /// The size of every segment's `.log` file together, Kafka's
217    /// `UnifiedLog.size`, which the metadata log's size retention weighs.
218    #[must_use]
219    pub fn size(&self) -> ByteSize { self.log.size() }
220
221    /// Leader path: appends a batch stamped with `append_timestamp_ms`.
222    /// krabka-log assigns the offset and records the batch's
223    /// `partition_leader_epoch`. Returns the assigned base offset.
224    ///
225    /// The stamp is the batch's create-time, as Kafka's `BatchAccumulator`
226    /// stamps every batch the raft client appends with the current time. It is
227    /// what [`Self::last_committed_timestamp_ms`] reads back for a snapshot
228    /// header, and what a follower replicates verbatim through
229    /// [`Self::append_at`].
230    ///
231    /// # Errors
232    /// Returns [`RaftError`] if the underlying append fails.
233    pub fn append(
234        &mut self,
235        batch: &mut RecordBatch,
236        append_timestamp_ms: i64,
237    ) -> Result<Offset, RaftError> {
238        // Every record in an engine-built batch carries `timestamp_delta` 0,
239        // so one stamp is the create-time of all of them.
240        batch.base_timestamp = append_timestamp_ms;
241        batch.max_timestamp = append_timestamp_ms;
242        // The metadata log is always `CreateTime`, so the log stamps nothing
243        // and the second element of the append result is always `None`.
244        let appended = self.log.append(batch);
245        let (base_offset, _log_append_time_ms) = self.checked(appended)?;
246        Ok(base_offset)
247    }
248
249    /// Follower path: appends a batch at the leader-assigned `offset`.
250    ///
251    /// # Errors
252    /// Returns [`RaftError`] if the underlying append fails, for example when
253    /// `offset` does not equal the current log end offset.
254    pub fn append_at(&mut self, batch: &mut RecordBatch, offset: Offset) -> Result<(), RaftError> {
255        let appended = self.log.append_at(batch, offset);
256        self.checked(appended)?;
257        Ok(())
258    }
259
260    /// Decoded read from `offset`. The tests and the replication apply path
261    /// use it.
262    ///
263    /// # Errors
264    /// Returns [`RaftError`] if the underlying read fails.
265    pub fn read_decoded(
266        &self,
267        offset: Offset,
268        max_size: ByteSize,
269    ) -> Result<Vec<RecordBatch>, RaftError> {
270        let log_end = self.log.log_end_offset();
271        if offset >= log_end {
272            return Ok(Vec::new());
273        }
274        let mut window = max_size;
275        loop {
276            let raw = self.log.read_raw(offset, log_end, window)?;
277            let mut bytes = raw.bytes.as_ref();
278            let mut batches = Vec::new();
279            while !bytes.is_empty() {
280                batches.push(
281                    RecordBatch::decode(&mut bytes)
282                        .map_err(|error| RaftError::ChangeRejected(error.to_string()))?,
283                );
284            }
285            if !batches.is_empty() {
286                return Ok(batches);
287            }
288            // A sparse index can floor `offset` to an earlier batch. If the
289            // configured window ends before the first requested batch, grow
290            // only until one complete requested batch fits so reads always
291            // make progress without unbounding the returned batch run.
292            window *= 2.0;
293        }
294    }
295
296    /// The append timestamp of the last record below `end_offset`: the
297    /// `max_timestamp` of the batch that contains `end_offset - 1`.
298    ///
299    /// KIP-630 stamps this into `SnapshotHeaderRecord`'s
300    /// `last_contained_log_timestamp`, where Kafka supplies the append time of
301    /// the last batch the snapshot contains. `None` when no such record is
302    /// readable here: the boundary is at or below the log start, because
303    /// everything under it was pruned or arrived inside an installed snapshot,
304    /// or it is beyond the log end.
305    #[must_use]
306    pub fn timestamp_below(&self, end_offset: Offset) -> Option<i64> {
307        let last = Offset(end_offset.0.checked_sub(1)?);
308        if last < self.log.log_start_offset() || last >= self.log.log_end_offset() {
309            return None;
310        }
311        let batches = self.read_decoded(last, TIMESTAMP_READ_WINDOW).ok()?;
312        // A sparse index floors the read to a batch boundary at or before
313        // `last`, and the window can carry batches past it, so the containing
314        // batch is the last one that still starts at or below `last`.
315        batches
316            .iter()
317            .take_while(|batch| batch.base_offset <= last.0)
318            .last()
319            .map(|batch| batch.max_timestamp)
320    }
321
322    /// The append timestamp of the last committed record (`hwm - 1`), for the
323    /// header of a snapshot taken at the high watermark.
324    #[must_use]
325    pub fn last_committed_timestamp_ms(&self) -> Option<i64> { self.timestamp_below(self.hwm) }
326
327    /// Serves KIP-595 `Fetch`: verbatim batch bytes in
328    /// `[offset, min(hwm, log_end))`.
329    ///
330    /// # Errors
331    /// Returns [`RaftError`] if the underlying raw read fails.
332    pub fn read_committed(&self, offset: Offset, max_size: ByteSize) -> Result<RawRead, RaftError> {
333        let limit = self.hwm.min(self.log.log_end_offset());
334        Ok(self.log.read_raw(offset, limit, max_size)?)
335    }
336
337    /// Advances the high watermark. The move is monotonic, and it never goes
338    /// past the log end.
339    pub fn advance_hwm(&mut self, new_hwm: Offset) {
340        let log_end = self.log.log_end_offset();
341        let next = Offset(raft::advance_high_watermark(
342            self.hwm.0, new_hwm.0, log_end.0,
343        ));
344        if next > self.hwm {
345            self.hwm = next;
346            self.persist_hwm();
347        }
348        assert2::assert!(self.hwm <= log_end);
349    }
350
351    /// Truncates the log so that no record at offset `>= offset` remains, and
352    /// clamps the HWM down.
353    ///
354    /// # Errors
355    /// Returns [`RaftError`] if the underlying truncation fails.
356    pub fn truncate_to(&mut self, offset: Offset) -> Result<(), RaftError> {
357        let truncated = self.log.truncate_to(offset);
358        self.checked(truncated)?;
359        // A cut inside a batch can leave the physical end below the request.
360        self.hwm = Offset(krabka_verified::truncation_frontier(
361            self.hwm.0,
362            self.log.log_end_offset().0,
363        ));
364        self.persist_hwm();
365        Ok(())
366    }
367
368    /// Prunes the committed prefix below `end_offset`: it advances the
369    /// log-start pointer and trims the now-dead segments. This is a no-op when
370    /// `end_offset` is at or below the current log start. The leader calls it
371    /// after it writes a snapshot.
372    ///
373    /// # Errors
374    /// Returns [`RaftError`] if the underlying log operations fail.
375    pub fn prune_to(&mut self, end_offset: Offset) -> Result<(), RaftError> {
376        if end_offset <= self.log.log_start_offset() {
377            return Ok(());
378        }
379        // `trim_to_offset` deletes every sealed segment wholly below the new
380        // start and then checkpoints the start itself. Setting the start
381        // first would make the trim see nothing left to do, and the segments
382        // would stay on disk.
383        let trimmed = self.log.trim_to_offset(end_offset);
384        self.checked(trimmed)?;
385        Ok(())
386    }
387
388    /// Deletes the files of every segment that [`Self::prune_to`] or
389    /// [`Self::install_snapshot`] retired at least `file.delete.delay.ms`
390    /// before `now`.
391    ///
392    /// A retired segment is renamed to `.deleted` and stays readable for that
393    /// delay, Kafka's 60-second default, as in Kafka's `KafkaRaftLog`. Kafka's
394    /// scheduler then deletes it. Without this call nothing would: the
395    /// broker's retention sweep reaps the partitions it owns, and the metadata
396    /// log is not one of them, so every cleaning would leave its segments on
397    /// disk until the node restarted.
398    ///
399    /// The unlinks run on a thread of their own, as the partition writer hands
400    /// its due deletions to a background task: a metadata segment can be a
401    /// GiB, and unlinking it on the engine would stall fetches, heartbeats and
402    /// every controller command behind it. One deletion runs at a time. Each
403    /// call first collects the one before it, and a failed one hands its
404    /// remaining files back to the log, which retries them on a later call.
405    ///
406    /// # Errors
407    /// Returns [`RaftError`] if the earlier deletion failed, or if the flush
408    /// that `Log::maintain_deferring_deletions` runs first fails.
409    /// It also returns one if the deletion thread panicked.
410    pub fn reap_retired_files(&mut self, now: SystemTime) -> Result<(), RaftError> {
411        if let Some(reaping) = self.reaping.take_if(|reaping| reaping.is_finished()) {
412            let deleted = reaping.join().map_err(|_| {
413                RaftError::Storage(krabka_log::LogError::Corrupt(
414                    "the retired-file deletion panicked".into(),
415                ))
416            })?;
417            if let Err(FailedDeletion { error, remaining }) = deleted {
418                self.log.restore_deletions(remaining, now);
419                return self.checked(Err(error));
420            }
421        }
422        if self.reaping.is_some() {
423            return Ok(());
424        }
425        let due = self.log.maintain_deferring_deletions(now);
426        let due = self.checked(due)?;
427        if !due.is_empty() {
428            self.reaping = Some(thread::spawn(move || due.delete()));
429        }
430        Ok(())
431    }
432
433    /// Replaces the log with an empty log that starts at `end_offset`, which
434    /// drops every segment, and sets the high watermark to `end_offset`. A
435    /// follower calls it when it installs a fetched snapshot whose `end_offset`
436    /// is ahead of its own log.
437    ///
438    /// # Errors
439    /// Returns [`RaftError`] if the underlying reset fails.
440    pub fn install_snapshot(&mut self, end_offset: Offset) -> Result<(), RaftError> {
441        let reset = self.log.reset_to(end_offset);
442        self.checked(reset)?;
443        self.hwm = end_offset;
444        self.persist_hwm();
445        Ok(())
446    }
447
448    /// Writes the high watermark to a temporary file and renames it over the
449    /// high-watermark file, so a crash leaves the old value or the new one.
450    /// Neither write is synced: the file is a cache (see
451    /// [`recover_high_watermark`]), and an advance that a crash loses only
452    /// lowers the value a restart begins from.
453    fn persist_hwm(&mut self) {
454        let tmp = self.hwm_path.with_file_name(HIGH_WATERMARK_TMP_FILE);
455        let written = fs::write(&tmp, encode_high_watermark(self.hwm))
456            .and_then(|()| fs::rename(&tmp, &self.hwm_path));
457        if let Err(error) = written {
458            tracing::error!(?error, path = %self.hwm_path.display(), "kraft: persist high watermark failed");
459            self.note_failure(&error);
460        }
461    }
462}
463
464/// Kafka's `KafkaRaftLog.endOffsetForEpoch` over a leader epoch checkpoint.
465///
466/// The checkpoint answers the floor epoch and its end, as
467/// `LeaderEpochFileCache.endOffsetFor` does. An epoch newer than every entry
468/// has no floor there, and Kafka then answers the log end with the latest
469/// epoch, which never equals the requested one. The WAL replica log shares
470/// this lookup.
471#[must_use]
472pub fn end_offset_for_epoch_in(
473    checkpoint: &krabka_log::LeaderEpochCheckpoint,
474    log_end: Offset,
475    epoch: Epoch,
476) -> LogOffsetMetadata {
477    let latest = u32::try_from(checkpoint.latest_epoch().unwrap_or(LeaderEpoch(0)).0).unwrap_or(0);
478    let past_every_epoch = LogOffsetMetadata {
479        offset: log_end.0,
480        epoch: latest,
481    };
482    let Ok(requested) = i32::try_from(epoch) else {
483        return past_every_epoch;
484    };
485    let (found, end) = checkpoint.epoch_and_offset_for(LeaderEpoch(requested), log_end);
486    u32::try_from(found.0).map_or(past_every_epoch, |found| LogOffsetMetadata {
487        offset: end.0,
488        epoch: found,
489    })
490}
491
492impl LogView for KraftLog {
493    // `LogView` is defined by the pure `krabka-kraft-core` consensus engine and
494    // speaks raw `i64` offsets; unwrap the `krabka-log` `Offset`s with `.0` at
495    // this boundary so the core sees the integers it expects.
496    fn end_offset(&self) -> i64 { self.log.log_end_offset().0 }
497
498    fn last_epoch(&self) -> Epoch {
499        // The log seam speaks `krabka_ids::LeaderEpoch(i32)`; the core's
500        // consensus `Epoch` is a `u32`. krabka-log epochs are non-negative
501        // (0 for an empty log), so unwrap the newtype and convert to `u32`.
502        latest_epoch_in(self.log.epoch_checkpoint())
503    }
504
505    fn end_offset_for_epoch(&self, epoch: Epoch) -> LogOffsetMetadata {
506        end_offset_for_epoch_in(
507            self.log.epoch_checkpoint(),
508            self.log.log_end_offset(),
509            epoch,
510        )
511    }
512}
513
514/// The latest checkpoint epoch as the consensus log view's unsigned epoch.
515#[must_use]
516pub fn latest_epoch_in(checkpoint: &krabka_log::LeaderEpochCheckpoint) -> Epoch {
517    let latest = checkpoint.latest_epoch().unwrap_or(LeaderEpoch(0));
518    u32::try_from(latest.0).unwrap_or(0)
519}
520
521#[cfg(test)]
522mod tests {
523    use {
524        super::*,
525        assert2::{assert, check},
526        krabka_units::{prelude, prelude::mebibytes},
527        std::{
528            fs, thread,
529            time::{Duration, Instant},
530        },
531    };
532
533    /// Read budget the log tests use. It is larger than any batch they append,
534    /// so a read returns everything written.
535    const TEST_READ_BUDGET: ByteSize = mebibytes(1);
536
537    #[derive(derive_more::From, derive_more::Into, Clone, Copy)]
538    struct KraftSeedCount(usize);
539
540    #[derive(Clone, Copy, krabka_macros::FieldDefaults)]
541    struct KraftLogSeedSetup {
542        #[default(KraftSeedCount(5))]
543        records: KraftSeedCount,
544    }
545
546    #[bon::builder]
547    fn append_seeded_records(
548        log: &mut KraftLog,
549        #[builder(into, default = KraftSeedCount (5))] records: KraftSeedCount,
550    ) {
551        let setup = KraftLogSeedSetup { records };
552        for _ in 0..setup.records.0 {
553            log.append(&mut batch(0, 1, b"x"), 0).unwrap();
554        }
555    }
556
557    /// A log and the temporary directory it lives in, which must outlive it.
558    struct LogInDir {
559        log: KraftLog,
560        dir: tempfile::TempDir,
561    }
562
563    #[bon::builder]
564    fn seeded_log(
565        #[builder(into, default = KraftSeedCount (5))] records: KraftSeedCount,
566    ) -> LogInDir {
567        let setup = KraftLogSeedSetup { records };
568        let LogInDir { mut log, dir } = open_tmp();
569        append_seeded_records()
570            .log(&mut log)
571            .records(setup.records)
572            .call();
573        LogInDir { log, dir }
574    }
575
576    fn open_tmp() -> LogInDir {
577        let dir = tempfile::tempdir().expect("tempdir");
578        let log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("open");
579        LogInDir { log, dir }
580    }
581
582    krabka_macros::epoch_record_batch_fixture!(batch);
583
584    #[test]
585    fn opens_empty_at_offset_zero() {
586        let LogInDir { log, dir: _dir } = open_tmp();
587        check!(
588            (
589                log.log_start_offset().0,
590                log.log_end_offset().0,
591                log.hwm().0,
592            ) == (0, 0, 0)
593        );
594    }
595
596    #[test]
597    fn append_assigns_sequential_offsets_and_reads_back() {
598        let LogInDir { mut log, dir: _dir } = open_tmp();
599        let off0 = log.append(&mut batch(0, 1, b"a"), 0).unwrap();
600        let off1 = log.append(&mut batch(0, 1, b"b"), 0).unwrap();
601        assert2::assert!((off0, off1, log.log_end_offset()) == (Offset(0), Offset(1), Offset(2)));
602        // read back decoded
603        let out = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
604        assert2::assert!(
605            out.iter()
606                .map(|batch| batch.partition_leader_epoch)
607                .collect::<Vec<_>>()
608                == vec![1, 1]
609        );
610    }
611
612    #[test]
613    fn append_stamps_the_batch_create_time_the_snapshot_header_reads_back() {
614        let LogInDir { mut log, dir: _dir } = open_tmp();
615        // Two batches with distinct create-times, committed one at a time: the
616        // KIP-630 header timestamp names the last batch a snapshot *contains*,
617        // so it follows the high watermark rather than the log end.
618        let older = 1_700_000_000_000;
619        let newer = 1_700_000_111_222;
620        log.append(&mut batch(0, 1, b"a"), older).unwrap();
621        log.append(&mut batch(0, 1, b"b"), newer).unwrap();
622
623        log.advance_hwm(Offset(1));
624        let at_first = log.last_committed_timestamp_ms();
625        log.advance_hwm(Offset(2));
626
627        assert2::assert!(
628            (
629                at_first,
630                log.last_committed_timestamp_ms(),
631                log.timestamp_below(Offset(1)),
632                log.read_decoded(Offset(0), TEST_READ_BUDGET)
633                    .unwrap()
634                    .iter()
635                    .map(|batch| (batch.base_timestamp, batch.max_timestamp))
636                    .collect::<Vec<_>>(),
637            ) == (Some(older), Some(newer), Some(older), vec![
638                (older, older),
639                (newer, newer)
640            ],)
641        );
642    }
643
644    #[test]
645    fn no_contained_record_has_no_timestamp() {
646        let LogInDir { mut log, dir: _dir } = open_tmp();
647        // Nothing committed yet: an empty log, and a log whose records are all
648        // still uncommitted, both have no last contained record to name.
649        let empty = log.last_committed_timestamp_ms();
650        log.append(&mut batch(0, 1, b"a"), 1_700_000_000_000)
651            .unwrap();
652        let uncommitted = log.last_committed_timestamp_ms();
653
654        // Committed, then pruned away: the boundary is now the log start, and
655        // the batch that carried the stamp is gone with the prefix.
656        log.advance_hwm(Offset(1));
657        log.prune_to(Offset(1)).unwrap();
658
659        assert2::assert!(
660            (
661                empty,
662                uncommitted,
663                log.last_committed_timestamp_ms(),
664                log.timestamp_below(Offset(0)),
665                log.timestamp_below(Offset(9)),
666            ) == (None, None, None, None, None)
667        );
668    }
669
670    #[test]
671    fn append_return_matches_assigned_base_and_advances_public_end_offset() {
672        let LogInDir { mut log, dir: _dir } = open_tmp();
673
674        let first = log.append(&mut batch(0, 1, b"a"), 0).unwrap();
675        let second = log.append(&mut batch(0, 1, b"b"), 0).unwrap();
676        let decoded = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
677
678        check!(
679            (
680                first.0,
681                second.0,
682                decoded
683                    .iter()
684                    .map(|batch| batch.base_offset)
685                    .collect::<Vec<_>>(),
686                log.log_end_offset().0,
687                LogView::end_offset(&log),
688            ) == (0, 1, vec![first.0, second.0], 2, 2)
689        );
690    }
691
692    #[test]
693    fn public_hwm_accessor_tracks_committed_offset_after_advance_and_snapshot() {
694        let LogInDir { mut log, dir: _dir } = seeded_log().records(3).call();
695        log.advance_hwm(Offset(2));
696        assert2::assert!(log.hwm() == 2);
697
698        log.install_snapshot(Offset(9)).unwrap();
699        check!(
700            (
701                log.hwm().0,
702                log.log_start_offset().0,
703                log.log_end_offset().0
704            ) == (9, 9, 9)
705        );
706    }
707
708    #[test]
709    fn append_at_preserves_leader_offset() {
710        let LogInDir { mut log, dir: _dir } = open_tmp();
711        // follower applies a leader-assigned batch at offset 0
712        log.append_at(&mut batch(0, 2, b"x"), Offset(0)).unwrap();
713        assert2::assert!(log.log_end_offset().0 == 1);
714        assert2::assert!(
715            log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap()[0].partition_leader_epoch == 2
716        );
717    }
718
719    #[test]
720    fn logview_reports_end_offset_and_last_epoch() {
721        let LogInDir { mut log, dir: _dir } = open_tmp();
722        log.append(&mut batch(0, 1, b"a"), 0).unwrap();
723        log.append(&mut batch(0, 3, b"b"), 0).unwrap(); // epoch jumps to 3
724        assert2::assert!(LogView::end_offset(&log) == 2);
725        assert2::assert!(LogView::last_epoch(&log) == 3);
726    }
727
728    #[yare::parameterized(
729        epoch_older_than_log = {
730            "an epoch older than the log",
731            1,
732            LogOffsetMetadata { offset: 0, epoch: 1 },
733        },
734        completed_prior_epoch = {
735            "a completed prior epoch",
736            2,
737            LogOffsetMetadata { offset: 1, epoch: 2 },
738        },
739        between_held_epochs = {
740            "an epoch between two the log holds",
741            3,
742            LogOffsetMetadata { offset: 1, epoch: 2 },
743        },
744        current_epoch = { "the current epoch", 4, LogOffsetMetadata { offset: 2, epoch: 4 } },
745        newer_than_log = {
746            "an epoch newer than the log",
747            9,
748            LogOffsetMetadata { offset: 2, epoch: 4 },
749        },
750    )]
751    fn logview_end_offset_for_epoch_follows_kafka(
752        case: &str,
753        epoch: u32,
754        expected: LogOffsetMetadata,
755    ) {
756        let LogInDir { mut log, dir: _dir } = open_tmp();
757        log.append(&mut batch(0, 2, b"a"), 0).unwrap(); // epoch 2 @ [0,1)
758        log.append(&mut batch(0, 4, b"b"), 0).unwrap(); // epoch 4 @ [1,2)
759        check!(
760            LogView::end_offset_for_epoch(&log, epoch) == expected,
761            "{case}"
762        );
763    }
764
765    #[test]
766    fn empty_log_last_epoch_is_zero() {
767        let LogInDir { log, dir: _dir } = open_tmp();
768        assert2::assert!(LogView::last_epoch(&log) == 0);
769    }
770
771    #[test]
772    fn read_committed_never_returns_bytes_past_hwm() {
773        let LogInDir { mut log, dir: _dir } = seeded_log().call(); // offsets 0..5
774        log.advance_hwm(Offset(3));
775        let r = log.read_committed(Offset(0), TEST_READ_BUDGET).unwrap();
776        // bytes contain only batches with base_offset < 3 (offsets 0,1,2)
777        let decoded = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
778        let committed: Vec<_> = decoded.into_iter().filter(|b| b.base_offset < 3).collect();
779        check!(committed.len() == 3);
780        // total committed bytes equals the size of the first 3 batches
781        check!((r.start_offset.0, r.bytes.is_empty()) == (0, false));
782    }
783
784    #[test]
785    fn advance_hwm_is_monotonic_and_clamped_to_log_end() {
786        let LogInDir { mut log, dir: _dir } = open_tmp();
787        log.append(&mut batch(0, 1, b"x"), 0).unwrap(); // log_end = 1
788        log.advance_hwm(Offset(5)); // clamp to log_end
789        assert2::assert!(log.hwm() == 1);
790        log.advance_hwm(Offset(0)); // never regress
791        assert2::assert!(log.hwm() == 1);
792    }
793
794    /// An advance to the current high watermark writes nothing, so a write
795    /// that would fail cannot fail the log through a no-op advance.
796    #[test]
797    fn advance_hwm_to_the_current_value_does_not_persist() {
798        let LogInDir { mut log, dir } = open_tmp();
799        log.append(&mut batch(0, 1, b"x"), 0).unwrap();
800        log.advance_hwm(Offset(1));
801        fs::create_dir(dir.path().join(HIGH_WATERMARK_TMP_FILE)).expect("block the tmp file");
802        log.advance_hwm(Offset(1));
803        assert2::assert!(log.hwm() == 1);
804        assert2::assert!(log.failure() == None);
805    }
806
807    #[test]
808    fn prune_to_advances_log_start_and_is_noop_when_behind() {
809        let LogInDir { mut log, dir: _dir } = seeded_log().call();
810        log.advance_hwm(log.log_end_offset());
811        assert2::assert!(log.log_start_offset() == 0);
812        log.prune_to(Offset(3)).unwrap();
813        assert2::assert!(log.log_start_offset() == 3);
814        log.prune_to(Offset(2)).unwrap(); // <= current start: no-op
815        assert2::assert!(log.log_start_offset() == 3);
816    }
817
818    #[test]
819    fn timestamp_below_returns_none_for_out_of_bounds() {
820        let LogInDir { mut log, dir: _dir } = seeded_log().call();
821        log.advance_hwm(log.log_end_offset());
822        log.prune_to(Offset(3)).unwrap();
823
824        assert2::assert!(log.timestamp_below(Offset(3)) == None);
825        assert2::assert!(log.timestamp_below(Offset(2)) == None);
826        assert2::assert!(log.timestamp_below(Offset(10)) == None);
827        assert2::assert!(log.timestamp_below(Offset(4)).is_some());
828        assert2::assert!(log.timestamp_below(Offset(5)).is_some());
829    }
830
831    #[test]
832    fn prune_inside_the_active_segment_survives_a_reopen() {
833        let dir = tempfile::tempdir().expect("tempdir");
834        {
835            let mut log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("open");
836            append_seeded_records().log(&mut log).call();
837            log.advance_hwm(log.log_end_offset());
838            // Every record is in one segment, so no segment name records the
839            // prune: `krabka_log::Log`'s checkpoint is what carries it.
840            log.prune_to(Offset(3)).unwrap();
841        }
842
843        let log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("reopen");
844
845        check!((log.log_start_offset().0, log.log_end_offset().0) == (3, 5));
846    }
847
848    #[test]
849    fn install_snapshot_resets_log_to_empty_at_offset() {
850        let LogInDir { mut log, dir: _dir } = seeded_log().records(4).call();
851        log.install_snapshot(Offset(100)).unwrap();
852        check!(
853            (
854                log.log_start_offset().0,
855                log.log_end_offset().0,
856                log.hwm().0,
857            ) == (100, 100, 100)
858        );
859        let base = log.append(&mut batch(0, 1, b"x"), 0).unwrap();
860        assert2::assert!(base == 100);
861    }
862
863    #[test]
864    fn truncate_to_drops_log_end_and_hwm() {
865        let LogInDir { mut log, dir: _dir } = seeded_log().call();
866        log.advance_hwm(Offset(5));
867        log.truncate_to(Offset(2)).unwrap();
868        assert2::assert!(log.log_end_offset().0 == 2);
869        assert2::assert!(log.hwm().0 == 2);
870    }
871
872    #[test]
873    fn truncation_inside_a_batch_clamps_hwm_to_the_retained_end() {
874        for hwm in [0, 1, 2, 3] {
875            let LogInDir { mut log, dir } = open_tmp();
876            log.append(&mut batch(0, 1, b"retained"), 0).unwrap();
877            let mut tail = batch(0, 1, b"discarded");
878            tail.last_offset_delta = 1;
879            let mut second = tail.records[0].clone();
880            second.offset_delta = 1;
881            tail.records.push(second);
882            log.append(&mut tail, 0).unwrap();
883            log.advance_hwm(Offset(hwm));
884            log.truncate_to(Offset(2)).unwrap();
885            assert2::assert!(log.log_end_offset() == Offset(1));
886            assert2::assert!(log.hwm() == Offset(hwm.min(1)));
887            log.advance_hwm(Offset(2));
888            assert2::assert!(log.hwm() == Offset(1));
889            drop(log);
890            let reopened = KraftLog::open(dir.path(), &MetadataLogConfig::default()).unwrap();
891            assert2::assert!(reopened.log_end_offset() == Offset(1));
892            assert2::assert!(reopened.hwm() == Offset(1));
893        }
894    }
895
896    /// Kafka's `KafkaRaftLog.truncateTo` hands the cut to
897    /// `UnifiedLog.truncateTo`, which never refuses a target below the log
898    /// start: it truncates and moves the start down onto the target.
899    #[test]
900    fn truncate_below_log_start_lowers_the_log_start() {
901        let LogInDir { mut log, dir: _dir } = seeded_log().records(4).call();
902        log.prune_to(Offset(2)).unwrap();
903
904        log.truncate_to(Offset(1)).unwrap();
905        check!(
906            (
907                log.log_start_offset().0,
908                log.log_end_offset().0,
909                log.hwm().0
910            ) == (1, 1, 0)
911        );
912    }
913
914    /// The `.log` files in `dir`, by name, oldest first.
915    fn segment_files(dir: &Path) -> Vec<String> {
916        let mut names: Vec<String> = fs::read_dir(dir)
917            .expect("list the partition directory")
918            .flatten()
919            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
920            .filter_map(|entry| entry.file_name().into_string().ok())
921            .collect();
922        names.sort();
923        names
924    }
925
926    /// The metadata log rolls as `metadata.log.segment.bytes` and
927    /// `metadata.log.segment.ms` say, on the append that would cross either
928    /// limit, as Kafka's `UnifiedLog.maybeRoll` does.
929    #[test]
930    fn segments_roll_by_the_metadata_log_limits() {
931        // (what, segment size, roll interval, append timestamps, segments)
932        let cases = [
933            (
934                "inside both limits",
935                mebibytes(1),
936                prelude::secs(10),
937                vec![0, 5_000, 10_000],
938                vec!["00000000000000000000.log"],
939            ),
940            (
941                "the size limit",
942                prelude::bytes(1),
943                prelude::secs(10),
944                vec![0, 1, 2],
945                vec![
946                    "00000000000000000000.log",
947                    "00000000000000000001.log",
948                    "00000000000000000002.log",
949                ],
950            ),
951            (
952                "the roll interval",
953                mebibytes(1),
954                prelude::secs(10),
955                vec![0, 10_000, 10_001],
956                vec!["00000000000000000000.log", "00000000000000000002.log"],
957            ),
958        ];
959        for (what, segment_size, segment_roll_interval, timestamps, segments) in cases {
960            let dir = tempfile::tempdir().expect("tempdir");
961            let config = MetadataLogConfig {
962                segment_size,
963                segment_roll_interval,
964                ..MetadataLogConfig::default()
965            };
966            let mut log = KraftLog::open(dir.path(), &config).expect("open");
967            for timestamp in timestamps {
968                log.append(&mut batch(0, 1, b"x"), timestamp)
969                    .expect("append");
970            }
971            check!(segment_files(dir.path()) == segments, "{what}");
972        }
973    }
974
975    /// Four committed one-record batches, one segment each.
976    fn four_committed_segments() -> LogInDir {
977        let dir = tempfile::tempdir().expect("tempdir");
978        let config = MetadataLogConfig {
979            segment_size: prelude::bytes(1),
980            ..MetadataLogConfig::default()
981        };
982        let mut log = KraftLog::open(dir.path(), &config).expect("open");
983        for _ in 0..4 {
984            log.append(&mut batch(0, 1, b"x"), 0).expect("append");
985        }
986        log.advance_hwm(Offset(4));
987        LogInDir { log, dir }
988    }
989
990    /// A prune deletes every segment wholly below the new log start, as
991    /// Kafka's `deleteBeforeSnapshot` does, and keeps the one the new start
992    /// falls in.
993    #[test]
994    fn a_prune_deletes_the_segments_wholly_below_the_new_log_start() {
995        let LogInDir { mut log, dir } = four_committed_segments();
996
997        log.prune_to(Offset(2)).expect("prune");
998
999        check!(
1000            (log.log_start_offset(), segment_files(dir.path()))
1001                == (Offset(2), vec![
1002                    "00000000000000000002.log".to_owned(),
1003                    "00000000000000000003.log".to_owned(),
1004                ])
1005        );
1006    }
1007
1008    /// A pruned segment stays on disk under a `.deleted` name for Kafka's
1009    /// 60-second `file.delete.delay.ms`, and the first reap after that deletes
1010    /// it, off the caller. Before the reap existed, nothing deleted it until a
1011    /// restart.
1012    #[test]
1013    fn a_reap_deletes_the_pruned_segments_once_their_delay_has_passed() {
1014        let retired = |dir: &Path| -> Vec<String> {
1015            let mut names: Vec<String> = fs::read_dir(dir)
1016                .expect("list the partition directory")
1017                .flatten()
1018                .filter_map(|entry| entry.file_name().into_string().ok())
1019                .filter(|name| name.ends_with(".deleted"))
1020                .collect();
1021            names.sort();
1022            names
1023        };
1024        let LogInDir { mut log, dir } = four_committed_segments();
1025        let pruned_at = SystemTime::now();
1026        log.prune_to(Offset(2)).expect("prune");
1027        // Retirement timestamps are sampled during pruning, which can take
1028        // more than a second on a loaded runner. Advance from its completion
1029        // so the simulated reap is past every segment's deletion deadline.
1030        let after_prune_at = SystemTime::now();
1031        let after_prune = retired(dir.path());
1032        check!(
1033            after_prune
1034                .iter()
1035                .any(|name| name.starts_with("00000000000000000000.log")),
1036            "{after_prune:?}"
1037        );
1038
1039        for (when, now, left) in [
1040            ("inside the delay", pruned_at, after_prune),
1041            (
1042                "past the delay",
1043                after_prune_at + Duration::from_secs(61),
1044                Vec::new(),
1045            ),
1046        ] {
1047            log.reap_retired_files(now).expect("reap");
1048            // The unlinks run off the caller, so wait for them, collecting
1049            // the finished deletion on each call as the engine does.
1050            let deadline = Instant::now() + Duration::from_secs(10);
1051            while retired(dir.path()) != left && Instant::now() < deadline {
1052                thread::sleep(Duration::from_millis(10));
1053                log.reap_retired_files(now).expect("reap");
1054            }
1055            check!(retired(dir.path()) == left, "{when}");
1056        }
1057    }
1058
1059    /// The exact text of a version-0 high-watermark file that records offset
1060    /// 3. A change to it is a change to the 1.x on-disk contract.
1061    const HIGH_WATERMARK_V0_FIXTURE: &[u8] = b"0\n3\n";
1062
1063    /// A log with four committed one-record batches, its high watermark at 3.
1064    fn log_with_hwm_three() -> LogInDir {
1065        let LogInDir { mut log, dir } = open_tmp();
1066        for _ in 0..4 {
1067            log.append(&mut batch(0, 1, b"x"), 0).expect("append");
1068        }
1069        log.advance_hwm(Offset(3));
1070        LogInDir { log, dir }
1071    }
1072
1073    #[test]
1074    fn the_high_watermark_file_matches_the_version_zero_fixture() {
1075        let LogInDir { log, dir } = log_with_hwm_three();
1076        drop(log);
1077
1078        let written = fs::read(dir.path().join(HIGH_WATERMARK_FILE)).expect("read");
1079        check!(written == HIGH_WATERMARK_V0_FIXTURE);
1080        check!(!dir.path().join(HIGH_WATERMARK_TMP_FILE).exists());
1081        check!(decode_high_watermark("0\n3\n") == Ok(Offset(3)));
1082    }
1083
1084    #[test]
1085    fn decode_high_watermark_refuses_every_other_layout() {
1086        let unsupported = |found| PersistedFormatError::UnsupportedVersion {
1087            found,
1088            min: 0,
1089            max: 0,
1090        };
1091        let malformed = |text: &str| PersistedFormatError::Malformed(format!("{text:?}"));
1092        let cases = [
1093            ("0.x layout", "3", PersistedFormatError::MissingVersion),
1094            (
1095                "0.x layout, padded",
1096                " 3 ",
1097                PersistedFormatError::MissingVersion,
1098            ),
1099            ("future version", "1\n3\n", unsupported(1)),
1100            ("negative version", "-1\n3\n", unsupported(-1)),
1101            ("empty", "", malformed("")),
1102            ("version only", "0\n", malformed("0\n")),
1103            ("no final newline", "0\n3", malformed("0\n3")),
1104            ("trailing line", "0\n3\n4\n", malformed("0\n3\n4\n")),
1105            ("negative offset", "0\n-3\n", malformed("0\n-3\n")),
1106            ("not a number", "0\nx\n", malformed("0\nx\n")),
1107            ("version not a number", "v\n3\n", malformed("v\n3\n")),
1108        ];
1109        for (case, text, want) in cases {
1110            check!(decode_high_watermark(text) == Err(want), "{case}");
1111        }
1112    }
1113
1114    /// A version marker this build does not read stops the open; a file that
1115    /// is merely damaged is a cache miss, and the log start is used.
1116    #[test]
1117    fn open_refuses_an_unknown_high_watermark_version_and_drops_a_damaged_one() {
1118        struct Scenario {
1119            case: &'static str,
1120            contents: &'static [u8],
1121            want: Result<Offset, PersistedFormatError>,
1122        }
1123        let cases = [
1124            Scenario {
1125                case: "current version",
1126                contents: HIGH_WATERMARK_V0_FIXTURE,
1127                want: Ok(Offset(3)),
1128            },
1129            Scenario {
1130                case: "0.x layout",
1131                contents: b"3",
1132                want: Err(PersistedFormatError::MissingVersion),
1133            },
1134            Scenario {
1135                case: "future version",
1136                contents: b"7\n3\n",
1137                want: Err(PersistedFormatError::UnsupportedVersion {
1138                    found: 7,
1139                    min: 0,
1140                    max: 0,
1141                }),
1142            },
1143            Scenario {
1144                case: "empty after a crash",
1145                contents: b"",
1146                want: Ok(Offset(0)),
1147            },
1148            Scenario {
1149                case: "not utf-8",
1150                contents: b"0\n\xff\n",
1151                want: Ok(Offset(0)),
1152            },
1153        ];
1154        for Scenario {
1155            case,
1156            contents,
1157            want,
1158        } in cases
1159        {
1160            let LogInDir { log, dir } = log_with_hwm_three();
1161            drop(log);
1162            let path = dir.path().join(HIGH_WATERMARK_FILE);
1163            fs::write(&path, contents).expect("write the high-watermark file");
1164
1165            let opened = KraftLog::open(dir.path(), &MetadataLogConfig::default());
1166
1167            let got = match opened {
1168                Ok(log) => Ok(log.hwm()),
1169                Err(RaftError::PersistedFormat {
1170                    artifact,
1171                    path: reported,
1172                    problem,
1173                }) => {
1174                    check!(
1175                        (artifact, reported) == (HIGH_WATERMARK_FILE, path.clone()),
1176                        "{case}"
1177                    );
1178                    Err(problem)
1179                }
1180                Err(other) => panic!("{case}: unexpected error {other}"),
1181            };
1182            check!(got == want, "{case}");
1183        }
1184    }
1185}