skip to content

Mechanically, how does a single compaction pass deduplicate keys, and what is the offset map?

level: seniorimportance: should knowfreq 40%

answer

  1. two passes: build map, then recopy/filter
  2. SkimpyOffsetMap: key-hash -> highest offset
  3. retain iff offset == mapped latest
  4. offsets preserved -> sparse, with gaps
  5. log.cleaner.dedupe.buffer.size 128MB / load.factor 0.9

basics

~20 s

The cleaner makes two passes over the dirty head. First it builds an in-memory offset map: key -> highest offset seen. Then it recopies the log, keeping each record only if its offset equals the map's value for that key, and merges results into new compacted segments.

solid answer

~50 s

Compaction is a two-pass, key-deduplicating segment rewrite. Pass 1 scans the dirty head and builds a SkimpyOffsetMap: a hash of each key's last (highest) offset. Pass 2 walks the cleanable log (tail + head) and writes a record to a new segment only if its offset matches the map entry for its key — i.e., it is the latest occurrence — otherwise it is dropped. Surviving records keep their original offsets, so offsets stay monotonic but become sparse (gaps where duplicates were removed). The new segments replace the old ones via an atomic swap. The offset map is memory-bounded: log.cleaner.dedupe.buffer.size (default 128 MB) shared across log.cleaner.threads, and load is capped by log.cleaner.io.buffer.load.factor (default 0.9). If a single dirty section has more unique keys than the map can hold, the cleaner cleans only as far as the map allows in that pass and resumes next cycle.

go deeper

for a junior

Know the cleaner keeps the newest record per key and drops older copies.

for a middle

Describe the two-pass build-map-then-filter approach and that offsets are preserved with gaps.

for a senior

Explain the SkimpyOffsetMap, retain-iff-latest-offset rule, atomic segment swap, and dedupe buffer limits.

for a principal

Diagnose stalled compaction from buffer exhaustion and tune dedupe buffer vs thread count for high-cardinality keyspaces.

## Goal of a pass A compaction pass turns a region of the log that may contain many records per key into one where each key appears at most once (its newest value), while preserving original offsets and ordering. ## Pass 1 — build the offset map The cleaner scans the **dirty head** and builds an in-memory **offset map** (implementation: `SkimpyOffsetMap`). For each record it records: ``` map[key] = max(offset seen for key) ``` So after pass 1 the map answers 'what is the highest (most recent) offset for this key in the dirty region?'. It is a *skimpy* (compact) hash map storing key-hash → offset to save memory; it stores the key's MD5 hash rather than the raw key. ## Pass 2 — recopy and filter The cleaner then walks the **cleanable log** (the already-clean tail plus the just-mapped head) record by record. A record is **retained** iff: - its key is in the map AND its offset == the mapped (latest) offset, OR - its offset is beyond what pass 1 mapped (newer than the map's coverage), or its key wasn't in the dirty head (then the tail copy is already the latest and is kept). Everything else — older duplicates of a key whose newer version exists — is **dropped**. Retained records are appended to fresh segment files; **offsets are preserved**, so the new log has the same offset values but with holes where records were removed. This is why consumers of compacted topics see non-contiguous offsets. ## Atomic swap New `.clean` segments are built alongside the originals and then swapped in atomically (rename), so readers never see a partially compacted log. The cleaner checkpoint advances to record how far the tail is now clean. ## Memory bounds — why the map size matters The map lives in a fixed buffer: - `log.cleaner.dedupe.buffer.size` — total bytes (default **128 MB**) shared across all cleaner threads. - `log.cleaner.io.buffer.load.factor` — how full the map may get (default **0.9**) before it's considered full. Each entry costs roughly 24 bytes (16-byte MD5 + 8-byte offset). If a single dirty section contains **more unique keys than fit**, the cleaner can only map a prefix of the head; it compacts up to that point and resumes the rest on the next cycle. Symptoms of an undersized buffer are slow compaction progress and growing logs; the fix is a larger dedupe buffer or more threads (which *splits* the buffer, so there is a balance). ## Edge cases - **Tombstones** (null-value records) are kept through compaction for `delete.retention.ms` so consumers learn of deletions, then dropped. - The **active segment** is never part of a pass, so the newest writes are always present in full. - Records keep their **timestamps and headers**; only superseded duplicates are removed. ## Why two passes You cannot decide whether a record is the latest for its key until you have seen the whole region — hence pass 1 maps the maximum offset per key, and pass 2 uses that knowledge to filter.

  • Why do consumers of a compacted topic see gaps in offsets?
    Compaction preserves the original offset of each surviving record but physically removes superseded duplicates, leaving holes. Offsets remain monotonically increasing but non-contiguous.
  • What happens if a dirty section has more unique keys than the dedupe buffer can hold?
    The offset map fills before covering the whole head, so the cleaner compacts only the prefix it could map and resumes the remainder next cycle. Compaction progress slows; remediate with a bigger dedupe buffer (log.cleaner.dedupe.buffer.size) or rebalanced threads.

saying these in an interview costs you the question

  • Describing compaction as a single in-place pass — it is two passes that rewrite into new segments.
  • Saying offsets are renumbered/compacted — surviving records keep their original offsets, leaving gaps.
  • Claiming the offset map stores full keys — it stores key hashes (MD5) to save memory.
  • Ignoring the dedupe buffer limit and assuming the whole head is always mapped in one pass.

context