KafkaIQ

2.3 Compaction & Retention Mechanics

Module Overview

Goal: Explain Kafka's log cleanup mechanisms at the engine level.

What You'll Learn:

  • Delete-based retention (time and size)
  • Log compaction algorithm internals
  • Tomb

stones and deletion markers

  • Cleaner thread mechanics
  • Compaction guarantees and edge cases
  • Cleanup coordination

What's NOT Covered Here:

  • Topic configuration strategy (covered in Operations)
  • When to use compaction vs deletion (design decision, covered in Operations)
  • Performance tuning of cleanup (covered in Operations)

Key Insight: Kafka provides two cleanup strategies — deletion (time/size-based) and compaction (key-based). Understanding the internal mechanics explains their guarantees and limitations.


1. Why Cleanup?

The Disk Space Problem

WITHOUT CLEANUP:

Log grows forever:
├─ Day 1: 100 GB
├─ Day 30: 3 TB
├─ Day 365: 36 TB
└─ Eventually: Disk full, broker crash

NEED CLEANUP MECHANISMS:

1. Delete old data (retention)
   └─ Keep last N days or last N GB

2. Delete old values per key (compaction)
   └─ Keep only latest value for each key

2. Cleanup Policies

Two Modes

cleanup.policy:

"delete":
├─ Time-based: Delete segments older than retention.ms
├─ Size-based: Delete oldest segments when size > retention.bytes
└─ Result: Data eventually deleted

"compact":
├─ Key-based: Delete old values for duplicate keys
├─ Keep latest value per key
└─ Result: Data never deleted (per key)

"compact,delete":
├─ Compaction first (remove old values)
├─ Then deletion (remove old segments)
└─ Hybrid: Keep latest values, but not forever

3. Delete-Based Retention

Time-Based Deletion

RETENTION BY TIME (retention.ms):

Configuration:
└─ retention.ms=604800000  # 7 days

Behavior:
├─ For each closed segment:
│   └─ Check: max timestamp in segment
├─ If (now - max timestamp) > retention.ms:
│   └─ Delete segment files (.log, .index, .timeindex)
└─ Active segment never deleted

Example:
├─ Segment 1: max timestamp = Jan 1, 2024
├─ Segment 2: max timestamp = Jan 5, 2024
├─ Segment 3: max timestamp = Jan 8, 2024 (active)
├─ Current time: Jan 10, 2024
├─ Retention: 7 days
└─ Action: Delete Segment 1 (> 7 days old)

Size-Based Deletion

RETENTION BY SIZE (retention.bytes):

Configuration:
└─ retention.bytes=10737418240  # 10 GB per partition

Behavior:
├─ Calculate total partition size
├─ If total size > retention.bytes:
│   └─ Delete oldest closed segments
│   └─ Until size <= retention.bytes
└─ Active segment never deleted

Example:
├─ Partition total size: 12 GB
├─ Retention limit: 10 GB
├─ Segments:
│   ├─ Segment 1: 1 GB (oldest)
│   ├─ Segment 2: 1 GB
│   ├─ Segment 3: 1 GB
│   └─ ... (10 segments)
└─ Action: Delete Segments 1 and 2 (reduce to 10 GB)

Combined Retention

IF BOTH retention.ms AND retention.bytes CONFIGURED:

Deletion occurs when EITHER condition met:
├─ Segment is older than retention.ms
└─ OR total size exceeds retention.bytes

Whichever happens first triggers deletion

Example:
├─ retention.ms: 7 days
├─ retention.bytes: 10 GB
├─ Segment 1: 5 days old, but total size = 11 GB
└─ Action: Delete Segment 1 (size limit hit)

Deletion Process

LOG CLEANER THREAD (for delete policy):

1. Scan all partitions
2. For each partition:
   a. Check closed segments
   b. Identify segments eligible for deletion
   c. Delete files atomically:
      ├─ rm *.log
      ├─ rm *.index
      └─ rm *.timeindex
   d. Update partition start offset
3. Sleep (log.retention.check.interval.ms)
4. Repeat

