KafkaIQ

3.3 Follower Fetching & Data Consistency

Module Overview

Goal: Deep dive into follower replica behavior and consistency mechanics.

What You'll Learn:

  • Follower fetch loop internals
  • Lag detection and recovery
  • Divergence handling and log truncation
  • Consistency protocols (leader epochs)
  • Fetch configurations and tuning
  • Edge cases in replication

What's NOT Covered Here:

  • Basic replication model (covered in Module 3.1)
  • ISR rules and HW calculation (covered in Module 3.2)

Key Insight: Followers are not passive — they actively manage log consistency, detect divergence, and recover from failures using sophisticated protocols.


1. Follower Fetch Loop

Core Algorithm

FOLLOWER MAIN LOOP (simplified):

while (broker is alive) {
    for each partition where this broker is follower {
        1. Determine fetch offset (current LEO)
        2. Build FetchRequest
        3. Send to leader broker
        4. Wait for FetchResponse
        5. Validate response
        6. Append records to local log
        7. Update local LEO
        8. Update local HW (from response)
        9. Handle errors if any
    }
    Sleep briefly (if no data fetched)
}

Continuous process, runs throughout broker lifetime

Fetch Request Construction

FETCH REQUEST DETAILS:

FetchRequest:
├─ replica id: This follower's broker ID
├─ max wait ms: replica.fetch.wait.max.ms (500ms default)
├─ min bytes: replica.fetch.min.bytes (1 byte default)
└─ fetch data:
    └─ For each partition:
        ├─ topic: "orders"
        ├─ partition: 0
        ├─ fetch offset: follower's current LEO
        ├─ max bytes: replica.fetch.max.bytes
        └─ log start offset: for leader validation

Sent to: Leader broker for this partition

2. Fetch Response Processing

Response Contents

FETCH RESPONSE:

