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 ×tamps
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}