Atomic deletion:
└─ All segment files deleted together
└─ No partial deletion (corrupted state)

4. Log Compaction Overview

Key-Based Retention

COMPACTION CONCEPT:

For each unique key:
└─ Keep only the latest value
└─ Delete all older values

Before compaction:
[k1:v1(0), k2:v2(1), k1:v3(2), k2:v4(3), k3:v5(4)]

After compaction:
[k1:v3(2), k2:v4(3), k3:v5(4)]

Offsets preserved:
└─ Offsets 0 and 1 are "deleted" (gaps appear)
└─ But offsets 2, 3, 4 remain

Use Cases

WHY COMPACTION?

Changelog topics:
├─ Database table replicated to Kafka
├─ Each record = current row state
├─ Only latest state matters
└─ Example: user profiles, product catalog

State stores:
├─ Kafka Streams state stores backed by Kafka
├─ Each key = state for an entity
└─ Old states can be discarded

Configuration stores:
├─ Application configs keyed by config name
└─ Only latest config value matters

5. Compaction Algorithm Internals

Log Structure for Compaction

LOG DIVIDED INTO TWO SECTIONS:

┌───────────────────────┬─────────────────────────┐
│   CLEAN               │   DIRTY                 │
│   (already compacted) │   (needs compaction)    │
└───────────────────────┴─────────────────────────┘
                        ↑
                Clean Point (cleaner-offset-checkpoint)

