Skip to main content

krabka_log/log/
compaction.rs

1//! One compaction pass over the sealed segment list, and the broker-side
2//! inputs that pass depends on.
3//!
4//! The pass rewrites the consumed sealed segments into one or more new ones,
5//! grouped so no output segment exceeds `segment.bytes` (Kafka's
6//! `Cleaner.groupSegmentsBySize`), and never touches the active segment, so
7//! the log-end offset a producer sees does not move.
8
9use {
10    super::Log,
11    crate::{
12        compact::{
13            RewriteOutput, RewriteRetention, atomic_swap, atomic_swap_retiring,
14            replaces_producer_snapshot,
15        },
16        error::LogError,
17        retention::{self, DirSync},
18        segment::{SealedInput, Segment},
19        txn_index::TxnIndex,
20    },
21    krabka_ids::Offset,
22    krabka_units::prelude::{
23        ByteSize, ByteSizeExt as _, Ratio, RatioExt as _, Time, TimeExt as _, fraction,
24    },
25    std::{
26        collections::BTreeSet,
27        sync::{Arc, PoisonError},
28    },
29    tracing::instrument,
30};
31
32mod job;
33
34pub(in crate::log) use self::job::CompactionGate;
35pub use self::job::{BuiltCompaction, CompactionJob, CompactionOutcome};
36use {
37    self::job::GroupPlan,
38    std::{fs, time::SystemTime},
39};
40
41/// Inputs to one [`Log::compact`] pass that depend on broker-side state.
42///
43/// These inputs are the wall clock that computes the KIP-534 delete horizons,
44/// and the last stable offset that bounds what a pass may rewrite. The pass
45/// reads the active producers from the producer state of the log itself, as
46/// Kafka's `Cleaner.cleanSegments` does.
47#[derive(Debug, Clone)]
48pub struct CompactionContext {
49    /// Wall clock for this pass. It drives delete-horizon stamps and expiry.
50    pub now: SystemTime,
51    /// The replica's last stable offset: the first offset that still belongs
52    /// to an open transaction, or that a complete transaction's marker has
53    /// not yet reached the high watermark for.
54    ///
55    /// A pass consumes only sealed segments that end at or below it, which is
56    /// the bound Kafka's `LogCleanerManager.cleanableOffsets` takes: the
57    /// minimum of `log.lastStableOffset()`, the active segment's base offset
58    /// and the segment the minimum compaction lag allows. The high watermark
59    /// alone is not enough, because the records between the LSO and the high
60    /// watermark belong to transactions with no marker yet; rewriting them
61    /// early would leave the output segment's aborted-transaction index
62    /// unable to learn about a marker that arrives later, and a
63    /// `read_committed` consumer could then see a record from a transaction
64    /// that later aborts.
65    pub last_stable_offset: Offset,
66}
67
68/// What a partition looks like to the broker's cleaner before it decides
69/// whether a compaction pass is worth running.
70///
71/// Kafka's `LogCleanerManager.cleanableOffsets` splits a log into three parts
72/// and this mirrors it: the clean prefix below the first dirty offset, the
73/// cleanable range a pass would rewrite, and an uncleanable tail that is
74/// neither. The ratio Kafka tests is the cleanable range over the clean
75/// prefix plus that range, so the uncleanable tail is left out of both halves.
76///
77/// krabka keeps no checkpoint file, because [`Log::compact`] rewrites the
78/// sealed segments it is given into one or more size-bounded outputs: the
79/// leading sealed segments are therefore the previous pass's output and count
80/// as clean, and every sealed segment after them arrived since. A log that
81/// has never been compacted reports no clean prefix at all, which is the
82/// conservative direction: the ratio it reports is never larger than the true
83/// one.
84///
85/// The uncleanable tail is Kafka's, too. The active segment is always in it,
86/// and so is every sealed segment from the first one whose largest timestamp
87/// is younger than `min.compaction.lag.ms` onwards. That is what the min lag
88/// means in Kafka: it withholds the young tail from the pass, it does not
89/// postpone the pass. A topic taking writes more often than its min lag still
90/// has its older segments cleaned on schedule.
91///
92/// The high watermark bounds the tail as well, which is the third term of
93/// Kafka's `LogCleanerManager.cleanableOffsets` minimum beside the active
94/// segment's base offset and the min lag. A record above the watermark is not
95/// committed yet: a leader election can still take it away, and the
96/// truncation that removes it would leave this replica missing the older
97/// record that compaction dropped in its favour. Bounding the pass at the
98/// watermark is what makes compaction a safe local operation on every
99/// replica, leader or follower.
100#[derive(Debug, Clone, Copy, PartialEq)]
101struct CompactionCandidacy {
102    /// Bytes the previous compaction pass already deduplicated.
103    clean_bytes: ByteSize,
104    /// Bytes in the cleanable range, which one pass would deduplicate.
105    cleanable_bytes: ByteSize,
106    /// Timestamp of the oldest record in the dirty region, which is what
107    /// `max.compaction.lag.ms` bounds. `None` when nothing is dirty.
108    ///
109    /// Kafka reads this over the whole dirty region rather than over the
110    /// cleanable range, so a segment held back by the min lag does not hide
111    /// an older one from the max lag.
112    oldest_dirty_timestamp_ms: Option<i64>,
113}
114
115impl CompactionCandidacy {
116    /// Kafka's `LogToClean.cleanableRatio`: cleanable bytes over the clean
117    /// prefix plus them. Zero for an empty log, which no cleaner should spend
118    /// a pass on.
119    fn cleanable_ratio(self) -> Ratio {
120        let total = self.clean_bytes + self.cleanable_bytes;
121        if total.bytes_f64() <= 0.0 {
122            return Ratio::ZERO;
123        }
124        fraction(self.cleanable_bytes.bytes_f64() / total.bytes_f64())
125    }
126}
127
128/// The three `LogConfig` values the decision reads, so it is a pure function
129/// of the log's shape and the topic's configuration.
130#[derive(Debug, Clone, Copy, PartialEq)]
131struct CompactionSchedule {
132    min_lag: Time,
133    max_lag: Option<Time>,
134    min_ratio: Ratio,
135}
136
137/// How many of the dirty sealed segments a pass may clean, given each one's
138/// largest timestamp in offset order.
139///
140/// This is Kafka's `findFirstUncleanableSegment`: with a positive
141/// `min.compaction.lag.ms`, the first segment whose largest timestamp is
142/// within the lag ends the cleanable range, and every segment from there on is
143/// uncleanable. A zero lag withholds nothing. A segment carrying no batch
144/// reports [`i64::MIN`], which no lag can hold back.
145fn cleanable_prefix(largest_timestamps_ms: &[i64], now_ms: i64, min_lag_ms: i64) -> usize {
146    if min_lag_ms <= 0 {
147        return largest_timestamps_ms.len();
148    }
149    let youngest_cleanable_ms = now_ms.saturating_sub(min_lag_ms);
150    largest_timestamps_ms
151        .iter()
152        .position(|timestamp| *timestamp > youngest_cleanable_ms)
153        .unwrap_or(largest_timestamps_ms.len())
154}
155
156/// Kafka's cleanable test, over one partition's [`CompactionCandidacy`].
157///
158/// This is `grabFilthiestCompactedLog`'s filter: a log with nothing cleanable
159/// is never due, one whose dirty region has outlived `max.compaction.lag.ms`
160/// is due regardless of its ratio, and otherwise it is due once the cleanable
161/// range is a larger share of the log than `min.cleanable.dirty.ratio`.
162fn compaction_is_due(
163    candidacy: CompactionCandidacy,
164    schedule: CompactionSchedule,
165    now_ms: i64,
166) -> bool {
167    if candidacy.cleanable_bytes.bytes_f64() <= 0.0 {
168        return false;
169    }
170    if let (Some(max_lag), Some(oldest)) = (schedule.max_lag, candidacy.oldest_dirty_timestamp_ms)
171        && now_ms.saturating_sub(oldest) > max_lag.millis_i64_trunc()
172    {
173        return true;
174    }
175    candidacy.cleanable_ratio().as_f64() > schedule.min_ratio.as_f64()
176}
177
178/// One output group of a compaction pass: the base offsets of the segments it
179/// consumes and the rewrite that replaces them.
180#[derive(Debug)]
181pub(in crate::log) struct GroupRewrite {
182    group_bases: Vec<Offset>,
183    rewrite: RewriteOutput,
184}
185
186impl Log {
187    /// Whether a compaction pass over this partition is due, as Kafka's
188    /// `LogCleanerManager` decides it: the cleanable range has to be a large
189    /// enough share of the log (`min.cleanable.dirty.ratio`), unless the dirty
190    /// region has been dirty so long that `max.compaction.lag.ms` forces a
191    /// pass regardless. `min.compaction.lag.ms` withholds the young tail from
192    /// that range rather than holding the pass back.
193    ///
194    /// The cleanup policy itself is not read here. That is the caller's test,
195    /// because a partition the policy excludes is one the broker's cleaner
196    /// skips before it opens the log at all.
197    #[must_use]
198    pub fn compaction_due(&self, now: SystemTime, last_stable_offset: Offset) -> bool {
199        let (min_lag, max_lag, min_ratio) = {
200            let cfg = self.config.read().unwrap_or_else(PoisonError::into_inner);
201            (
202                cfg.min_compaction_lag,
203                cfg.max_compaction_lag,
204                cfg.min_cleanable_dirty_ratio,
205            )
206        };
207        let now_ms = retention::now_ms(now);
208        compaction_is_due(
209            self.compaction_candidacy(min_lag.millis_i64_trunc(), now_ms, last_stable_offset),
210            CompactionSchedule {
211                min_lag,
212                max_lag,
213                min_ratio,
214            },
215            now_ms,
216        )
217    }
218
219    /// The clean/cleanable split and dirty-region age [`Self::compaction_due`]
220    /// reads. See [`CompactionCandidacy`] for what counts as which and why.
221    fn compaction_candidacy(
222        &self,
223        min_lag_ms: i64,
224        now_ms: i64,
225        last_stable_offset: Offset,
226    ) -> CompactionCandidacy {
227        // Kafka's clean prefix is what a previous pass produced, which it
228        // reads from `cleaner-offset-checkpoint`. A log nothing has cleaned
229        // has no clean prefix at all: its whole sealed region is dirty, which
230        // is what makes a single sealed segment cleanable on the first pass.
231        //
232        // Clamped to the segments actually on hand: retention or truncation
233        // can remove a previous pass's output out from under this count, and
234        // a clean prefix wider than the sealed list itself is nonsensical.
235        let clean_segments = self.clean_prefix_segments.min(self.segments.len());
236        let clean_bytes = self
237            .segments
238            .iter()
239            .take(clean_segments)
240            .fold(ByteSize::ZERO, |total, segment| total + segment.size());
241        let dirty: Vec<&Segment> = self.segments.iter().skip(clean_segments).collect();
242        let cleanable = self
243            .cleanable_sealed_count(min_lag_ms, now_ms, last_stable_offset)
244            .saturating_sub(clean_segments);
245        let cleanable_bytes = dirty
246            .iter()
247            .take(cleanable)
248            .fold(ByteSize::ZERO, |total, segment| total + segment.size());
249        let oldest_dirty_timestamp_ms = dirty
250            .iter()
251            .filter_map(|segment| {
252                segment
253                    .offset_for_timestamp(i64::MIN)
254                    .map(|(_, timestamp)| timestamp)
255            })
256            .min();
257        CompactionCandidacy {
258            clean_bytes,
259            cleanable_bytes,
260            oldest_dirty_timestamp_ms,
261        }
262    }
263
264    /// How many sealed segments one pass may consume, counting the clean
265    /// prefix: the clean prefix plus the dirty segments neither Kafka's
266    /// `min.compaction.lag.ms` nor the high watermark withholds.
267    ///
268    /// The lag applies to every dirty segment, the oldest one included, as
269    /// Kafka's `findFirstUncleanableSegment` tests each dirty segment from the
270    /// first dirty offset. A log nothing has cleaned, or one that has been
271    /// reopened, has no clean prefix, so its oldest sealed segment is dirty
272    /// and the lag holds it back while its records are still young. The clean
273    /// prefix is a previous pass's output, which the lag already released.
274    ///
275    /// Zero for a log with no sealed segment at all, and zero for one whose
276    /// oldest sealed segment still reaches above `last_stable_offset`.
277    fn cleanable_sealed_count(
278        &self,
279        min_lag_ms: i64,
280        now_ms: i64,
281        last_stable_offset: Offset,
282    ) -> usize {
283        let clean_segments = self.clean_prefix_segments.min(self.segments.len());
284        let dirty_timestamps: Vec<i64> = self
285            .segments
286            .iter()
287            .skip(clean_segments)
288            .map(Segment::max_timestamp)
289            .collect();
290        let by_lag = clean_segments + cleanable_prefix(&dirty_timestamps, now_ms, min_lag_ms);
291        by_lag.min(self.sealed_segments_below(last_stable_offset))
292    }
293
294    /// How many of the leading sealed segments end at or below `bound`.
295    ///
296    /// A sealed segment ends where the next one begins, and the last one ends
297    /// where the active segment does. Counting whole segments is what the
298    /// rewrite consumes, so a segment straddling the bound is withheld
299    /// entirely rather than split.
300    fn sealed_segments_below(&self, bound: Offset) -> usize {
301        self.segments
302            .iter()
303            .enumerate()
304            .position(|(index, _)| self.sealed_segment_end(index) > bound)
305            .unwrap_or(self.segments.len())
306    }
307
308    /// Kafka's `Cleaner.groupSegmentsBySize`: split a run of segment sizes
309    /// into contiguous groups, none heavier than `segment_bytes`. Each group
310    /// becomes one output segment of a compaction pass, so a long-lived
311    /// compacted log never ends up with one sealed segment far larger than
312    /// the configured limit. The grouping only combines segments; a single
313    /// segment already at or over the limit still gets a group of its own,
314    /// the same as Kafka never splitting one segment's records across two
315    /// output segments.
316    ///
317    /// Returns the length of each group, in input order; the lengths sum to
318    /// `sizes.len()`. An empty input returns no groups.
319    fn group_segments_by_size(sizes: &[ByteSize], segment_bytes: ByteSize) -> Vec<usize> {
320        let mut groups = Vec::new();
321        // The length and total size of the group being filled.
322        let mut current: Option<(usize, ByteSize)> = None;
323        for &size in sizes {
324            current = match current {
325                Some((len, total)) if total + size <= segment_bytes => {
326                    Some((len + 1, total + size))
327                }
328                Some((len, _)) => {
329                    groups.push(len);
330                    Some((1, size))
331                }
332                None => Some((1, size)),
333            };
334        }
335        if let Some((len, _)) = current {
336            groups.push(len);
337        }
338        groups
339    }
340
341    /// The first offset past sealed segment `index`.
342    fn sealed_segment_end(&self, index: usize) -> Offset {
343        self.segments.get(index + 1).map_or_else(
344            || {
345                self.active
346                    .as_ref()
347                    .map_or_else(|| self.log_end_offset(), Segment::base_offset)
348            },
349            Segment::base_offset,
350        )
351    }
352
353    /// Run one compaction pass over the cleanable sealed segments, holding
354    /// the log throughout: [`Self::prepare_compaction`], then
355    /// [`CompactionJob::build`], then [`Self::finish_compaction`]. A caller
356    /// that shares the log between threads runs the three itself and builds
357    /// without the log's lock.
358    ///
359    /// The pass never touches the active segment, and it stops short of the
360    /// sealed segments `min.compaction.lag.ms` or `ctx.last_stable_offset`
361    /// withholds -- the same range [`Self::compaction_due`] measures, so the
362    /// decision and the pass agree on what one pass would do. The output is
363    /// one or more new sealed segments, grouped so none exceeds
364    /// `segment.bytes`, starting at the lowest input base offset; together
365    /// they replace the sealed segments this pass consumed. Any withheld
366    /// sealed segments stay where they are and become cleanable once their
367    /// records outlive the lag.
368    ///
369    /// `ctx` carries the wall clock, which drives the KIP-534 delete-horizon
370    /// computation, and the last stable offset. The pass keeps the last record
371    /// of each producer in the producer state of this log, and the last batch
372    /// of the pass, as an empty batch (`RETAIN_EMPTY`) when compaction removes
373    /// all of their records. The last record of a producer is its last data
374    /// batch, or a marker at its epoch when it has no data batch at that epoch.
375    /// This is Kafka's `Cleaner.cleanSegments`, which reads
376    /// `UnifiedLog.lastRecordsOfActiveProducers` on every replica. A leader
377    /// and a follower update that state for each batch that they append, so
378    /// they keep the same batches.
379    #[instrument(
380        level = "info",
381        skip_all,
382        fields(sealed_segments = self.segments.len()),
383        err,
384    )]
385    /// # Errors
386    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
387    pub fn compact(&mut self, ctx: &CompactionContext) -> Result<(), LogError> {
388        let Some(job) = self.prepare_compaction(ctx)? else {
389            return Ok(());
390        };
391        let built = job.build();
392        self.finish_compaction(built).map(drop)
393    }
394
395    /// Take a compaction pass over the cleanable sealed segments from the log,
396    /// for [`CompactionJob::build`] to run without it. See [`Self::compact`]
397    /// for what one pass does.
398    ///
399    /// Returns `None` when there is nothing to do: the cleanup policy has no
400    /// `compact`, no sealed segment is cleanable, the log is fenced, or an
401    /// earlier job is still out. Only one job runs at a time; it ends when
402    /// [`Self::finish_compaction`] takes it back, or when it is dropped.
403    ///
404    /// The job holds what the pass reads from the log now: the consumed
405    /// segments' open files, the aborted transactions of their range, and the
406    /// last record of each active producer. Kafka's cleaner reads the same
407    /// facts when a pass starts. An append after this call lands past the
408    /// consumed range, so it changes none of them; a producer it moves on
409    /// only leaves the pass keeping an empty batch the producer no longer
410    /// needs.
411    ///
412    /// # Errors
413    /// Returns an error when the log's rolled segments could not be flushed.
414    pub fn prepare_compaction(
415        &mut self,
416        ctx: &CompactionContext,
417    ) -> Result<Option<CompactionJob>, LogError> {
418        // A fenced log is being removed: an error a flush recorded earlier is
419        // no reason to refuse a pass that does nothing anyway.
420        if self.fenced {
421            return Ok(None);
422        }
423        self.rollover_flusher.check()?;
424        if self.segments.is_empty() {
425            return Ok(None);
426        }
427
428        let (index_interval, delete_retention, min_lag, segment_bytes, record_limit) = {
429            let cfg_guard = self.config.read().unwrap_or_else(PoisonError::into_inner);
430            if !cfg_guard.cleanup_policy.contains_compact() {
431                return Ok(None);
432            }
433            (
434                cfg_guard.index_interval,
435                cfg_guard.delete_retention,
436                cfg_guard.min_compaction_lag,
437                cfg_guard.segment_size,
438                cfg_guard.max_decompressed_record,
439            )
440        };
441
442        let now_ms = retention::now_ms(ctx.now);
443        let consumed =
444            self.cleanable_sealed_count(min_lag.millis_i64_trunc(), now_ms, ctx.last_stable_offset);
445        if consumed == 0 {
446            // Everything sealed is withheld — by the min lag, by the last
447            // stable offset, or by both — so this pass has nothing to rewrite.
448            return Ok(None);
449        }
450        let Some(slot) = self.compaction_gate.claim() else {
451            return Ok(None);
452        };
453
454        // The offset map is built once over the whole consumed range:
455        // whether a record is the newest for its key is a fact about the full
456        // dirty region, not about whichever output group a record lands in.
457        // The map skips the batches of aborted transactions, so it needs the
458        // aborted transactions of the range, wherever their abort marker sits.
459        // The rewrite itself then runs once per size-bounded group, so no
460        // output segment grows past `segment.bytes`, and every group's
461        // `.cleaned` files are written before any of them is promoted.
462        //
463        // Kafka's `Cleaner.cleanSegments` reads the last record of each
464        // active producer from the producer state of the log, once for the
465        // groups of one pass.
466        let fenced = self.compaction_gate.fenced_flag();
467        let past_sealed = || {
468            LogError::Corrupt(format!(
469                "a compaction pass over {consumed} segments reaches past the sealed segments"
470            ))
471        };
472        let inputs: Vec<SealedInput> = self
473            .segments
474            .iter()
475            .take(consumed)
476            .map(|segment| segment.sealed_input().watched_by(Arc::clone(&fenced)))
477            .collect();
478        let upper_bound = self.sealed_segment_end(consumed - 1);
479        let sizes: Vec<ByteSize> = self
480            .segments
481            .iter()
482            .take(consumed)
483            .map(Segment::size)
484            .collect();
485        let mut groups = Vec::new();
486        let mut start = 0usize;
487        for len in Self::group_segments_by_size(&sizes, segment_bytes) {
488            groups.push(GroupPlan {
489                len,
490                aborted: self.aborted_in_range(
491                    self.segments
492                        .get(start)
493                        .ok_or_else(past_sealed)?
494                        .base_offset(),
495                    self.sealed_segment_end(start + len - 1),
496                ),
497            });
498            start += len;
499        }
500        Ok(Some(CompactionJob {
501            dir: self.dir.clone(),
502            io: Arc::clone(&self.io),
503            map_aborted: self.aborted_in_range(
504                inputs.first().ok_or_else(past_sealed)?.base_offset(),
505                upper_bound,
506            ),
507            inputs,
508            groups,
509            active_producers: self.last_records_of_active_producers(),
510            upper_bound,
511            record_limit,
512            retention: RewriteRetention {
513                now_ms,
514                delete_retention,
515            },
516            index_interval,
517            // A rollover flush still holds the files of a segment it sealed,
518            // and writes the producer snapshot at its end, which the swap may
519            // retire. The install waits for the flushes of the consumed
520            // segments, all submitted by now; the build reads the page cache
521            // and need not wait for them.
522            flushes: self.rollover_flusher.ticket(),
523            slot,
524            output_unclaimed: false,
525        }))
526    }
527
528    /// Install a built compaction pass: replace the segments it read with its
529    /// output.
530    ///
531    /// Nothing is installed when the log has dropped or replaced a segment the
532    /// pass read since [`Self::prepare_compaction`], by a truncation,
533    /// retention, `DeleteRecords` or a reset, or when the log has been fenced.
534    /// The pass's files are deleted and [`CompactionOutcome::Stale`] reports
535    /// it, whether or not the build succeeded: a build that failed because
536    /// the log took its input away is no fault of the disk.
537    ///
538    /// # Errors
539    /// Returns the build's error when the build failed on a log that still
540    /// holds its input, and an error when the swap's log I/O fails or the
541    /// rewritten segment is corrupt.
542    pub fn finish_compaction(
543        &mut self,
544        built: BuiltCompaction,
545    ) -> Result<CompactionOutcome, LogError> {
546        let BuiltCompaction { mut job, rewrites } = built;
547        if self.fenced || !self.still_holds_inputs_of(&job) {
548            // Dropping the job deletes its files.
549            return Ok(CompactionOutcome::Stale);
550        }
551        let rewrites = rewrites?;
552        // The install opens each output from what the build recorded, so an
553        // output that no longer holds what the build wrote fails the pass
554        // here, while the consumed segments are still in place and dropping
555        // the job deletes the outputs.
556        for GroupRewrite { rewrite, .. } in &rewrites {
557            let held = fs::metadata(&rewrite.log_swap)?.len();
558            if held != rewrite.frontier.valid_end() {
559                return Err(LogError::Corrupt(format!(
560                    "compaction output {} holds {held} bytes, the build wrote {}",
561                    rewrite.log_swap.display(),
562                    rewrite.frontier.valid_end()
563                )));
564            }
565        }
566        // The build has usually outlasted these flushes, so this rarely
567        // waits, and never for a flush of a segment rolled since the job.
568        self.rollover_flusher.finish_through(job.flushes)?;
569        let consumed = job.inputs.len();
570        // The swap below claims the job's files. The job's inputs share the
571        // consumed segments' file handles, which must close before it, as the
572        // segments' own do.
573        job.output_unclaimed = false;
574        drop(job);
575
576        // Compaction rewrites sealed segments, so any record the last
577        // activation walk read may not survive it. Nothing on disk has changed
578        // until here: the offset map and every rewrite of the build write only
579        // `.cleaned` files and can refuse the pass (a record above
580        // `max.decompressed.message.bytes`), and a refused pass that reset the
581        // schedule would send every later sweep of the partition through a full
582        // delivery walk for nothing.
583        let compacted_from = self.log_start_offset();
584        self.invalidate_delivery_schedule(compacted_from);
585
586        // Take ownership of the consumed segments out of `self.segments` and
587        // drop them now, closing their `.log`/`.index`/`.timeindex` file
588        // handles before any `atomic_swap` runs its delete/rename steps.
589        // `compact::swap` requires this: on Windows, `remove_file` and
590        // `rename` both fail while another handle on the same file is still
591        // open, so promoting even the first output group while a consumed
592        // segment's handle is still alive would fail every time.
593        let consumed_segments: Vec<Segment> = self.segments.drain(..consumed).collect();
594        drop(consumed_segments);
595
596        // A consumed segment with no aborted transaction usually has no
597        // `.txnindex`, and a rename of a missing file costs a path lookup for
598        // each such segment under the log.
599        let without_txn_file: BTreeSet<Offset> = rewrites
600            .iter()
601            .flat_map(|group| &group.group_bases)
602            .copied()
603            .filter(|base| {
604                self.sealed_txn_indexes
605                    .get(base)
606                    .is_some_and(|index| !index.on_disk())
607            })
608            .collect();
609        let mut new_segments: Vec<Segment> = Vec::with_capacity(rewrites.len());
610        for GroupRewrite {
611            group_bases,
612            rewrite,
613        } in &rewrites
614        {
615            if self
616                .config
617                .read()
618                .unwrap_or_else(PoisonError::into_inner)
619                .file_delete_delay
620                <= krabka_units::Time::ZERO
621            {
622                atomic_swap(&*self.io, &self.dir, group_bases, rewrite)?;
623            } else {
624                let io = Arc::clone(&self.io);
625                let dir = self.dir.clone();
626                let now = SystemTime::now();
627                atomic_swap_retiring(&*io, &dir, group_bases, rewrite, |base| {
628                    let mut files = vec!["log", "index", "timeindex"];
629                    if !without_txn_file.contains(&base) {
630                        files.push("txnindex");
631                    }
632                    if replaces_producer_snapshot(base, rewrite.new_base_offset) {
633                        files.push("snapshot");
634                    }
635                    // The swap syncs the directory once all the consumed
636                    // segments are retired, before anything depends on it.
637                    self.retire_files(base, now, &files, DirSync::Deferred)
638                })?;
639            }
640
641            // The rewrite derived the output's frontiers and sparse indexes
642            // as it wrote it, by the rule a recovery scan applies, so the
643            // segment opens without decoding its batches again under the log.
644            let mut new_seg =
645                Segment::open_rewritten(&self.dir, rewrite.new_base_offset, &rewrite.frontier)?;
646            new_seg.set_io(Arc::clone(&self.io));
647            new_seg.seal()?;
648            let txn_index = TxnIndex::open(new_seg.txn_index_path())?;
649            for base in group_bases {
650                self.sealed_txn_indexes.remove(base);
651            }
652            self.sealed_txn_indexes
653                .insert(rewrite.new_base_offset, txn_index);
654            new_segments.push(new_seg);
655        }
656
657        // Every segment just inserted was rewritten from the full offset map
658        // over this pass's consumed range, so all of them -- not only the
659        // first -- are this pass's output and belong to the clean prefix.
660        self.clean_prefix_segments = new_segments.len();
661        self.segments.splice(0..0, new_segments);
662        Ok(CompactionOutcome::Installed)
663    }
664
665    /// Whether the log still leads with the segments `job` read, unchanged.
666    fn still_holds_inputs_of(&self, job: &CompactionJob) -> bool {
667        job.dir == self.dir
668            && self.segments.len() >= job.inputs.len()
669            && self
670                .segments
671                .iter()
672                .zip(&job.inputs)
673                .all(|(segment, input)| segment.still_holds(input))
674    }
675}
676
677#[cfg(test)]
678mod install_tests;
679#[cfg(test)]
680mod job_tests;
681#[cfg(test)]
682mod producer_tests;
683#[cfg(test)]
684mod replication_tests;
685#[cfg(test)]
686mod transaction_tests;
687
688#[cfg(test)]
689mod tests {
690    use {
691        super::*,
692        crate::{
693            CleanupPolicy,
694            config::{DeliveryPolicy, LogConfig},
695            log::test_support::{
696                KeyedRecord, TempLog, append_keyed_samples, compact_test_log, compacting_segments,
697                compaction_ctx, keyed_batch, set_segment_size, tiny_segments,
698            },
699            name,
700            test_support::KeyValue,
701        },
702        bytes::BytesMut,
703        krabka_units::prelude::{bytes, mebibytes},
704        std::{
705            fs::{self, OpenOptions},
706            io::Write as _,
707            path::Path,
708            time::{Duration, SystemTime},
709        },
710        tempfile::tempdir,
711    };
712
713    fn read_values(log: &Log) -> Vec<Vec<u8>> {
714        log.read(Offset(0), mebibytes(1))
715            .unwrap()
716            .batches
717            .iter()
718            .flat_map(|batch| batch.records.iter())
719            .map(|record| record.value.as_deref().unwrap().to_vec())
720            .collect()
721    }
722
723    /// A high watermark past every offset these logs hold, for the tests
724    /// whose subject is the ratio, the lag or the rewrite rather than the
725    /// watermark bound.
726    const UNBOUNDED_HW: Offset = Offset(i64::MAX);
727
728    /// Kafka's cleanable test, over the shapes a partition can be in. The
729    /// clock is fixed at `10_000` ms so a "dirty since" timestamp reads as an
730    /// age directly.
731    #[test]
732    fn the_cleanable_test_reads_the_ratio_and_the_max_lag() {
733        const NOW_MS: i64 = 10_000;
734        let candidacy = |clean: u32, cleanable: u32, oldest: i64| CompactionCandidacy {
735            clean_bytes: bytes(clean),
736            cleanable_bytes: bytes(cleanable),
737            oldest_dirty_timestamp_ms: Some(oldest),
738        };
739        let schedule = |min_lag_ms: i64, max_lag_ms: Option<i64>, ratio: f64| CompactionSchedule {
740            min_lag: Time::from_millis(min_lag_ms),
741            max_lag: max_lag_ms.map(Time::from_millis),
742            min_ratio: fraction(ratio),
743        };
744        let cases = [
745            (
746                "nothing cleanable",
747                candidacy(100, 0, 0),
748                schedule(0, None, 0.5),
749                false,
750            ),
751            (
752                "cleanable two thirds of the log",
753                candidacy(100, 200, 0),
754                schedule(0, None, 0.5),
755                true,
756            ),
757            (
758                // Kafka's filter is `cleanableRatio > minCleanableRatio`, so
759                // a log exactly at the threshold is not yet worth a pass.
760                "cleanable exactly half the log",
761                candidacy(100, 100, 0),
762                schedule(0, None, 0.5),
763                false,
764            ),
765            (
766                "cleanable a quarter of the log is below the ratio",
767                candidacy(300, 100, 0),
768                schedule(0, None, 0.5),
769                false,
770            ),
771            (
772                "below the ratio but past the max lag",
773                candidacy(300, 100, 0),
774                schedule(0, Some(5_000), 0.5),
775                true,
776            ),
777            (
778                "below the ratio and inside the max lag",
779                candidacy(300, 100, 9_000),
780                schedule(0, Some(5_000), 0.5),
781                false,
782            ),
783            (
784                // The min lag withholds segments from the cleanable range; it
785                // never holds back a pass over what is left, so a log with a
786                // cleanable range above the ratio is due whatever the lag is.
787                "above the ratio under a long min lag",
788                candidacy(100, 200, 9_900),
789                schedule(60_000, None, 0.5),
790                true,
791            ),
792            (
793                // Everything dirty is inside the min lag, so the cleanable
794                // range is empty and even the max lag has nothing to clean.
795                "the whole dirty region is withheld by the min lag",
796                candidacy(100, 0, 9_900),
797                schedule(60_000, Some(1), 0.5),
798                false,
799            ),
800        ];
801        for (label, candidacy, schedule, expected) in cases {
802            assert2::check!(
803                compaction_is_due(candidacy, schedule, NOW_MS) == expected,
804                "{label}"
805            );
806        }
807    }
808
809    /// The min lag's own rule: it ends the cleanable range at the first
810    /// segment whose largest timestamp is younger than the lag, and withholds
811    /// nothing when it is zero.
812    #[test]
813    fn the_min_lag_ends_the_cleanable_range_at_the_first_young_segment() {
814        const NOW_MS: i64 = 10_000;
815        // Four dirty segments, the last two written inside a 1s lag.
816        let timestamps = [1_000, 5_000, 9_500, 9_800];
817        let cases = [
818            ("no lag withholds nothing", 0, 4),
819            ("a lag shorter than the whole log", 1_000, 2),
820            ("a lag that covers every segment", 60_000, 0),
821            // The boundary is Kafka's `largestTimestamp > now - minLag`, so a
822            // segment exactly at the edge is still cleanable.
823            ("a segment exactly at the edge", 5_000, 2),
824            ("an empty log has no cleanable segment", 1_000, 0),
825        ];
826        for (label, min_lag_ms, expected) in cases {
827            let stamps: &[i64] = if label.starts_with("an empty log") {
828                &[]
829            } else {
830                &timestamps
831            };
832            assert2::check!(
833                cleanable_prefix(stamps, NOW_MS, min_lag_ms) == expected,
834                "{label}"
835            );
836        }
837    }
838
839    #[test]
840    fn a_pass_leaves_a_log_the_cleaner_no_longer_owes_one() {
841        // Twelve distinct keys survive the pass, so the segment it produces is
842        // the bulk of the log and the ratio falls under Kafka's 0.5 default.
843        let TempLog { dir: _dir, mut log } = compact_test_log();
844        append_keyed_samples(&mut log, 12, |i| format!("k{i}"));
845        assert2::check!(log.compaction_due(SystemTime::now(), UNBOUNDED_HW));
846
847        // Grouping shares `segment.bytes` with the roll that packed these
848        // one-record segments, so widen it before the pass: the fixture
849        // still needs many small sealed segments going in, but this test is
850        // about the ratio the pass leaves behind, not the grouping cap.
851        set_segment_size(&log, mebibytes(1));
852        log.compact(&compaction_ctx()).unwrap();
853        assert2::check!(!log.compaction_due(SystemTime::now(), UNBOUNDED_HW));
854    }
855
856    /// One sealed segment per batch, each stamped `1_000 * i` milliseconds
857    /// after the epoch, all under one key. The last append lands in the active
858    /// segment.
859    fn log_stamped_a_second_apart(dir: &Path, cfg: LogConfig, batches: i64) -> Log {
860        let mut log = Log::open(dir, cfg).unwrap();
861        for i in 0..batches {
862            let value = format!("v{i}");
863            let mut batch = keyed_batch(i, &[KeyedRecord {
864                offset_delta: 0,
865                key: b"key",
866                value: value.as_bytes(),
867            }]);
868            batch.base_timestamp = i * 1_000;
869            batch.max_timestamp = i * 1_000;
870            log.append(&mut batch).unwrap();
871        }
872        log
873    }
874
875    /// A `SystemTime` `millis` after the epoch, which is the clock the
876    /// stamped-segment tests reason in.
877    fn at_epoch_millis(millis: u64) -> SystemTime {
878        SystemTime::UNIX_EPOCH + Duration::from_millis(millis)
879    }
880
881    fn compact_at(log: &mut Log, millis: u64, stable: Offset) -> Result<(), LogError> {
882        log.compact(&CompactionContext {
883            now: at_epoch_millis(millis),
884            last_stable_offset: stable,
885        })
886    }
887
888    /// Kafka's `min.compaction.lag.ms` withholds the young tail from the
889    /// cleanable range; it does not postpone the pass. A topic that takes a
890    /// write more often than its lag would otherwise never be compacted at
891    /// all, because its newest record is always too young.
892    #[test]
893    fn a_topic_written_to_faster_than_its_min_lag_is_still_due() {
894        let dir = tempdir().unwrap();
895        let cfg = LogConfig {
896            cleanup_policy: CleanupPolicy::Compact,
897            // Longer than the second that separates two appends, and short
898            // enough to leave the older segments cleanable.
899            min_compaction_lag: Time::from_millis(1_500),
900            ..tiny_segments()
901        };
902        let log = log_stamped_a_second_apart(dir.path(), cfg, 6);
903
904        assert2::check!(log.compaction_due(at_epoch_millis(6_000), UNBOUNDED_HW));
905    }
906
907    /// Kafka's `Cleaner.groupSegmentsBySize`: a run of segment sizes splits
908    /// into contiguous groups none heavier than `segment.bytes`, combining
909    /// where it can and never splitting a single segment across groups.
910    #[test]
911    fn group_segments_by_size_caps_every_group_at_segment_bytes() {
912        /// The segment sizes, `segment.bytes`, and the expected group lengths.
913        struct GroupCase {
914            sizes: &'static [u32],
915            segment_bytes: u32,
916            expected: &'static [usize],
917        }
918        let cases = [
919            GroupCase {
920                sizes: &[],
921                segment_bytes: 100,
922                expected: &[],
923            },
924            GroupCase {
925                sizes: &[10],
926                segment_bytes: 100,
927                expected: &[1],
928            },
929            // Every size fits in one group together.
930            GroupCase {
931                sizes: &[10, 10, 10],
932                segment_bytes: 100,
933                expected: &[3],
934            },
935            // Nothing combines: each already at or over the limit.
936            GroupCase {
937                sizes: &[60, 60, 60],
938                segment_bytes: 50,
939                expected: &[1, 1, 1],
940            },
941            // A mix: the first two combine, the third starts a new group
942            // with the fourth, and the fifth (alone, over the limit) gets
943            // its own group.
944            GroupCase {
945                sizes: &[30, 30, 30, 30, 200],
946                segment_bytes: 70,
947                expected: &[2, 2, 1],
948            },
949        ];
950        for GroupCase {
951            sizes,
952            segment_bytes,
953            expected,
954        } in cases
955        {
956            let sizes: Vec<ByteSize> = sizes.iter().map(|&n| bytes(n)).collect();
957            let groups = Log::group_segments_by_size(&sizes, bytes(segment_bytes));
958            assert2::check!(
959                groups == expected,
960                "sizes={sizes:?} segment_bytes={segment_bytes}"
961            );
962            assert2::check!(
963                groups.iter().sum::<usize>() == sizes.len(),
964                "group lengths must account for every input segment"
965            );
966        }
967    }
968
969    /// A log nothing has cleaned yet has no clean prefix: its one sealed
970    /// segment is dirty, and the pass it is owed happens.
971    ///
972    /// This is Kafka's `cleaner-offset-checkpoint` read on a fresh log — the
973    /// dirty region is `[logStart, firstUncleanable)`, not `[secondSegment,
974    /// firstUncleanable)`. Treating the first sealed segment as clean before
975    /// any pass has run makes a partition with a single sealed segment report
976    /// a cleanable ratio of zero, so it is never due and a duplicate key
977    /// survives however long the log sits there. The `compact,delete`
978    /// end-to-end case in `jvm_acceptance_cli` is what caught it.
979    #[test]
980    fn a_never_cleaned_log_has_no_clean_prefix() {
981        let dir = tempdir().unwrap();
982        let cfg = compacting_segments();
983        // Two appends: one sealed segment and the active one.
984        let mut log = log_stamped_a_second_apart(dir.path(), cfg, 2);
985
986        assert2::check!(log.segments.len() == 1, "one sealed segment");
987        assert2::check!(
988            log.compaction_due(at_epoch_millis(6_000), UNBOUNDED_HW),
989            "a single dirty sealed segment is cleanable"
990        );
991
992        compact_at(&mut log, 6_000, UNBOUNDED_HW).unwrap();
993
994        // The pass output is now the clean prefix, so the same log is not due
995        // again until something else is produced.
996        assert2::check!(
997            !log.compaction_due(at_epoch_millis(6_000), UNBOUNDED_HW),
998            "a log whose only sealed segment is the last pass's output is settled"
999        );
1000    }
1001
1002    /// Kafka's `findFirstUncleanableSegment` tests every dirty segment against
1003    /// `min.compaction.lag.ms`, the oldest one included. A log nothing has
1004    /// cleaned, or one that was just reopened, has no clean prefix, so its
1005    /// oldest sealed segment is dirty and the lag holds it back until its
1006    /// records are old enough. Consumers that rely on seeing every update
1007    /// inside the lag window would otherwise miss intermediate values.
1008    #[test]
1009    fn the_min_lag_holds_back_the_oldest_segment_of_a_never_cleaned_log() {
1010        let dir = tempdir().unwrap();
1011        let cfg = LogConfig {
1012            cleanup_policy: CleanupPolicy::Compact,
1013            min_compaction_lag: Time::from_millis(3_000),
1014            ..tiny_segments()
1015        };
1016        let mut log = Log::open(dir.path(), cfg).unwrap();
1017        // The first segment holds two records under one key, written at
1018        // timestamp 0. A second batch seals it.
1019        let mut first = keyed_batch(0, &[
1020            KeyedRecord {
1021                offset_delta: 0,
1022                key: b"key",
1023                value: b"old",
1024            },
1025            KeyedRecord {
1026                offset_delta: 1,
1027                key: b"key",
1028                value: b"new",
1029            },
1030        ]);
1031        let mut second = keyed_batch(0, &[KeyedRecord {
1032            offset_delta: 0,
1033            key: b"other",
1034            value: b"v",
1035        }]);
1036        second.base_timestamp = 1_000;
1037        second.max_timestamp = 1_000;
1038        log.append(&mut first).unwrap();
1039        log.append(&mut second).unwrap();
1040        assert2::assert!(log.segments.len() == 1, "one sealed segment");
1041        let compact_at = |log: &mut Log, millis: u64| {
1042            compact_at(log, millis, UNBOUNDED_HW).unwrap();
1043        };
1044
1045        // Every record is younger than the lag: nothing is due, and a pass
1046        // leaves the intermediate value alone.
1047        assert2::check!(!log.compaction_due(at_epoch_millis(2_500), UNBOUNDED_HW));
1048        compact_at(&mut log, 2_500);
1049        assert2::check!(read_values(&log) == vec![b"old".to_vec(), b"new".to_vec(), b"v".to_vec()]);
1050
1051        // Once the segment's newest record has outlived the lag, the pass
1052        // deduplicates it.
1053        assert2::check!(log.compaction_due(at_epoch_millis(3_500), UNBOUNDED_HW));
1054        compact_at(&mut log, 3_500);
1055        assert2::check!(read_values(&log) == vec![b"new".to_vec(), b"v".to_vec()]);
1056    }
1057
1058    /// The withheld tail is withheld from the pass as well, so a record inside
1059    /// the min lag is not deduplicated early: the pass rewrites the segments
1060    /// below the lag and leaves the rest where they are.
1061    #[test]
1062    fn a_pass_leaves_the_segments_the_min_lag_withholds() {
1063        let dir = tempdir().unwrap();
1064        let cfg = LogConfig {
1065            cleanup_policy: CleanupPolicy::Compact,
1066            min_compaction_lag: Time::from_millis(2_500),
1067            ..tiny_segments()
1068        };
1069        // Sealed segments carry timestamps 0..=4_000 and the active one 5_000.
1070        // At 6_000 the lag withholds everything stamped after 3_500, so the
1071        // pass consumes the four segments stamped 0..=3_000.
1072        let mut log = log_stamped_a_second_apart(dir.path(), cfg, 6);
1073        // Grouping shares `segment.bytes` with the roll that produced these
1074        // one-record segments, so widen it before the pass: the fixture
1075        // still needs many small sealed segments going in, but this test is
1076        // about the min-lag withhold, not the grouping cap, so the output
1077        // should be free to collapse them into one.
1078        set_segment_size(&log, mebibytes(1));
1079        compact_at(&mut log, 6_000, UNBOUNDED_HW).unwrap();
1080
1081        assert2::check!(log.segments.len() == 2);
1082        let values = read_values(&log);
1083        assert2::check!(values == vec![b"v3".to_vec(), b"v4".to_vec(), b"v5".to_vec()]);
1084    }
1085
1086    /// Kafka's `cleanableOffsets` bounds a pass at the last stable offset: an
1087    /// uncommitted record can still be truncated away by a leader election,
1088    /// and a pass that dropped an older record in its favour would leave this
1089    /// replica short of one the leader kept.
1090    ///
1091    /// Six one-batch segments under one key, so every sealed segment is
1092    /// cleanable on the lag and the ratio, and only the last stable offset
1093    /// holds the pass back.
1094    #[test]
1095    fn the_last_stable_offset_bounds_the_cleanable_range() {
1096        let dir = tempdir().unwrap();
1097        let cfg = compacting_segments();
1098        let mut log = log_stamped_a_second_apart(dir.path(), cfg, 6);
1099        // Five sealed segments (one batch each) and the active sixth.
1100        assert2::assert!(log.segments.len() == 5);
1101
1102        // Nothing is committed: no sealed segment ends at or below zero, so
1103        // there is nothing to be due for and a pass rewrites nothing.
1104        assert2::check!(!log.compaction_due(at_epoch_millis(6_000), Offset(0)));
1105        compact_at(&mut log, 6_000, Offset(0)).unwrap();
1106        assert2::check!(log.segments.len() == 5, "no segment was consumed");
1107
1108        // Committing the first three segments makes exactly those cleanable:
1109        // the pass collapses them into one and leaves the rest alone. Widen
1110        // `segment.bytes` first, for the same reason as the min-lag test
1111        // above: grouping shares the config with the roll that produced
1112        // these one-record segments, and this test is about the last-stable-
1113        // offset bound, not the grouping cap.
1114        assert2::check!(log.compaction_due(at_epoch_millis(6_000), Offset(3)));
1115        set_segment_size(&log, mebibytes(1));
1116        compact_at(&mut log, 6_000, Offset(3)).unwrap();
1117        assert2::check!(log.segments.len() == 3);
1118        let values = read_values(&log);
1119        assert2::check!(
1120            values
1121                == vec![
1122                    b"v2".to_vec(),
1123                    b"v3".to_vec(),
1124                    b"v4".to_vec(),
1125                    b"v5".to_vec()
1126                ],
1127            "only the committed prefix was deduplicated",
1128        );
1129    }
1130
1131    /// A watermark inside a sealed segment withholds that whole segment: the
1132    /// rewrite consumes segments, not record ranges, so a straddling segment
1133    /// cannot be half-cleaned.
1134    #[test]
1135    fn a_watermark_inside_a_segment_withholds_that_segment() {
1136        let dir = tempdir().unwrap();
1137        let cfg = LogConfig {
1138            cleanup_policy: CleanupPolicy::Compact,
1139            // Two batches per sealed segment.
1140            segment_size: bytes(200),
1141            ..Default::default()
1142        };
1143        let mut log = log_stamped_a_second_apart(dir.path(), cfg, 6);
1144        let first_end = log.segments[1].base_offset();
1145        assert2::assert!(first_end > Offset(1), "the fixture packed a segment");
1146
1147        // One offset short of the first segment's end: it straddles the
1148        // watermark, so nothing is cleanable at all.
1149        compact_at(&mut log, 6_000, first_end - 1).unwrap();
1150        let sealed_before = log.segments.len();
1151        assert2::check!(!log.compaction_due(at_epoch_millis(6_000), first_end - 1));
1152
1153        // At its end offset the segment is committed in full and cleanable.
1154        // Grouping shares `segment.bytes` with the roll that packed this
1155        // segment, so widen it before the pass: this test is about the
1156        // watermark straddle, not the grouping cap, and the straddling
1157        // segment must stay untouched either way.
1158        assert2::check!(log.compaction_due(at_epoch_millis(6_000), first_end));
1159        set_segment_size(&log, mebibytes(1));
1160        compact_at(&mut log, 6_000, first_end).unwrap();
1161        assert2::check!(log.segments.len() == sealed_before);
1162        let out = log.read(Offset(0), mebibytes(1)).unwrap();
1163        let kept: usize = out.batches.iter().map(|batch| batch.records.len()).sum();
1164        assert2::check!(
1165            kept == 5,
1166            "the first segment's duplicate went, nothing else"
1167        );
1168    }
1169
1170    #[test]
1171    fn a_ratio_of_one_holds_a_partly_dirty_log_back_until_the_max_lag() {
1172        let dir = tempdir().unwrap();
1173        let cfg = LogConfig {
1174            cleanup_policy: CleanupPolicy::Compact,
1175            min_cleanable_dirty_ratio: fraction(1.0),
1176            ..tiny_segments()
1177        };
1178        let mut log = Log::open(dir.path(), cfg.clone()).unwrap();
1179        for i in 0..3 {
1180            let value = format!("v{i}");
1181            let mut batch = keyed_batch(i, &[KeyedRecord {
1182                offset_delta: 0,
1183                key: b"key",
1184                value: value.as_bytes(),
1185            }]);
1186            log.append(&mut batch).unwrap();
1187        }
1188        assert2::check!(!log.compaction_due(SystemTime::now(), UNBOUNDED_HW));
1189
1190        // The batches carry timestamp 0, so any max lag has long elapsed and
1191        // the pass is owed however clean the ratio says the log is.
1192        log.set_config(LogConfig {
1193            max_compaction_lag: Some(Time::from_millis(1)),
1194            ..cfg
1195        });
1196        assert2::check!(log.compaction_due(SystemTime::now(), UNBOUNDED_HW));
1197    }
1198
1199    fn assert_corrupt_suffix_preserves_originals(suffix: &[u8]) {
1200        let TempLog { dir, mut log } = compact_test_log();
1201        for i in 0..3 {
1202            let value = format!("v{i}");
1203            let mut batch = keyed_batch(0, &[KeyedRecord {
1204                offset_delta: 0,
1205                key: b"key",
1206                value: value.as_bytes(),
1207            }]);
1208            log.append(&mut batch).unwrap();
1209        }
1210        assert2::assert!(!log.segments.is_empty());
1211
1212        let corrupt_path = name::log_path(dir.path(), log.segments[0].base_offset().0);
1213        OpenOptions::new()
1214            .append(true)
1215            .open(&corrupt_path)
1216            .unwrap()
1217            .write_all(suffix)
1218            .unwrap();
1219
1220        let originals: Vec<_> = log
1221            .segments
1222            .iter()
1223            .map(|segment| {
1224                let path = name::log_path(dir.path(), segment.base_offset().0);
1225                (path.clone(), fs::read(path).unwrap())
1226            })
1227            .collect();
1228        let bases_before: Vec<_> = log.segments.iter().map(Segment::base_offset).collect();
1229
1230        let error = log.compact(&compaction_ctx()).unwrap_err();
1231        assert2::assert!(matches!(error, LogError::Records(_) | LogError::Corrupt(_)));
1232        assert2::assert!(
1233            log.segments
1234                .iter()
1235                .map(Segment::base_offset)
1236                .collect::<Vec<_>>()
1237                == bases_before
1238        );
1239        for (path, bytes) in originals {
1240            assert2::assert!(fs::read(path).unwrap() == bytes);
1241        }
1242    }
1243
1244    #[test]
1245    fn compact_rejects_truncated_suffix_without_replacing_originals() {
1246        assert_corrupt_suffix_preserves_originals(&[0; 16]);
1247    }
1248
1249    #[test]
1250    fn compact_rejects_crc_corrupt_suffix_without_replacing_originals() {
1251        let batch = keyed_batch(0, &[KeyedRecord {
1252            offset_delta: 0,
1253            key: b"corrupt",
1254            value: b"suffix",
1255        }]);
1256        let mut encoded = BytesMut::with_capacity(batch.encoded_len());
1257        batch.encode(&mut encoded).unwrap();
1258        // The stored CRC occupies bytes 17..21. Changing it preserves framing
1259        // while making the complete suffix fail integrity validation.
1260        encoded[17] ^= 1;
1261        assert_corrupt_suffix_preserves_originals(&encoded);
1262    }
1263
1264    /// Kafka trunk's cleaner reads every record of a compressed batch it
1265    /// indexes, and a record above `max.decompressed.message.bytes` throws
1266    /// `InvalidRecordException`, which `LogCleaner` turns into an uncleanable
1267    /// partition. The record here is superseded by a later write, and Kafka
1268    /// refuses it all the same, because the offset map reads it before it knows
1269    /// that. The refused pass leaves the log as it found it.
1270    fn append_compressed_versions(log: &mut Log, codec: krabka_compression::CompressionType) {
1271        let large = [b'x'; 1_000];
1272        for (key, value) in [
1273            (b"k1".as_slice(), large.as_slice()),
1274            (b"k1", b"new"),
1275            (b"tail", b"t"),
1276        ] {
1277            let mut batch = keyed_batch(0, &[KeyedRecord {
1278                offset_delta: 0,
1279                key,
1280                value,
1281            }]);
1282            batch.attributes = batch.attributes.with_compression(codec);
1283            log.append(&mut batch).unwrap();
1284        }
1285    }
1286
1287    fn append_versions(log: &mut Log) {
1288        for i in 0..3 {
1289            let value = format!("v{i}");
1290            log.append(&mut keyed_batch(0, &[KeyedRecord {
1291                offset_delta: 0,
1292                key: b"k1",
1293                value: value.as_bytes(),
1294            }]))
1295            .unwrap();
1296        }
1297    }
1298
1299    /// Three sealed versions of k1 plus an active record outside the pass.
1300    /// A segment's base offset and the bytes of its `.log` file.
1301    #[derive(Debug, PartialEq, Eq)]
1302    struct SegmentFile {
1303        base: i64,
1304        contents: Vec<u8>,
1305    }
1306
1307    fn versioned_log(dir: &Path, active: KeyValue<'_>) -> Log {
1308        let cfg = LogConfig {
1309            cleanup_policy: CleanupPolicy::Compact,
1310            segment_size: bytes(256), // force rolls
1311            ..Default::default()
1312        };
1313        let mut log = Log::open(dir, cfg).unwrap();
1314        append_versions(&mut log);
1315        log.append(&mut keyed_batch(0, &[KeyedRecord {
1316            offset_delta: 0,
1317            key: active.key,
1318            value: active.value,
1319        }]))
1320        .unwrap();
1321        log
1322    }
1323
1324    fn sealed_sizes(log: &Log) -> Vec<ByteSize> { log.segments.iter().map(Segment::size).collect() }
1325
1326    fn append_distinct_keys(log: &mut Log) { append_keyed_samples(log, 6, |i| format!("k{i}")); }
1327
1328    #[yare::parameterized(
1329        compressed_oversized = {
1330            "compressed, oversized",
1331            krabka_compression::CompressionType::Gzip,
1332            Some(bytes(100)),
1333            true,
1334        },
1335        compressed_without_limit = {
1336            "compressed, no limit",
1337            krabka_compression::CompressionType::Gzip,
1338            None,
1339            false,
1340        },
1341        uncompressed_oversized = {
1342            "uncompressed, oversized",
1343            krabka_compression::CompressionType::None,
1344            Some(bytes(100)),
1345            false,
1346        },
1347    )]
1348    fn compact_refuses_a_compressed_record_above_the_limit_and_leaves_the_log_alone(
1349        name: &'static str,
1350        codec: krabka_compression::CompressionType,
1351        limit: Option<ByteSize>,
1352        refuses: bool,
1353    ) {
1354        let dir = tempdir().unwrap();
1355        let cfg = LogConfig {
1356            cleanup_policy: CleanupPolicy::Compact,
1357            max_decompressed_record: limit,
1358            ..tiny_segments()
1359        };
1360        let mut log = Log::open(dir.path(), cfg).unwrap();
1361        append_compressed_versions(&mut log, codec);
1362        let files = |log: &Log| -> Vec<SegmentFile> {
1363            log.segments
1364                .iter()
1365                .map(|segment| {
1366                    let base = segment.base_offset().0;
1367                    SegmentFile {
1368                        base,
1369                        contents: fs::read(name::log_path(dir.path(), base)).unwrap(),
1370                    }
1371                })
1372                .collect()
1373        };
1374        let before = files(&log);
1375
1376        let result = log.compact(&compaction_ctx());
1377
1378        if refuses {
1379            let error = result.unwrap_err();
1380            assert2::check!(
1381                matches!(error, LogError::RecordTooLarge { limit: 100, .. }),
1382                "{name}: {error}"
1383            );
1384            assert2::check!(files(&log) == before, "{name}");
1385        } else {
1386            result.unwrap();
1387            let out = log.read(Offset(0), mebibytes(1)).unwrap();
1388            let k1: Vec<&[u8]> = out
1389                .batches
1390                .iter()
1391                .flat_map(|batch| batch.records.iter())
1392                .filter(|record| record.key.as_deref() == Some(b"k1".as_slice()))
1393                .filter_map(|record| record.value.as_deref())
1394                .collect();
1395            assert2::check!(k1 == [b"new".as_slice()], "{name}");
1396        }
1397    }
1398
1399    /// A pass rewrites records, so it resets what the last delivery walk learned
1400    /// about them, and it does that only once it will change the log. A pass a
1401    /// record above `max.decompressed.message.bytes` refuses touches nothing on
1402    /// disk, and a refusal that dropped the watermark would send every later
1403    /// sweep of the partition through a full delivery walk.
1404    #[yare::parameterized(
1405        refused = { "refused", Some(bytes(100)), true },
1406        rewritten = { "rewritten", None, false },
1407    )]
1408    fn only_a_pass_that_rewrites_resets_the_delivery_watermark(
1409        name: &'static str,
1410        limit: Option<ByteSize>,
1411        refuses: bool,
1412    ) {
1413        use krabka_compression::CompressionType;
1414
1415        let dir = tempdir().unwrap();
1416        let cfg = LogConfig {
1417            cleanup_policy: CleanupPolicy::Compact,
1418            delivery_policy: DeliveryPolicy::Scheduled,
1419            max_decompressed_record: limit,
1420            ..tiny_segments()
1421        };
1422        let mut log = Log::open(dir.path(), cfg).unwrap();
1423        append_compressed_versions(&mut log, CompressionType::Gzip);
1424        // Every batch is stamped 0, long past, so the walk reads them all.
1425        log.advance_delivery_watermark(1_000_000);
1426        assert2::assert!(log.delivery_watermark() == log.log_end_offset(), "{name}");
1427
1428        let result = log.compact(&compaction_ctx());
1429
1430        assert2::check!(result.is_err() == refuses, "{name}");
1431        let expected = if refuses {
1432            log.log_end_offset()
1433        } else {
1434            log.log_start_offset()
1435        };
1436        assert2::check!(log.delivery_watermark() == expected, "{name}");
1437    }
1438
1439    #[test]
1440    fn compact_no_op_when_only_one_segment() {
1441        let dir = tempdir().unwrap();
1442        let cfg = LogConfig {
1443            cleanup_policy: CleanupPolicy::Compact,
1444            ..Default::default()
1445        };
1446        let mut log = Log::open(dir.path(), cfg).unwrap();
1447        let mut b = keyed_batch(0, &[KeyedRecord {
1448            offset_delta: 0,
1449            key: b"k1",
1450            value: b"v1",
1451        }]);
1452        log.append(&mut b).unwrap();
1453        // Only the active segment exists; sealed list is empty.
1454        log.compact(&compaction_ctx()).unwrap();
1455        assert2::assert!(log.log_end_offset() == 1);
1456    }
1457
1458    #[test]
1459    fn compact_dedupes_sealed_segments_keeps_active_intact() {
1460        let dir = tempdir().unwrap();
1461        // Write 3 sealed segments, each with one record under "k1", then a
1462        // fresh active segment outside the compaction pass.
1463        let mut log = versioned_log(dir.path(), KeyValue {
1464            key: b"active-key",
1465            value: b"active-value",
1466        });
1467
1468        let active_leo_before = log.log_end_offset();
1469        log.compact(&compaction_ctx()).unwrap();
1470        assert2::assert!(log.log_end_offset() == active_leo_before);
1471
1472        // After compaction: read everything, assert only the newest k1 plus
1473        // the active "active-key" survive.
1474        let out = log.read(Offset(0), mebibytes(1)).unwrap();
1475        let all_records: Vec<_> = out.batches.iter().flat_map(|b| b.records.iter()).collect();
1476        let keys: Vec<&[u8]> = all_records
1477            .iter()
1478            .map(|r| r.key.as_ref().unwrap().as_ref())
1479            .collect();
1480        assert2::assert!(keys.contains(&b"k1".as_ref()));
1481        assert2::assert!(keys.contains(&b"active-key".as_ref()));
1482    }
1483
1484    /// Compaction must run and must not do nothing. Three sealed segments
1485    /// each carry a record under the SAME key "k1". After `compact`, exactly
1486    /// ONE k1 record must remain, the newest one, "v2". The sealed segment
1487    /// list must collapse to a single rewritten segment. A compaction that
1488    /// returned `Ok(())` at once would leave all three k1 records and three
1489    /// sealed segments.
1490    #[test]
1491    fn compact_actually_dedupes_reducing_record_count() {
1492        let TempLog { dir: _dir, mut log } = compact_test_log();
1493
1494        // Three sealed segments, each one record under "k1" (v0, v1, v2).
1495        append_versions(&mut log);
1496        // A final append lands in a fresh active segment (untouched by compact).
1497        let mut tail = keyed_batch(0, &[KeyedRecord {
1498            offset_delta: 0,
1499            key: b"tail",
1500            value: b"t",
1501        }]);
1502        log.append(&mut tail).unwrap();
1503
1504        // Sanity: before compaction there are >= 2 sealed segments holding the
1505        // three k1 versions.
1506        assert2::assert!(log.segments.len() >= 2);
1507
1508        // Grouping shares `segment.bytes` with the roll that packed these
1509        // one-record segments, so widen it before the pass: this test is
1510        // about dedup collapsing the sealed segments, not the grouping cap.
1511        set_segment_size(&log, mebibytes(1));
1512        log.compact(&compaction_ctx()).unwrap();
1513
1514        // Sealed segments collapse to exactly one rewritten segment.
1515        assert2::assert!(log.segments.len() == 1);
1516
1517        // Exactly one surviving k1 record, and it is the newest value "v2".
1518        let out = log.read(Offset(0), mebibytes(1)).unwrap();
1519        let k1_values: Vec<&[u8]> = out
1520            .batches
1521            .iter()
1522            .flat_map(|b| b.records.iter())
1523            .filter(|r| r.key.as_deref() == Some(b"k1".as_ref()))
1524            .map(|r| r.value.as_deref().unwrap())
1525            .collect();
1526        assert2::assert!(k1_values == vec![b"v2".as_ref()]);
1527    }
1528
1529    #[test]
1530    fn compact_is_idempotent() {
1531        let dir = tempdir().unwrap();
1532        let mut log = versioned_log(dir.path(), KeyValue {
1533            key: b"active",
1534            value: b"x",
1535        });
1536        log.compact(&compaction_ctx()).unwrap();
1537        let leo1 = log.log_end_offset();
1538        log.compact(&compaction_ctx()).unwrap();
1539        let leo2 = log.log_end_offset();
1540        assert2::assert!(leo1 == leo2);
1541    }
1542
1543    /// A pass that splits into more than one output segment
1544    /// (`Log::group_segments_by_size`) must mark every output clean, not only
1545    /// the first: `clean_prefix_segments` tracks the count of leading clean
1546    /// segments rather than a single boolean, so the whole output run counts
1547    /// toward the clean prefix `compaction_candidacy` reads.
1548    ///
1549    /// Before this fix, `compacted_once` was a `bool`, so
1550    /// `compaction_candidacy` treated only the first output segment as clean
1551    /// and the rest as freshly dirty -- a log made entirely of one pass's own
1552    /// outputs would still (wrongly) report itself due for another pass.
1553    #[test]
1554    fn a_multi_group_pass_treats_every_output_as_clean() {
1555        let TempLog { dir: _dir, mut log } = compact_test_log();
1556        // Six distinct keys: nothing is superseded, so the pass only
1557        // regroups records into size-bounded outputs, it never shrinks them.
1558        append_distinct_keys(&mut log);
1559        // Five sealed segments (one record each) and the active sixth.
1560        assert2::assert!(log.segments.len() == 5);
1561
1562        let sealed_bytes: Vec<ByteSize> = sealed_sizes(&log);
1563        // A cap that fits exactly the first two sealed segments together, so
1564        // the pass is forced to produce more than one output group.
1565        let group_cap = sealed_bytes[0] + sealed_bytes[1];
1566        assert2::assert!(
1567            Log::group_segments_by_size(&sealed_bytes, group_cap).len() >= 2,
1568            "the fixture must actually exercise more than one output group"
1569        );
1570
1571        // `segment.bytes` at compact time is what `Log::compact` reads for
1572        // the grouping cap; widen it to exactly `group_cap`.
1573        set_segment_size(&log, group_cap);
1574        log.compact(&compaction_ctx()).unwrap();
1575
1576        let output_segments = log.segments.len();
1577        assert2::assert!(
1578            output_segments >= 2,
1579            "the pass must have split into more than one output segment"
1580        );
1581        assert2::assert!(
1582            log.clean_prefix_segments == output_segments,
1583            "every rewritten output segment must count toward the clean prefix"
1584        );
1585        assert2::check!(
1586            !log.compaction_due(SystemTime::now(), UNBOUNDED_HW),
1587            "a log made entirely of this pass's own outputs owes no further pass"
1588        );
1589    }
1590
1591    /// [`compact::swap`] requires every consumed segment's file handle
1592    /// closed before it deletes or renames the underlying files -- on
1593    /// Windows, `remove_file` and `rename` both fail while another handle on
1594    /// the same file is open. `Log::compact` meets that precondition by
1595    /// draining the consumed segments out of `self.segments` (dropping their
1596    /// `Segment`s, and with them the `Arc<File>` handles inside) before any
1597    /// `atomic_swap` call runs, for every output group in a multi-group pass,
1598    /// not only the first.
1599    ///
1600    /// This test cannot reproduce the Windows failure itself on this
1601    /// platform (removing or renaming a file out from under an open handle
1602    /// is not an error on Linux), so it instead pins the invariant a
1603    /// Windows run depends on: once a multi-group pass returns, none of the
1604    /// original consumed segments' files remain, only every output's own
1605    /// files. That is only possible if the consumed segments were dropped
1606    /// (and their handles closed) before the swap ran, since a leftover open
1607    /// handle guarding a still-existing original file is exactly what the
1608    /// precondition rules out.
1609    #[test]
1610    fn a_multi_group_pass_drops_every_original_segment_file_before_swap() {
1611        let TempLog { dir, mut log } = compact_test_log();
1612        append_distinct_keys(&mut log);
1613        let original_bases: Vec<i64> = log.segments.iter().map(|s| s.base_offset().0).collect();
1614        assert2::assert!(original_bases.len() == 5);
1615
1616        let sealed_bytes: Vec<ByteSize> = sealed_sizes(&log);
1617        let group_cap = sealed_bytes[0] + sealed_bytes[1];
1618        set_segment_size(&log, group_cap);
1619        log.compact(&compaction_ctx()).unwrap();
1620        assert2::assert!(
1621            log.segments.len() >= 2,
1622            "the fixture exercises multiple groups"
1623        );
1624
1625        // Every original consumed segment's `.log`/`.index`/`.timeindex`
1626        // files are gone, except where an output group's own base offset
1627        // happens to equal one it consumed (the lowest offset in that
1628        // group): `atomic_swap` only removes the others once their
1629        // `Segment` handle is closed, so their absence here is only
1630        // possible because the drain-before-swap ordering held for every
1631        // group.
1632        let new_bases: Vec<i64> = log.segments.iter().map(|s| s.base_offset().0).collect();
1633        for original_base in original_bases {
1634            if new_bases.contains(&original_base) {
1635                continue;
1636            }
1637            assert2::assert!(!name::log_path(dir.path(), original_base).exists());
1638            assert2::assert!(!name::index_path(dir.path(), original_base).exists());
1639            assert2::assert!(!name::timeindex_path(dir.path(), original_base).exists());
1640        }
1641        // Every output segment's own files exist, and no `.swap` scratch
1642        // file was left behind.
1643        for base in &new_bases {
1644            assert2::assert!(name::log_path(dir.path(), *base).exists());
1645        }
1646        let leftover_swaps = fs::read_dir(dir.path())
1647            .unwrap()
1648            .filter_map(Result::ok)
1649            .any(|entry| entry.file_name().to_string_lossy().ends_with(".swap"));
1650        assert2::assert!(!leftover_swaps, "no .swap scratch file must remain");
1651    }
1652}