Skip to main content

krabka_log/log/
tick.rs

1//! Periodic maintenance: the time- and size-based retention sweep, Kafka's
2//! `UnifiedLog.deleteOldSegments`.
3//!
4//! The segment roll itself is not here. Kafka rolls inside the append, in
5//! [`Log::should_roll_for_incoming`], against the incoming batch's own size
6//! and timestamp; a wall-clock roll on this sweep would roll an idle
7//! partition's active segment, which Kafka never does.
8//!
9//! Retention may delete every segment, the active one included, and then
10//! rolls first so the log keeps a fresh empty segment. It never deletes an
11//! empty newest segment, and never evicts a segment that still holds a record
12//! whose delivery time has not arrived.
13
14use {
15    super::Log,
16    crate::{error::LogError, producer_snapshot, retention, segment::Segment},
17    krabka_ids::Offset,
18    krabka_units::prelude::{ByteSizeExt, TimeExt as _},
19    krabka_verified::retention::{LocalRetentionSegment, local_retention_prefix},
20    std::{
21        collections::HashSet,
22        iter,
23        sync::PoisonError,
24        time::{Instant, SystemTime},
25    },
26    tracing::{field, instrument},
27};
28
29impl Log {
30    /// Periodic maintenance: apply time- and size-based retention when `cleanup.policy` holds `delete`, and delete
31    /// segments below the log start offset under every policy. The passes
32    /// follow Kafka's `UnifiedLog.deleteOldSegments`: size retention deletes
33    /// an oldest segment only while the bytes over `retention.bytes` still
34    /// cover the whole segment, and time retention ages a segment by Kafka's
35    /// `largestTimestamp()` -- its newest record timestamp, or its `.log`
36    /// file's modification time when no record carries one. The walk covers
37    /// the active segment too; when every segment goes, the active one is
38    /// rolled first, as Kafka's `deleteSegments` does, and the fresh empty
39    /// segment stays.
40    ///
41    /// Like Kafka's `deletableSegments`, the walk stops at the first segment
42    /// the partition's `high_watermark` has not passed: a segment whose upper
43    /// bound (the next segment's base, or the log end for the active one) is
44    /// above it still holds records followers may not have replicated.
45    #[instrument(
46        level = "debug",
47        skip_all,
48        fields(evicted = field::Empty),
49        err,
50    )]
51    /// # Errors
52    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
53    pub fn tick(&mut self, now: SystemTime, high_watermark: Offset) -> Result<(), LogError> {
54        self.rollover_flusher.check()?;
55        self.flush_if_due(Instant::now())?;
56        self.reap_deleted_files(now)?;
57        // Tiered topics' segment lifecycle is owned by the RemoteLogManager.
58        if self
59            .config
60            .read()
61            .unwrap_or_else(PoisonError::into_inner)
62            .remote_storage_enable
63        {
64            return Ok(());
65        }
66        // Refresh the visibility watermark before retention reads it, so a
67        // partition that no consumer has fetched from still gets an accurate
68        // floor. On a topic that delivers immediately this returns the log
69        // end and does no I/O.
70        let visible_floor = self
71            .advance_delivery_watermark(retention::now_ms(now))
72            .watermark;
73
74        let now_ms = retention::now_ms(now);
75        let cfg_guard = self.config.read().unwrap_or_else(PoisonError::into_inner);
76        // Kafka's `UnifiedLog.deleteOldSegments`: time and size retention run
77        // only when `cleanup.policy` holds `delete`. A compact-only log keeps
78        // every key however old it is, and loses a segment only when the whole
79        // segment is below the log start offset.
80        let retention_applies = cfg_guard.cleanup_policy.contains_delete();
81        // Kafka's `deleteRetentionMsBreachedSegments` ages a segment by its
82        // `largestTimestamp()` and deletes it once `now - anchor >
83        // retention.ms`. Truncating, not rounding: a sub-millisecond window
84        // must not round up into deleting a segment a millisecond early.
85        let time_cutoff = cfg_guard
86            .retention
87            .filter(|_| retention_applies)
88            .map(|window| now_ms.saturating_sub(window.millis_i64_trunc()));
89        // Kafka's `deleteRetentionSizeBreachedSegments` runs only when the log
90        // is at least its budget, and a log exactly at its budget still runs
91        // it with a debt of zero. The active segment counts toward the size.
92        let size_debt = cfg_guard
93            .retention_size
94            .filter(|_| retention_applies)
95            .and_then(|budget| self.size().bytes_u64().checked_sub(budget.bytes_u64()));
96        drop(cfg_guard);
97
98        // Kafka's `deletableSegments` walks every segment, the active one
99        // last, and `deleteOldSegments` runs three passes over them: the
100        // log-start breach, `retention.bytes`, then `retention.ms`. The kernel
101        // folds the breach into the time flag, which deletes the same prefix
102        // (see `local_retention_model`).
103        //
104        // Kafka's `deleteLogStartOffsetBreachedSegments`: a segment whose next
105        // segment starts at or below the log start offset holds no record at
106        // or above it. Every policy deletes it. The active segment has no next
107        // segment, so the breach never reaches it.
108        let log_start = self.log_start_offset();
109        let log_end = self.log_end_offset();
110        let segments: Vec<&Segment> = self.segments.iter().chain(self.active.as_ref()).collect();
111        let next_bases = segments
112            .iter()
113            .skip(1)
114            .map(|segment| Some(segment.base_offset()))
115            .chain(iter::once(None));
116        // On an immediate topic the floor is the log end, so no segment is
117        // blocked. On a scheduled topic the first waiting segment stops the
118        // prefix; later segments are never skipped around it.
119        let facts: Vec<LocalRetentionSegment> = segments
120            .iter()
121            .zip(next_bases)
122            .map(|(segment, next_base)| LocalRetentionSegment {
123                // Kafka's `deletableSegments` requires `highWatermark >=
124                // upperBoundOffset`, the next segment's base or the log end.
125                blocked: segment.last_offset() >= visible_floor
126                    || next_base.unwrap_or(log_end) > high_watermark,
127                expired: next_base.is_some_and(|next_base| next_base <= log_start)
128                    || time_cutoff.is_some_and(|cutoff| self.largest_timestamp(segment) < cutoff),
129                size: segment.size().bytes_u64(),
130            })
131            .collect();
132        let mut evict_len = local_retention_prefix(&facts, size_debt);
133        if self.active.is_none() {
134            // Nothing to roll into: the newest sealed segment stays.
135            evict_len = evict_len.min(self.segments.len().saturating_sub(1));
136        } else if evict_len == facts.len() {
137            // Kafka's `deleteSegments`: a log must always keep a segment, so
138            // when every segment goes, the active one is rolled first and the
139            // fresh empty segment the roll opens is the one that stays. The
140            // kernel never selects an empty newest segment, so the roll always
141            // seals records and never recreates what it is about to delete.
142            self.roll_active_segment()?;
143        }
144        let to_evict: Vec<Offset> = self
145            .segments
146            .iter()
147            .take(evict_len)
148            .map(Segment::base_offset)
149            .collect();
150        if !to_evict.is_empty() {
151            self.rollover_flusher.finish()?;
152        }
153
154        // Unlink first, and forget only what actually left the disk. A failed
155        // unlink otherwise drops the segment from `self.segments` -- and from
156        // `Log::size`, and from the partition's disk gauge -- while its bytes
157        // stay on the filesystem with nothing left to retry them. Eviction is
158        // a prefix, so stopping at the first failure keeps it one.
159        let mut deleted: HashSet<Offset> = HashSet::with_capacity(to_evict.len());
160        let mut failure: Option<LogError> = None;
161        for base in to_evict {
162            if let Err(error) = self.retire_segment(base, now) {
163                failure = Some(error);
164                break;
165            }
166            deleted.insert(base);
167            // Kafka's `deleteSegments` → `deleteProducerSnapshots`: the
168            // snapshot at a deleted segment's base goes with it.
169            if let Err(error) = producer_snapshot::remove_at(&self.dir, base) {
170                failure = Some(error);
171                break;
172            }
173        }
174        tracing::Span::current().record("evicted", deleted.len());
175        self.segments
176            .retain(|s| !deleted.contains(&s.base_offset()));
177        self.sealed_txn_indexes
178            .retain(|base, _| !deleted.contains(base));
179        self.stamp_indexes.retain(|base, _| !deleted.contains(base));
180        if let Some(error) = failure {
181            // The floor still follows whatever did come off disk, so the log
182            // start is refreshed before the failure is reported.
183            self.follow_retained_segments(Instant::now())?;
184            return Err(error);
185        }
186        // Ordinary retention deletes the records outright: nothing holds them
187        // any more, so the global floor follows the files off disk (Kafka's
188        // `deleteSegments` → `maybeIncrementLogStartOffset`). This is the
189        // opposite of the tiered eviction in `delete_local_segments_through`,
190        // which leaves the floor behind because the remote tier still answers
191        // for those offsets. The early return above keeps a tiered topic out
192        // of this path entirely. The segment names record the new start, so
193        // its checkpoint write waits for the timer Kafka writes it on.
194        self.follow_retained_segments(Instant::now())?;
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use {
202        super::*,
203        crate::{
204            config::LogConfig,
205            log::test_support::{
206                TempLog, append_samples, install_stamps, rolled_log, rolling_test_log,
207                sample_batch, test_log, tiny_segments,
208            },
209        },
210        assert2::check,
211        krabka_units::prelude::{ByteSize, kibibytes, millis, secs},
212        std::time::{Duration, UNIX_EPOCH},
213        tempfile::tempdir,
214    };
215
216    /// Retention may take the whole log, but it never leaves the log without a
217    /// segment, however far past the budget the log is.
218    ///
219    /// A log with no segments has nowhere to append and no offset to report.
220    /// Kafka's `deleteSegments` rolls before it deletes every segment, so the
221    /// fresh empty segment that roll opens is the one that stays, and the log
222    /// end and start both land on it.
223    #[test]
224    fn retention_never_evicts_the_last_segment() {
225        let dir = tempdir().unwrap();
226        // Roll often, and keep nothing: everything is evictable.
227        let config = LogConfig {
228            segment_size: kibibytes(1),
229            retention_size: Some(ByteSize::ZERO),
230            ..LogConfig::default()
231        };
232        let mut log = Log::open(dir.path(), config).unwrap();
233        append_samples(&mut log, 40, 4);
234        check!(!log.segments.is_empty(), "the appends should have rolled");
235
236        let end = log.log_end_offset();
237        log.tick(SystemTime::now(), log.log_end_offset())
238            .expect("tick");
239        check!(log.segments.is_empty(), "every sealed segment is gone");
240        check!(
241            log.active.as_ref().map(Segment::base_offset) == Some(end),
242            "an empty active segment stays at the log end"
243        );
244        check!(log.log_start_offset() == end);
245        // And it is still usable afterwards.
246        let mut batch = sample_batch(1);
247        check!(
248            log.append(&mut batch).is_ok(),
249            "the log still accepts appends"
250        );
251    }
252
253    /// Kafka's `UnifiedLog.deleteOldSegments`: `cleanup.policy` decides which
254    /// passes run. Each log has three one-record segments. Offset 0 is older
255    /// than `retention.ms`, and offsets 1 and 2 are fresh. The base offsets
256    /// that survive the tick are compared as a whole.
257    #[test]
258    fn tick_applies_time_and_size_retention_only_when_the_policy_deletes() {
259        use crate::config::CleanupPolicy;
260
261        /// The policy, the retention byte budget, the log start offset set
262        /// before the tick, and the base offsets that survive it.
263        struct Case {
264            cleanup_policy: CleanupPolicy,
265            retention_size: Option<ByteSize>,
266            log_start: Offset,
267            expected: Vec<Offset>,
268        }
269
270        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
271        let all = vec![Offset(0), Offset(1), Offset(2)];
272        let cases = [
273            Case {
274                cleanup_policy: CleanupPolicy::Delete,
275                retention_size: None,
276                log_start: Offset(0),
277                expected: vec![Offset(1), Offset(2)],
278            },
279            Case {
280                cleanup_policy: CleanupPolicy::Compact,
281                retention_size: None,
282                log_start: Offset(0),
283                expected: all.clone(),
284            },
285            Case {
286                cleanup_policy: CleanupPolicy::CompactAndDelete,
287                retention_size: None,
288                log_start: Offset(0),
289                expected: vec![Offset(1), Offset(2)],
290            },
291            // Size retention with a budget of nothing deletes every segment,
292            // the active one behind a roll, but only under a policy that
293            // deletes.
294            Case {
295                cleanup_policy: CleanupPolicy::Delete,
296                retention_size: Some(ByteSize::ZERO),
297                log_start: Offset(0),
298                expected: vec![],
299            },
300            Case {
301                cleanup_policy: CleanupPolicy::Compact,
302                retention_size: Some(ByteSize::ZERO),
303                log_start: Offset(0),
304                expected: all,
305            },
306            Case {
307                cleanup_policy: CleanupPolicy::CompactAndDelete,
308                retention_size: Some(ByteSize::ZERO),
309                log_start: Offset(0),
310                expected: vec![],
311            },
312            // A segment wholly below the log start offset goes under every
313            // policy, and a segment the start offset only reaches stays.
314            Case {
315                cleanup_policy: CleanupPolicy::Compact,
316                retention_size: None,
317                log_start: Offset(1),
318                expected: vec![Offset(1), Offset(2)],
319            },
320            Case {
321                cleanup_policy: CleanupPolicy::Compact,
322                retention_size: None,
323                log_start: Offset(2),
324                expected: vec![Offset(2)],
325            },
326        ];
327
328        for Case {
329            cleanup_policy,
330            retention_size,
331            log_start,
332            expected,
333        } in cases
334        {
335            let dir = tempdir().unwrap();
336            let mut log = Log::open(dir.path(), LogConfig {
337                retention: Some(secs(10)),
338                retention_size,
339                cleanup_policy,
340                ..tiny_segments()
341            })
342            .unwrap();
343            for timestamp in [1_000, 95_000, 95_000] {
344                let mut batch = sample_batch(1);
345                batch.base_timestamp = timestamp;
346                batch.max_timestamp = timestamp;
347                log.append(&mut batch).unwrap();
348            }
349            log.set_log_start_offset(log_start).unwrap();
350
351            log.tick(now, log.log_end_offset()).unwrap();
352
353            let surviving: Vec<Offset> = log
354                .segments
355                .iter()
356                .chain(log.active.as_ref())
357                .filter(|segment| segment.size() > ByteSize::ZERO)
358                .map(Segment::base_offset)
359                .collect();
360            check!(
361                surviving == expected,
362                "{cleanup_policy:?} {retention_size:?} start={log_start:?}"
363            );
364        }
365    }
366
367    #[test]
368    fn tick_with_no_retention_is_noop() {
369        let TempLog { dir: _dir, mut log } = test_log();
370        let mut b1 = sample_batch(2);
371        let mut b2 = sample_batch(3);
372        log.append(&mut b1).unwrap();
373        log.append(&mut b2).unwrap();
374        let before = log.log_end_offset();
375        log.tick(SystemTime::now(), log.log_end_offset()).unwrap();
376        assert2::assert!(log.log_end_offset() == before);
377    }
378
379    /// Kafka's `deletableSegments` walks the active segment too: a lone
380    /// active segment past `retention.ms` goes behind a roll, and the log
381    /// keeps an empty segment at its end with the start moved up to it. An
382    /// empty active segment is never deleted, so a second tick changes
383    /// nothing.
384    #[test]
385    fn tick_deletes_a_breached_active_segment_behind_a_roll() {
386        use std::time::Duration;
387        let dir = tempdir().unwrap();
388        let config = LogConfig {
389            retention: Some(secs(1)),
390            ..LogConfig::default()
391        };
392        let mut log = Log::open(dir.path(), config).unwrap();
393        log.append(&mut sample_batch(2)).unwrap();
394        let now = SystemTime::now() + Duration::from_hours(30 * 24);
395
396        for _ in 0..2 {
397            log.tick(now, log.log_end_offset()).unwrap();
398            check!(log.segments.is_empty());
399            check!(log.active.as_ref().map(Segment::base_offset) == Some(Offset(2)));
400            check!(log.log_start_offset() == Offset(2));
401            check!(log.log_end_offset() == Offset(2));
402        }
403    }
404
405    /// Kafka's `LogSegment.largestTimestamp()`: a segment whose records carry
406    /// no timestamp (`-1`) is aged by its `.log` file's modification time, so
407    /// it stays while the file is young and goes once the file is older than
408    /// `retention.ms`.
409    #[yare::parameterized(
410        young_file = { "a young file stays", Duration::ZERO, false },
411        old_file = { "an old file goes", Duration::from_hours(2), true },
412    )]
413    fn a_segment_without_record_timestamps_is_aged_by_its_file(
414        case_name: &str,
415        age: Duration,
416        deleted: bool,
417    ) {
418        let dir = tempdir().unwrap();
419        let mut log = Log::open(dir.path(), LogConfig {
420            retention: Some(secs(60 * 60)),
421            ..tiny_segments()
422        })
423        .unwrap();
424        let mut untimed = sample_batch(1);
425        untimed.max_timestamp = -1;
426        log.append(&mut untimed).unwrap();
427        log.append(&mut sample_batch(1)).unwrap();
428        let now = SystemTime::now() + age;
429        // Keep the newest segment inside the window whatever `now` is.
430        let mut fresh = sample_batch(1);
431        fresh.max_timestamp = retention::now_ms(now);
432        log.append(&mut fresh).unwrap();
433
434        log.tick(now, log.log_end_offset()).unwrap();
435
436        let first = log.segments.first().map(Segment::base_offset);
437        check!(
438            (first != Some(Offset(0))) == deleted,
439            "{case_name}: {first:?}"
440        );
441    }
442
443    /// Kafka's `deletableSegments` deletes a segment only once the high
444    /// watermark has passed its upper bound: the next segment's base, or the
445    /// log end for the active segment.
446    #[yare::parameterized(
447        nothing_replicated = { "nothing is replicated", Offset(0), Offset(0) },
448        first_segment_replicated = { "the first segment is replicated", Offset(1), Offset(1) },
449        sealed_segments_replicated = { "the sealed segments are replicated", Offset(2), Offset(2) },
450        whole_log_replicated = { "the whole log is replicated", Offset(3), Offset(3) },
451    )]
452    fn retention_stops_at_the_high_watermark(
453        what: &str,
454        high_watermark: Offset,
455        expected_start: Offset,
456    ) {
457        // `(what, high watermark, log start after the tick)`. Three
458        // one-record segments at 0, 1 and 2, all past retention.ms.
459        let dir = tempdir().unwrap();
460        let config = LogConfig {
461            retention: Some(secs(1)),
462            ..tiny_segments()
463        };
464        let mut log = Log::open(dir.path(), config).unwrap();
465        for _ in 0..3 {
466            let mut batch = sample_batch(1);
467            batch.base_timestamp = 1_000;
468            batch.max_timestamp = 1_000;
469            log.append(&mut batch).unwrap();
470        }
471
472        log.tick(
473            SystemTime::UNIX_EPOCH + Duration::from_secs(60),
474            high_watermark,
475        )
476        .unwrap();
477
478        assert2::check!(log.log_start_offset() == expected_start, "{what}");
479    }
480
481    #[test]
482    fn tick_removes_only_retained_away_segment_stamp_indexes() {
483        let dir = tempdir().unwrap();
484        let mut log = Log::open(dir.path(), LogConfig {
485            retention: Some(millis(1)),
486            ..tiny_segments()
487        })
488        .unwrap();
489        install_stamps(&mut log, 10, 1);
490        for timestamp in [0, 0, 1_000] {
491            let mut batch = sample_batch(1);
492            batch.max_timestamp = timestamp;
493            log.append(&mut batch).unwrap();
494        }
495
496        log.tick(UNIX_EPOCH + Duration::from_secs(1), log.log_end_offset())
497            .unwrap();
498
499        check!(log.stamp_for_offset(Offset(0)) == None);
500        check!(log.stamp_for_offset(Offset(1)) == None);
501        check!(log.stamp_for_offset(Offset(2)) == Some(12));
502        check!(!log.sealed_txn_indexes.contains_key(&Offset(0)));
503        check!(!log.sealed_txn_indexes.contains_key(&Offset(1)));
504    }
505
506    /// Kafka's `deleteRetentionSizeBreachedSegments` deletes an oldest segment
507    /// only while the size over budget still covers the whole segment
508    /// (`diff - segment.size() >= 0`), and the active segment counts toward
509    /// the log size. Each log holds four sealed segments and an active one,
510    /// all of one size `s`; the budget is the total minus the debt below.
511    #[test]
512    fn tick_size_retention_deletes_only_segments_the_debt_covers() {
513        /// A case name, the size debt in half-segments, and the sealed
514        /// segments left.
515        struct DebtCase {
516            name: &'static str,
517            debt_halves: Option<u64>,
518            expected_sealed: usize,
519        }
520        let cases = [
521            DebtCase {
522                name: "under budget",
523                debt_halves: None,
524                expected_sealed: 4,
525            },
526            DebtCase {
527                name: "exactly at budget",
528                debt_halves: Some(0),
529                expected_sealed: 4,
530            },
531            DebtCase {
532                name: "half a segment over",
533                debt_halves: Some(1),
534                expected_sealed: 4,
535            },
536            DebtCase {
537                name: "one segment over",
538                debt_halves: Some(2),
539                expected_sealed: 3,
540            },
541            DebtCase {
542                name: "one and a half segments over",
543                debt_halves: Some(3),
544                expected_sealed: 3,
545            },
546            DebtCase {
547                name: "two segments over",
548                debt_halves: Some(4),
549                expected_sealed: 2,
550            },
551            DebtCase {
552                name: "the whole log over, the active segment behind a roll",
553                debt_halves: Some(10),
554                expected_sealed: 0,
555            },
556        ];
557        for DebtCase {
558            name,
559            debt_halves,
560            expected_sealed,
561        } in cases
562        {
563            let dir = tempdir().unwrap();
564            let mut log = rolling_test_log(dir.path());
565            append_samples(&mut log, 5, 1);
566            let segment = log.segments[0].size().bytes_u64();
567            check!(
568                log.segments
569                    .iter()
570                    .chain(log.active.as_ref())
571                    .all(|s| s.size().bytes_u64() == segment),
572                "{name}: the fixture needs equal segments"
573            );
574            let total = log.size().bytes_u64();
575            let budget = debt_halves.map_or(total + 1, |halves| {
576                total.saturating_sub(halves * segment / 2)
577            });
578            let mut config = log.config_snapshot();
579            config.retention_size = Some(ByteSize::from_bytes(budget));
580            log.set_config(config);
581
582            log.tick(SystemTime::UNIX_EPOCH, log.log_end_offset())
583                .unwrap();
584
585            check!(log.segments.len() == expected_sealed, "{name}");
586        }
587    }
588
589    #[test]
590    fn tick_skips_retention_when_remote_storage_enable_is_true() {
591        use std::time::Duration;
592        let far_future = SystemTime::now() + Duration::from_hours(365 * 24);
593
594        // Tiered topic: tick must not delete any segment, however old.
595        // The remote-log manager owns local eviction.
596        let dir_tiered = tempdir().unwrap();
597        let mut tiered = rolled_log(dir_tiered.path(), &LogConfig {
598            remote_storage_enable: true,
599            retention: Some(millis(1)),
600            ..LogConfig::default()
601        });
602        let sealed_before = tiered.tierable_segments().len();
603        assert2::assert!(sealed_before > 0);
604        tiered.tick(far_future, Offset(i64::MAX)).unwrap();
605        assert2::assert!(tiered.tierable_segments().len() == sealed_before);
606
607        // Non-tiered baseline: tick should still evict aggressively.
608        let dir_plain = tempdir().unwrap();
609        let mut plain = rolled_log(dir_plain.path(), &LogConfig {
610            remote_storage_enable: false,
611            retention: Some(millis(1)),
612            ..LogConfig::default()
613        });
614        assert2::assert!(!plain.tierable_segments().is_empty());
615        plain.tick(far_future, Offset(i64::MAX)).unwrap();
616        // Non-tiered path: every segment is past retention, so every sealed
617        // segment is evicted.
618        assert2::assert!(plain.tierable_segments().len() == 0);
619    }
620}