Clean section:
├─ Previously compacted
├─ Only latest value per key
└─ Immutable (won't be re-compacted)

Dirty section:
├─ Recently written records
├─ May have duplicate keys
└─ Target for next compaction

Compaction Process

CLEANER THREAD ALGORITHM:

1. Select partition for compaction
   └─ Based on "dirty ratio" (dirty size / total size)

2. Build offset map:
   ├─ Scan dirty section
   ├─ For each key: record latest offset
   └─ Map: key → latest offset

3. Create new compacted segment:
   ├─ Scan all segments (clean + dirty)
   ├─ For each record:
   │   ├─ If key in map AND offset == map[key]:
   │   │   └─ Keep record (latest value)
   │   └─ Else: Skip record (older value)
   └─ Write to new segment

4. Replace old segments:
   ├─ Atomically swap in new segments
   └─ Delete old segments

5. Update clean point
   └─ Mark compacted area as "clean"

Offset Map (In-Memory)

OFFSET MAP STRUCTURE:

HashMap: MD5(key) → offset

Why hash instead of full key?
└─ Saves memory (16 bytes vs full key size)
└─ Collision risk very low

Memory usage:
├─ 24 bytes per unique key (hash + offset + metadata)
├─ log.cleaner.dedupe.buffer.size: 128 MB default
└─ Can compact ~5 million unique keys with default

If map fills up:
└─ Compact partial dirty section
└─ Multiple compaction passes

Compaction Example

BEFORE COMPACTION:

Segment 1 (clean):
[k1:v1(100), k2:v2(101)]

Segment 2 (dirty):
[k1:v3(200), k3:v4(201), k2:v5(202)]

Segment 3 (dirty):
[k1:v6(300), k3:v7(301)]

OFFSET MAP (from dirty sections):
k1 → 300 (latest)
k2 → 202 (latest)
k3 → 301 (latest)

AFTER COMPACTION:

New Segment (clean):
[k1:v6(300), k2:v5(202), k3:v7(301)]

Result:
└─ Offsets 100, 101, 200, 201 deleted (gaps)
└─ Offsets 300, 202, 301 retained

6. Tombstones

Deletion Markers

TOMBSTONE = Record with null value

Purpose:
└─ Signal deletion of a key in compacted log

Example:
Produce: key="user123", value=null

Effect:
└─ After compaction, "user123" removed entirely

Timeline:
1. Record exists: [user123:profile_data]
2. Tombstone written: [user123:null]
3. Compaction runs: Both records deleted
4. Result: user123 completely removed

Tombstone Retention

TOMBSTONE DELAY (delete.retention.ms):

Configuration:
└─ delete.retention.ms=86400000  # 1 day

Behavior:
├─ Tombstone must exist for at least delete.retention.ms
├─ Ensures consumers see the deletion
└─ After delay, tombstone itself deleted

Why delay?
├─ Consumer may be offline during tombstone write
├─ Consumer restarts and reads from old offset
└─ Must see tombstone to know key was deleted

Without delay:
└─ Consumer could miss deletion (never see tombstone)

Compaction with Tombstones

PROCESS:

1. Producer writes tombstone: [k1:null]
2. Tombstone remains for delete.retention.ms
3. Compaction runs:
   ├─ Removes all old values for k1
   ├─ Keeps tombstone (if within retention)
4. After delete.retention.ms:
   └─ Next compaction removes tombstone too
5. Result: k1 completely gone

Timeline:
├─ T=0: [k1:v1(0)]
├─ T=1: [k1:v1(0), k1:null(1)]
├─ T=2 (compaction): [k1:null(1)]
├─ T=3 (after retention): []
└─ k1 fully deleted

7. Compaction Guarantees

What Compaction Guarantees

GUARANTEES:

1. At least one value per key retained:
   └─ If key exists, latest value is kept

2. Offsets are not reused:
   └─ Deleted offsets leave gaps (never filled)

3. Ordering per key preserved:
   └─ Later writes always have higher offsets

4. Consumer sees all writes:
   └─ Compaction doesn't affect reads from HW
   └─ Only cleaned logs affected

5. Transactional atomicity respected:
   └─ All records in transaction kept or deleted together

What Compaction Does NOT Guarantee

NO GUARANTEES:

1. Timing:
   └─ Compaction is asynchronous (not immediate)
   └─ May take hours for dirty section to compact

2. Exact deduplication:
   └─ Multiple values may exist during dirty phase
   └─ Only cleaned log has single value per key

3. Deleted keys immediately gone:
   └─ Tombstones retained for delete.retention.ms
   └─ Key fully deleted only after compaction + retention

4. Ordering across keys:
   └─ No guarantee on order of different keys

8. Cleaner Thread Internals

Cleaner Configuration

CLEANER THREADS:

log.cleaner.threads=1  # Number of cleaner threads

Each thread:
├─ Selects partitions for compaction
├─ Runs compaction algorithm
└─ Manages offset map

More threads:
├─ More partitions compacted in parallel
└─ Higher CPU usage

Compaction Trigger

WHEN TO COMPACT?

Dirty ratio:
└─ dirty size / (clean size + dirty size)

Configuration:
└─ min.cleanable.dirty.ratio=0.5  # 50%

If dirty ratio >= threshold:
└─ Partition eligible for compaction

Example:
├─ Clean size: 600 MB
├─ Dirty size: 400 MB
├─ Dirty ratio: 400/(600+400) = 0.4 (40%)
└─ Below 50% threshold, not yet compacted

├─ Dirty size grows to: 600 MB
├─ Dirty ratio: 600/(600+600) = 0.5 (50%)
└─ Now eligible for compaction

Compaction Priority

CLEANER SELECTS PARTITIONS:

Priority based on:
├─ Dirty ratio (higher = higher priority)
├─ Time since last compaction
└─ log.cleaner.min.compaction.lag.ms (delay before compaction)

Backoff:
└─ Recently compacted partitions lower priority
└─ Ensures all partitions get compacted eventually

9. Compaction and Replication

Interaction

COMPACTION IS LOCAL TO EACH REPLICA:

Leader:
├─ Compaction runs independently
└─ Creates compacted segments

Followers:
├─ Replicate raw data from leader (not compacted data)
├─ Run own compaction independently
└─ May compact at different times

Result:
├─ Each replica has its own compaction schedule
└─ All replicas eventually converge to same data
└─ Compaction state NOT replicated

10. Edge Cases and Gotchas

Active Segment Never Compacted

ACTIVE SEGMENT EXEMPTION:

Active segment:
└─ Never compacted (currently being written)
└─ Always in "dirty" section

Implication:
├─ Latest writes may have duplicates
├─ Only visible after segment rolls
└─ Then becomes eligible for compaction

Workaround:
└─ Force segment roll with segment.ms

Compaction Lag

COMPACTION IS ASYNC:

Write to compacted topic:
├─ T=0: Write [k1:v1]
├─ T=1: Write [k1:v2]
├─ T=2: Reads see both k1:v1 and k1:v2
└─ T=100: Compaction runs, only k1:v2 remains

Consumer reading at T=2:
└─ Sees duplicates (k1:v1 and k1:v2)
└─ Application must handle (use latest)

Why async?
└─ Compaction is expensive (CPU, I/O)
└─ Can't block writes

Transactional Records

TRANSACTIONS AND COMPACTION:

Records in transaction:
├─ Kept or deleted as a unit
└─ Partial transaction never compacted

If transaction includes:
├─ [k1:v1(txn1), k2:v2(txn1)]

Both records kept until compaction determines:
└─ Keep (if latest) or delete (if superseded)
└─ But always together

11. Cleanup Coordination

Controller-Side Logic

CONTROLLER DOES NOT TRIGGER CLEANUP:

Cleanup is broker-local:
├─ Each broker runs own cleaner threads
├─ Independently decides when to clean
└─ No coordination with controller

Controller only:
└─ Provides metadata (partition assignments)
└─ Cleanup execution is autonomous

Cleanup During Rebalance

IF PARTITION LEADERSHIP CHANGES:

Old leader:
├─ Stops compaction (if running)
└─ Closes partition cleanly

New leader:
├─ Starts accepting writes immediately
├─ Compaction resumes independently
└─ No continuity of compaction state

Result:
└─ Compaction may restart from scratch on new leader
└─ But semantics remain correct

12. Key Takeaways

You've learned:

✅ Two cleanup policies: delete (time/size-based) and compact (key-based)
✅ Delete retention removes old segments based on time or size
✅ Compaction keeps only the latest value per key
✅ Offset map (in-memory) tracks latest offset per key during compaction
✅ Tombstones (null values) signal key deletion in compacted logs
✅ delete.retention.ms keeps tombstones visible to consumers
✅ Compaction is async — duplicates may exist until compaction runs
✅ Offsets are preserved — compaction creates gaps, doesn't renumber
✅ Cleaner threads run compaction based on dirty ratio
✅ Compaction is per-replica — each broker compacts independently


13. Cleanup Mechanics Checklist

DELETE-BASED RETENTION:

☑ retention.ms: Time-based deletion
☑ retention.bytes: Size-based deletion
☑ Closed segments deleted atomically
☑ Active segment never deleted

LOG COMPACTION:

☑ Keep latest value per key
☑ Offset map built from dirty section
☑ Compaction triggered by dirty ratio
☑ Offsets preserved (gaps appear)

TOMBSTONES:

☑ null value signals deletion
☑ Retained for delete.retention.ms
☑ Ensures consumers see deletion
☑ Eventually deleted by compaction

CLEANER THREADS:

☑ log.cleaner.threads (default: 1)
☑ min.cleanable.dirty.ratio (trigger)
☑ Runs asynchronously
☑ Per-broker, not coordinated

Summary

Kafka provides flexible log cleanup mechanisms:

Delete Retention =
├─ Time-based (retention.ms)
├─ Size-based (retention.bytes)
├─ Removes old segments entirely
└─ Simple, predictable

Log Compaction =
├─ Key-based (latest value per key)
├─ Tombstones for deletion
├─ Offset map tracks latest offsets
├─ Async (duplicates until compaction)
└─ Preserves offsets (creates gaps)

Cleaner Threads =
├─ Run independently per broker
├─ Triggered by dirty ratio
├─ Use in-memory offset map
└─ Atomic segment replacement

Remember: Retention controls disk usage, compaction controls data semantics. Delete retention is straightforward (remove old data). Compaction is complex but powerful (changelog semantics, state stores). Choose based on your use case.

Next up: Module 3.1 — Replication Protocol Explained — One authoritative explanation of Kafka's replication mechanics.