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;