FetchResponse:
└─ partition data:
    ├─ partition: orders-0
    ├─ high watermark: 1050 (leader's HW)
    ├─ last stable offset: 1050 (for transactions)
    ├─ log start offset: 0
    ├─ error code: NONE (or error)
    ├─ records: RecordBatch[] (data to replicate)
    └─ aborted transactions: [] (if any)

Follower Processing Steps

UPON RECEIVING FETCH RESPONSE:

1. Check error code
   ├─ If NONE: Proceed
   ├─ If NOT_LEADER_FOR_PARTITION:
   │   └─ Refresh metadata, retry with new leader
   ├─ If OFFSET_OUT_OF_RANGE:
   │   └─ Fetch OffsetsForLeaderEpoch, truncate if needed
   └─ Other errors: Log and retry

2. Validate records
   ├─ Check offset sequence
   ├─ Verify CRCs
   └─ Ensure no gaps

3. Append to local log
   ├─ Write to active segment
   ├─ Update offset and time indexes
   └─ Fsync (based on flush policy)

4. Update follower LEO
   └─ LEO = last appended offset + 1

5. Update follower HW
   └─ HW = min(leader's HW, follower's LEO)

6. Acknowledge internally
   └─ Ready for next fetch

3. Lag Detection

Measuring Lag

LAG METRICS:

1. Record lag (messages):
   └─ Leader LEO - Follower LEO

2. Time lag (seconds):
   └─ Leader timestamp - Follower timestamp of last fetch

Leader tracks both:
├─ Per-follower LEO (from fetch offset)
├─ Per-follower last fetch time
└─ Determines ISR membership

Follower-Side Lag Awareness

FOLLOWER KNOWS ITS LAG:

From FetchResponse:
├─ Leader HW: 1050
├─ Records returned: offsets 1000-1049
└─ Follower calculates: Still catching up (LEO < HW)

Follower behavior:
├─ If lagging: Fetch immediately (no wait)
├─ If caught up: Respect replica.fetch.wait.max.ms
└─ Aggressive fetching when behind

4. Divergence Detection

How Divergence Occurs

SCENARIO:

1. Leader writes: offsets 1000-1010
2. Follower fetches up to 1008
3. Leader fails before follower gets 1009-1010
4. New leader elected (doesn't have 1009-1010)
5. Old leader rejoins as follower
   └─ Has divergent records 1009-1010

Problem:
└─ Old leader's 1009-1010 ≠ new leader's log
└─ Must be truncated

Leader Epoch Protocol

LEADER EPOCH TRACKING:

Each leader change increments epoch:
├─ Leader 1, Epoch 0: Offsets 0-999
├─ Leader 1, Epoch 1: Offsets 1000-1010
└─ Leader 2, Epoch 2: Offsets 1011-... (new leader)

leader-epoch-checkpoint file:
├─ Epoch 0 → Offset 0
├─ Epoch 1 → Offset 1000
└─ Epoch 2 → Offset 1011

Follower uses epochs to detect divergence

OffsetsForLeaderEpoch Request

WHEN FOLLOWER SUSPECTS DIVERGENCE:

1. Follower sends OffsetsForLeaderEpochRequest
   └─ "What offset started epoch X?"

2. Leader responds with epoch boundaries

3. Follower compares with local epochs
   ├─ If match: Continue replicating
   └─ If mismatch: Truncate divergent data

Example:
├─ Follower has: Epoch 1 → Offset 1000
├─ Leader responds: Epoch 1 doesn't exist (now Epoch 2)
└─ Follower truncates everything from offset 1000 onward

5. Log Truncation

Truncation Process

WHEN FOLLOWER MUST TRUNCATE:

1. Determine truncation offset
   └─ From OffsetsForLeaderEpoch response

2. Truncate local log
   ├─ Remove segments beyond truncation point
   ├─ Truncate active segment if needed
   └─ Rebuild indexes

3. Update follower LEO
   └─ LEO = truncation offset

4. Resume replication
   └─ Fetch from truncation offset

5. Catch up to leader
   └─ Eventually rejoin ISR

Truncation Example

TIMELINE:

T=0:
├─ Old leader: [Epoch 1: 1000-1010], LEO: 1011
├─ Follower A: [Epoch 1: 1000-1008], LEO: 1009
└─ Follower B: [Epoch 1: 1000-1009], LEO: 1010

T=1: Old leader fails, Follower A becomes new leader (Epoch 2)
├─ New leader (A): [Epoch 2: 1009-...], LEO: 1009
└─ Follower B still has: offsets 1009 from Epoch 1 (divergent!)

T=2: Old leader rejoins as follower
├─ Fetches from new leader
├─ Detects: Epoch 2 started at offset 1009
├─ Realizes: My offsets 1009-1010 (Epoch 1) are divergent
├─ Truncates to offset 1009
└─ Resumes replication from 1009 (Epoch 2)

Result:
└─ Both old leader and Follower B truncated to offset 1009
└─ All replicas consistent with new leader

6. Fetch Configurations

replica.fetch.max.bytes

CONFIGURATION:

replica.fetch.max.bytes = 1048576  # 1 MB default

Meaning:
└─ Max bytes follower fetches per request (per partition)

Impact:

Too low:
├─ More fetch requests (higher overhead)
├─ Slower replication (small batches)
└─ Higher network round-trip costs

Too high:
├─ Larger memory buffers
├─ Longer processing time per batch
└─ May delay replication of other partitions

Recommended:
└─ 1-10 MB (balance throughput and latency)

replica.fetch.wait.max.ms

CONFIGURATION:

replica.fetch.wait.max.ms = 500  # 500ms default

Meaning:
└─ Max time leader waits before responding if no data available

Behavior:

If leader has data:
└─ Respond immediately

If leader has no new data:
├─ Wait up to replica.fetch.wait.max.ms
├─ If data arrives: Respond immediately
└─ If timeout: Respond with empty result

Impact:

Low value (e.g., 100ms):
├─ Lower replication latency (frequent checks)
├─ More fetch requests (higher CPU)
└─ Better for low-latency requirements

High value (e.g., 2000ms):
├─ Higher replication latency
├─ Fewer fetch requests (lower CPU)
└─ Risk: Follower appears lagging

replica.fetch.min.bytes

CONFIGURATION:

replica.fetch.min.bytes = 1  # 1 byte default

Meaning:
└─ Min bytes of data before leader responds

Behavior:

If available data >= min bytes:
└─ Respond immediately

If available data < min bytes:
├─ Wait up to replica.fetch.wait.max.ms
└─ Then respond with whatever is available

Impact:

Low value (1 byte):
├─ Leader responds with any data (even small)
├─ Lower latency
└─ More fetch requests

High value (e.g., 10 KB):
├─ Leader waits for more data (batching)
├─ Higher latency
└─ Fewer, larger fetch requests

7. Fetch Throttling

Replication Quota

THROTTLING CONFIGURATION:

follower.replication.throttled.rate = 10485760  # 10 MB/s

Applied to:
└─ Follower fetch bandwidth

Mechanism:
├─ Follower tracks bytes fetched per second
├─ If rate exceeds quota: Delay next FetchRequest
└─ Effectively limits replication speed

Use cases:
├─ Prevent replication from saturating network
├─ Prioritize client traffic over replication
└─ Controlled partition reassignment

8. Consistency Guarantees

Read-After-Write Consistency

SCENARIO:

1. Producer writes record to leader
   └─ acks=all, write committed (HW advanced)

2. Consumer reads from follower (if enabled)
   └─ Follower HW may lag behind leader HW

Potential issue:
├─ Leader HW: 1010 (record visible)
├─ Follower HW: 1008 (record NOT visible)
└─ Consumer reading from follower sees stale data

Kafka's default:
└─ Consumers read from leader only (avoids issue)

KIP-392 (optional):
└─ Enable follower fetching for consumers
└─ Must tolerate slightly stale reads

Monotonic Reads

GUARANTEE:

Kafka ensures:
└─ Consumer never sees offset X, then fails to see X later

How:
├─ HW never decreases (except unclean leader election)
├─ Once record visible (HW advanced), stays visible
└─ Monotonic read consistency

9. Edge Cases

Follower Restarts

WHEN FOLLOWER RESTARTS:

1. Load partition logs from disk
2. Determine current LEO (last appended offset)
3. Send FetchRequest from LEO
4. Leader responds with records from LEO onward
5. Follower appends and catches up

Issue:
├─ Follower may have divergent data (if leader changed during downtime)
└─ Use OffsetsForLeaderEpoch to detect and truncate

Leader Restarts

WHEN LEADER RESTARTS:

1. Leader failover occurs (new leader elected)
2. Old leader restarts as follower
3. Fetches from new leader
4. Detects epoch change
5. Truncates divergent data
6. Replicates from new leader
7. Eventually catches up, rejoins ISR

Network Partitions

NETWORK PARTITION SCENARIO:

1. Follower loses connectivity to leader
2. Leader marks follower out-of-sync (after replica.lag.time.max.ms)
3. ISR shrinks (follower removed)
4. Network heals
5. Follower resumes fetching
6. Follower catches up
7. ISR expands (follower re-added)

Key point:
└─ ISR membership dynamically adjusts to network health

10. Key Takeaways

You've learned:

✅ Follower fetch loop continuously pulls data from leader
✅ FetchRequest includes follower LEO — leader tracks follower progress
✅ FetchResponse includes leader HW — follower updates local HW
✅ Lag measured by time and records — determines ISR membership
✅ Divergence detected via leader epochs — OffsetsForLeaderEpoch protocol
✅ Truncation resolves divergence — followers discard conflicting data
✅ Fetch configs control latency vs throughput — wait.max.ms, max.bytes, min.bytes
✅ Fetch throttling limits replication bandwidth — protects network
✅ Monotonic reads guaranteed — HW never decreases (except unclean election)
✅ Followers recover automatically — after restarts or network issues


Summary

Follower replicas are active participants in data consistency:

Fetch Mechanics =
├─ Pull-based (follower initiates)
├─ Continuous loop (fetch, append, repeat)
├─ LEO and HW updated per fetch
└─ Aggressive fetching when lagging

Consistency Protocols =
├─ Leader epochs detect leader changes
├─ OffsetsForLeaderEpoch identifies divergence
├─ Truncation removes conflicting data
└─ Eventually consistent across ISR

Configuration Tuning =
├─ replica.fetch.max.bytes (batch size)
├─ replica.fetch.wait.max.ms (latency vs throughput)
├─ replica.fetch.min.bytes (batching threshold)
└─ follower.replication.throttled.rate (bandwidth limit)

Remember: Followers aren't just copying data — they actively maintain consistency using leader epochs, detect divergence, truncate when necessary, and recover from failures automatically.

Next up: Module 4.1 — Partition Leadership — Defining leader and follower responsibilities.