Skip to main content

krabka_log/log/
tiering.rs

1//! Tiered storage (KIP-405): describing sealed segments for offload, rolling
2//! the active segment when local retention asks for it, and dropping the local
3//! copies once they are safely remote.
4//!
5//! `Log` enforces no tiered-storage invariant of its own. It reports what
6//! a `RemoteLogManager` needs, rolls when that manager tells it to roll, and
7//! deletes what that manager tells it to delete.
8
9use {
10    super::Log,
11    crate::{
12        error::LogError, leader_epoch_checkpoint::EpochEntry, name, producer_snapshot, retention,
13        segment::Segment,
14    },
15    krabka_ids::{LeaderEpoch, Offset},
16    krabka_units::prelude::{ByteSize, ByteSizeExt as _},
17    std::{
18        fs, iter,
19        path::{Path, PathBuf},
20    },
21    tracing::{field, instrument},
22};
23
24/// A sealed segment described for tiered-storage offload (KIP-405).
25///
26/// It carries the on-disk file paths, the offset, timestamp, and size
27/// metadata, and the leader-epoch ranges that a `RemoteLogManager` needs to
28/// build remote-segment metadata. [`Log::tierable_segments`] produces these
29/// values.
30// No `Eq`: `size` is a `ByteSize`, which stores `f64`. The derive was unused —
31// `SegmentExport` is never hashed nor used as a map key.
32#[derive(Debug, Clone, PartialEq)]
33pub struct SegmentExport {
34    /// First absolute offset in the segment.
35    pub base_offset: Offset,
36    /// Last absolute offset (inclusive) in the segment.
37    pub last_offset: Offset,
38    /// Kafka's `LogSegment.largestTimestamp()`, which is what Kafka's copy
39    /// records as the remote segment's `maxTimestampMs`: the highest record
40    /// timestamp in the segment, or [`SegmentExport::last_modified_ms`] when
41    /// no record carries a non-negative one.
42    pub max_timestamp: i64,
43    /// Kafka's `LogSegment.lastModified()`: the `.log` file's modification
44    /// time in epoch milliseconds, or `0` when the filesystem cannot say, as
45    /// `File.lastModified()` answers. Kafka's tiered local retention ages a
46    /// segment whose records claim a future timestamp by this instead.
47    pub last_modified_ms: i64,
48    /// `.log` file size.
49    pub size: ByteSize,
50    /// Path to the `.log` data file.
51    pub log_path: PathBuf,
52    /// Path to the `.index` (offset index) file.
53    pub offset_index_path: PathBuf,
54    /// Path to the `.timeindex` file.
55    pub time_index_path: PathBuf,
56    /// Path to the `.txnindex` file, present only when it exists on disk.
57    pub transaction_index_path: Option<PathBuf>,
58    /// Producer-state snapshot at `last_offset + 1`.
59    pub producer_snapshot_path: PathBuf,
60    /// Leader epochs whose coverage overlaps `[base_offset, last_offset]`,
61    /// as `(epoch, start_offset)` clamped to `base_offset`, ordered by
62    /// offset. May be empty when no epochs were recorded for this log.
63    pub leader_epochs: Vec<(LeaderEpoch, Offset)>,
64}
65
66/// The active segment as tiered local retention measures it (KIP-405).
67///
68/// Kafka's `UnifiedLog.deletableSegments` walks a tiered log's active segment
69/// last. When the retention predicate holds for it, Kafka rolls it, so that
70/// the next copy can upload its records and the next retention pass can drop
71/// them from local disk. This value carries what that predicate reads.
72/// [`Log::active_segment_export`] produces it.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct ActiveSegmentExport {
75    /// First absolute offset in the segment.
76    pub base_offset: Offset,
77    /// Kafka's `LogSegment.largestTimestamp()`, as in
78    /// [`SegmentExport::max_timestamp`].
79    pub max_timestamp: i64,
80    /// Kafka's `LogSegment.lastModified()`, as in
81    /// [`SegmentExport::last_modified_ms`].
82    pub last_modified_ms: i64,
83    /// `.log` file size.
84    pub size: ByteSize,
85}
86
87/// Leader epochs whose coverage `[start_e, start_{e+1})` overlaps the
88/// segment range `[base, last]`, returned as `(epoch, start_offset)` with
89/// the start clamped up to `base` and ordered by offset. An epoch with no
90/// recorded entries yields an empty result.
91///
92/// `sorted` must be ordered by `start_offset` ascending (the caller sorts
93/// once and reuses the slice across segments).
94fn epochs_for_range(
95    sorted: &[EpochEntry],
96    base: Offset,
97    last: Offset,
98) -> Vec<(LeaderEpoch, Offset)> {
99    let mut out = Vec::new();
100    for (i, e) in sorted.iter().enumerate() {
101        // Coverage of this epoch is [start_offset, next.start_offset).
102        let end = sorted
103            .get(i + 1)
104            .map_or(Offset(i64::MAX), |n| n.start_offset);
105        if e.start_offset <= last && end > base {
106            out.push((e.epoch, e.start_offset.max(base)));
107        }
108    }
109    out
110}
111
112/// A sealed segment and the exclusive end of its offsets, which is the base
113/// offset of its successor.
114pub(super) struct SealedSegment<'a> {
115    pub(super) segment: &'a Segment,
116    pub(super) next_base: Offset,
117}
118
119/// Kafka's `LogSegment.lastModified()` for the file at `path`: its
120/// modification time in epoch milliseconds, or `0` when it cannot be read,
121/// which is what `java.io.File.lastModified()` returns for an I/O error.
122fn last_modified_ms(path: &Path) -> i64 {
123    fs::metadata(path)
124        .and_then(|metadata| metadata.modified())
125        .map_or(0, retention::now_ms)
126}
127
128impl Log {
129    /// Pair each sealed segment with the exclusive end supplied by its successor.
130    pub(super) fn sealed_segments_with_next_base(&self) -> impl Iterator<Item = SealedSegment<'_>> {
131        let active_base = self
132            .active
133            .as_ref()
134            .map_or_else(|| self.log_end_offset(), Segment::base_offset);
135        self.segments
136            .iter()
137            .zip(
138                self.segments
139                    .iter()
140                    .skip(1)
141                    .map(Segment::base_offset)
142                    .chain(iter::once(active_base)),
143            )
144            .map(|(segment, next_base)| SealedSegment { segment, next_base })
145    }
146
147    /// Kafka's `LogSegment.largestTimestamp()`: the segment's highest record
148    /// timestamp when one is non-negative, and otherwise its `.log` file's
149    /// modification time. Retention ages a segment by this, so a segment
150    /// whose records carry no timestamp still expires once its file is old,
151    /// and an empty one is aged by when it was last written.
152    pub(super) fn largest_timestamp(&self, segment: &Segment) -> i64 {
153        let max_timestamp = segment.max_timestamp();
154        if max_timestamp >= 0 {
155            max_timestamp
156        } else {
157            last_modified_ms(&name::log_path(&self.dir, segment.base_offset().0))
158        }
159    }
160
161    /// First absolute offset still readable from this broker's local disk
162    /// (KIP-405): Kafka's `localLogStartOffset`.
163    ///
164    /// It is the base offset of the oldest segment on disk, raised to the
165    /// global [`Log::log_start_offset`] when a `DeleteRecords` floor sits
166    /// above the files that are still there. Dropping a local segment whose
167    /// copy is in the remote tier moves this pointer and leaves the global one
168    /// where it was, so the offsets in `[log_start_offset(),
169    /// local_log_start_offset())` are exactly the ones the remote tier serves.
170    #[must_use]
171    pub fn local_log_start_offset(&self) -> Offset {
172        self.first_local_offset().max(self.log_start_offset())
173    }
174
175    /// Delete every sealed segment whose `last_offset < target` from disk
176    /// (KIP-405).
177    ///
178    /// Only [`Log::local_log_start_offset`] moves. The global
179    /// [`Log::log_start_offset`] stays where it was, because the records are
180    /// still in the remote tier and a fetch for them must reach it rather than
181    /// answer `OFFSET_OUT_OF_RANGE`. Kafka splits the two floors the same way:
182    /// `local.retention.*` moves `localLogStartOffset` and leaves
183    /// `logStartOffset` to `DeleteRecords` and to remote-segment deletion.
184    ///
185    /// This method never touches the active segment. The producer snapshot at
186    /// each removed segment's base goes with it. It returns the number of
187    /// segments removed. It does nothing and returns `Ok(0)` when
188    /// `target <= local_log_start_offset()`.
189    ///
190    /// The caller must confirm that these segments are safely in the remote
191    /// tier (`CopySegmentFinished`) before it calls this method. `Log`
192    /// enforces no tiered-storage invariants. See
193    /// `crates/broker/src/remote_log_manager.rs` for the production caller.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`LogError::InvalidArgument`] if `target` is negative.
198    #[instrument(
199        level = "info",
200        skip(self),
201        fields(removed = field::Empty),
202        err,
203    )]
204    pub fn delete_local_segments_through(&mut self, target: Offset) -> Result<usize, LogError> {
205        self.rollover_flusher.finish()?;
206        if target < 0 {
207            return Err(LogError::InvalidArgument(
208                "delete_local_segments_through: target must be >= 0".into(),
209            ));
210        }
211        if target <= self.local_log_start_offset() {
212            return Ok(0);
213        }
214
215        let to_drop: Vec<Offset> = self
216            .sealed_segments_with_next_base()
217            .filter(|sealed| sealed.next_base - 1 < target)
218            .map(|sealed| sealed.segment.base_offset())
219            .collect();
220
221        let removed = to_drop.len();
222        tracing::Span::current().record("removed", removed);
223        self.remove_sealed_segments(&to_drop)?;
224
225        Ok(removed)
226    }
227
228    /// Describe the active segment for tiered local retention (KIP-405), or
229    /// return `None` when the log has no active segment.
230    ///
231    /// `max_timestamp` is Kafka's `largestTimestamp()`, so an empty segment
232    /// and a segment whose records carry no timestamp both report the `.log`
233    /// file's modification time.
234    #[must_use]
235    pub fn active_segment_export(&self) -> Option<ActiveSegmentExport> {
236        self.active.as_ref().map(|segment| {
237            let base_offset = segment.base_offset();
238            ActiveSegmentExport {
239                base_offset,
240                max_timestamp: self.largest_timestamp(segment),
241                last_modified_ms: last_modified_ms(&name::log_path(&self.dir, base_offset.0)),
242                size: segment.size(),
243            }
244        })
245    }
246
247    /// Kafka's `UnifiedLog.roll()`, as tiered local retention calls it: seal
248    /// the active segment and open an empty one at the log end.
249    ///
250    /// Kafka's `deletableSegments` rolls a tiered log's active segment once
251    /// it breaches `local.retention.ms` or `local.retention.bytes`. The
252    /// remote tier never holds the active segment, so without the roll an
253    /// idle partition keeps its newest records on local disk until
254    /// `segment.ms` or `segment.bytes` rolls the segment.
255    ///
256    /// Returns `true` when the log rolled. It does nothing and returns
257    /// `false` when there is no active segment or the active segment is
258    /// empty: an empty segment holds nothing to copy, and the new segment
259    /// would open at the same base offset.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error when the flush, the producer snapshot, the seal, or
264    /// the new segment's files fail.
265    pub fn roll(&mut self) -> Result<bool, LogError> {
266        if self
267            .active
268            .as_ref()
269            .is_none_or(|segment| segment.size() == ByteSize::ZERO)
270        {
271            return Ok(false);
272        }
273        self.roll_active_segment()?;
274        Ok(true)
275    }
276
277    /// Describe every sealed segment for tiered-storage offload (KIP-405).
278    ///
279    /// The result includes sealed segments after their rollover flush has
280    /// published the boundary snapshot. It never includes the active segment.
281    ///
282    /// `last_offset` comes from the next segment's `base_offset`. For the
283    /// most-recent sealed segment it comes from the active segment's base.
284    /// The value is therefore correct even for segments loaded from disk
285    /// without a tail scan. `max_timestamp` is Kafka's `largestTimestamp()`,
286    /// so a segment with no record timestamp reports its file's
287    /// modification time rather than an unknown.
288    #[must_use]
289    pub fn tierable_segments(&self) -> Vec<SegmentExport> {
290        // Sort the epoch entries once here rather than per-segment inside
291        // `epochs_for_range`.
292        let mut epoch_entries = self.epoch_checkpoint.entries().to_vec();
293        epoch_entries.sort_by_key(|e| e.start_offset);
294        self.sealed_segments_with_next_base()
295            // A rollover's snapshot appears only after its records and indexes
296            // have flushed. Pending segments stay local until then.
297            .filter(|sealed| producer_snapshot::path(&self.dir, sealed.next_base).exists())
298            .map(
299                |SealedSegment {
300                     segment: seg,
301                     next_base,
302                 }| {
303                    let base = seg.base_offset();
304                    let last = next_base - 1;
305                    let txn = name::txnindex_path(&self.dir, base.0);
306                    let log_path = name::log_path(&self.dir, base.0);
307                    SegmentExport {
308                        base_offset: base,
309                        last_offset: last,
310                        max_timestamp: self.largest_timestamp(seg),
311                        last_modified_ms: last_modified_ms(&log_path),
312                        size: seg.size(),
313                        log_path,
314                        offset_index_path: name::index_path(&self.dir, base.0),
315                        time_index_path: name::timeindex_path(&self.dir, base.0),
316                        transaction_index_path: txn.exists().then_some(txn),
317                        producer_snapshot_path: producer_snapshot::path(&self.dir, next_base),
318                        leader_epochs: epochs_for_range(&epoch_entries, base, last),
319                    }
320                },
321            )
322            .collect()
323    }
324}
325
326#[cfg(test)]
327mod tests;