Skip to content

feat: implement KIP-392 (consumer rack-aware fetching) - #1434

Open
GlebShipilov wants to merge 8 commits into
segmentio:mainfrom
exness:main
Open

feat: implement KIP-392 (consumer rack-aware fetching)#1434
GlebShipilov wants to merge 8 commits into
segmentio:mainfrom
exness:main

Conversation

@GlebShipilov

@GlebShipilov GlebShipilov commented Apr 26, 2026

Copy link
Copy Markdown

What

Adds support for KIP-392 (follower fetching), which lets a consumer
fetch records from the closest in-rack replica instead of always going
to the partition leader. This cuts cross-AZ traffic and tail latency in
multi-rack Kafka clusters.

Changes

KIP-392 core (wire format + routing)

  • fetch.go: added RackID field on FetchRequest,
    PreferredReadReplica field on FetchResponse.
  • reader.go: added Rack option on ReaderConfig. When set, the
    reader passes it through to every fetch.
  • dialer.go: added RackID on Dialer so the low-level Conn API
    can be rack-aware too.
  • conn.go: handles the v11 fetch response header, decodes the
    PreferredReadReplica field, and exposes it on Batch.
  • batch.go: new PreferredReadReplica() accessor on Batch.
  • read.go, write.go, protocol.go: small plumbing changes to carry
    the new field through the wire format.
  • protocol/fetch/fetch.go: tagged RackID on the request and
    PreferredReadReplica on the response per partition for v11+.

Drain in-flight batch before reconnecting

When the PreferredReadReplica in a fetch response differs from the
cached value, upstream would immediately close the batch and connection,
discarding unread records. This PR sets a prrChanged flag, delivers
every record from the batch first, then signals errPreferredReadReplicaChanged
to the run loop. The reconnect happens between batches, not mid-batch.

ISR-only replica selection

dialPreferredReplica() resolves the preferred replica against p.Isr
(not p.Replicas). A broker that is in the replica set but out of ISR —
because it is offline or lagging — is skipped. This matches the Java
client's behavior and prevents fetching from a stale follower.

Clear preferred replica on NotLeaderForPartition

When a NotLeaderForPartition error arrives while connected to a
preferred follower, the reader now clears the preference and adds the
replica to the cooldown cache before falling back to the leader.
Without this, initialize() would re-dial the same failed follower
in a tight loop.

Clear preferred replica on follower OffsetOutOfRange

An OffsetOutOfRange from a follower is not authoritative because
follower offsets can lag behind the leader's. The reader now drops
the preference and lets initialize() reconnect through the leader,
whose first/last offsets are the only ones trusted for the reset
decision.

Negative cache for failed preferred replicas

A per-reader preferredReadReplicaCooldown map records an "ignore
until" timestamp for each recently-failed replica. Subsequent fetch
responses that re-advertise the same broker id are silently ignored
until the 5-minute cooldown expires. Without this the broker
re-suggests the same dead replica on every fetch and the reader
live-locks in a dial→fail→fallback→re-suggest loop.

SeekDontCheck on follower connections

In initialize(), when the reader is about to seek on a KIP-392
follower, it uses SeekAbsolute|SeekDontCheck instead of plain
SeekAbsolute. This skips the ListOffsets validation call, which
followers reject with NotLeaderForPartition (only leaders serve
ListOffsets). The offset has already been validated by the leader
on the prior fetch.

Resolve FirstOffset/LastOffset via leader before follower dial

When the reader needs to resolve FirstOffset or LastOffset (e.g.
after a group rebalance), it now dials the leader first to call
ReadOffsets, materializes the concrete offset, then re-dials the
preferred follower for the actual fetch path. This avoids sending
ListOffsets to the follower.

Follower's PreferredReadReplica = -1 is a heartbeat

Per KIP-392, -1 from a follower the reader is already pinned to means
"stay with me" (the follower does not advertise itself as its own
preference). -1 from the leader means "no preferred replica, stay on
me." The reader now tracks connectedToFollower to disambiguate these
two cases: when connected to the follower, -1 refreshes the TTL
instead of triggering a reconnect. Without this the reader oscillates
between leader and follower every few hundred milliseconds.

How it was tested

Ran the full test matrix locally against Kafka 2.7.0, 2.8.1, and 3.7.0
(the same versions CircleCI uses):

  • go test -race -cover ./...
  • go test -tags=unsafe -race -cover ./...
  • cd sasl/aws_msk_iam && go test -race -cover ./...

All tests pass except TestReaderConsumerGroup, which is a pre-existing
nil-pointer panic on main unrelated to this change.

Chaos testing on a live cluster

Ran a structured chaos protocol against a 10-broker, 3-rack Strimzi
cluster (KRaft mode, RF=3, MIRR=2) with a go-kafka-consumer highlevel
consumer reading 10 partitions. The protocol covers every failure mode
the resilience fixes are designed for:

step action exercises
1 — negative control kill broker with 0 partitions in fetch path baseline sanity
2 — follower-only kill kill broker we follower-fetch 1 partition from batch draining, ISR check, cooldown
3 — leader kill kill partition leader (we fetch from follower) NotLeaderForPartition clearing, KIP-392 proof point
4 — biggest blast kill broker with 5 affected partitions (leader + follower roles) all of the above under stress
5 — cooldown re-test re-kill same broker as step 2 within 5 min negative cache suppression
6 — KRaft controller kill 1 of 3 controllers quorum resilience (no consumer impact)
7 — full-rack outage kill all 5 in-rack brokers simultaneously cross-rack fallback + re-pin after recovery

Results: zero Not Leader For Partition errors, zero
fetch_message_failed, zero routing oscillations, all partitions
resumed committing within 60 s of each kill. Full report in
KAFKA_OUTAGE_TEST_REPORT.md.

Notes on the PreferredReadReplica = -1 edge case

This is the part that's easy to get wrong. Per KIP-392, a value of -1
means different things depending on which broker sent it:

  • -1 from the leader means "no preferred replica for you, stay on the
    leader" — the client must drop any cached follower.
  • -1 from a follower that the client is already cached on means
    "stay with me" — the client must keep the cache.

If both cases are treated the same, the consumer oscillates between
leader and follower on every fetch and the optimization is silently
broken. The connectedToFollower flag disambiguates the two cases,
matching the Java client behavior.

Backwards compatibility

The new fields default to zero values, so consumers that don't set
RackID see no behavior change. Brokers older than Fetch v11 simply
ignore the field. All resilience mechanisms are inert when
preferredReadReplica is -1 (the default).

GlebShipilov and others added 8 commits April 26, 2026 12:35
Add RackID support to FetchRequest/FetchResponse and propagate
PreferredReadReplica from the broker's Fetch v11+ response.
This allows consumers to set a rack ID so the broker can direct
them to fetch from the closest replica, reducing cross-datacenter
traffic.
Changes:
- fetch.go: RackID field on FetchRequest, PreferredReadReplica on FetchResponse
- reader.go: RackID config option on ReaderConfig, propagated through to fetch
- conn.go: rack-aware fetch support in low-level Conn API
- batch.go: expose PreferredReadReplica on Batch
- dialer.go: RackID on DialerConfig
- read.go: helper for rack-aware reading
- write.go: pass-through support
- protocol.go: PreferredReadReplica constant
feat: implement KIP-392 consumer rack awareness
The three-part form (1.23.0) is only understood by Go 1.21+ toolchains.
The upstream segmentio/kafka-go CircleCI uses circleci/golang without a
version tag, which pulls an older Go that rejects 1.23.0 with:
  invalid go version '1.23.0': must match format 1.23
preserve in-flight batch, leader fallback on follower errors, correct -1 default, ISR-only preferred-replica selection
…adOffsets failure

When the Reader is connected to a preferred follower (KIP-392) and the
follower returns NotLeaderForPartition (on fetch or during readOffsets in
initialize), the stale preference was never cleared. The next initialize
call dialed the same follower again, causing an infinite retry loop.
Fix:
- NotLeaderForPartition handler in readLoop: clear preferredReadReplica
  before breaking, same pattern as the existing OffsetOutOfRange fix.
- initialize/readOffsets: when readOffsets fails on a preferred replica
  connection, clear the preference and retry via the leader on the same
  broker iteration instead of breaking out entirely.
…lock

When the leader re-advertises the same PreferredReadReplica on every
Fetch, a follower that rejects ListOffsets (NotLeaderForPartition) or
fails to dial caused an infinite re-init loop:
  leader -> follower -> fail -> leader -> same hint -> follower -> ...

This change adds a per-reader negative cache
(preferredReadReplicaCooldown, 5m TTL matching the PRR TTL) keyed by
broker id. Whenever a preferred replica fails (dial error,
NotLeaderForPartition, OffsetOutOfRange) we record an "ignore until"
timestamp. Subsequent Fetch responses that re-suggest the same broker
id are ignored until the cooldown expires.

Also: KIP-392 only allows Fetch on followers; ListOffsets stays on
the leader. initialize() now resolves FirstOffset/LastOffset markers
against the leader first and only then redials the preferred follower
for the actual fetch. For a concrete resume offset we skip readOffsets
entirely on the follower path.
The previous live-lock turned out to have a deeper root cause than the
negative-cache alone could solve. After dialing a KIP-392 preferred
follower and (correctly) skipping the explicit readOffsets call,
initialize() still invoked conn.Seek(offset, SeekAbsolute). That call
internally issues ReadOffsets (ListOffsets v?) against the connected
broker to validate the requested offset — and a follower rejects it
with NotLeaderForPartition. The error then surfaced as
"error initializing the kafka reader for partition X: [6] Not Leader
For Partition" on every retry, defeating the cooldown logic because
the failure happened before any Fetch was ever issued.

Fix: pass SeekAbsolute|SeekDontCheck whenever the connection we are
about to seek on is a preferred follower. The leader has already
validated the offset for us on the prior fetch that handed us this
concrete resume offset, so the local seek is safe without a network
round-trip to ListOffsets.

A small `onFollower` flag is set in the two places where initialize()
ends up holding a follower connection (the concrete-offset fast path
and the redial-after-leader-resolution path) and consumed at the
single conn.Seek call site.
After dialing a KIP-392 preferred follower, the very first FetchResponse
from that follower carries preferred_read_replica = -1, simply because
followers do not advertise themselves as their own preference. The
existing reader logic compared this against r.preferredReadReplica
(= the follower's broker id) and concluded the broker had revoked the
preference. It then closed the follower connection, re-dialed the
leader, got re-redirected to the same follower on the next fetch, and
oscillated for several seconds until the broker's selector eventually
stopped re-asserting the suggestion.

Symptom in the high-level consumer log:
    routing_change preferred_replica_change  -1 -> 103   (leader-driven)
    routing_change ttl_or_no_preference      103 -> -1   (~800ms later)
    ... repeated for 5-10 partitions in the first ~10 seconds ...

Fix: split the prr-changed branch in reader.read() into three cases:
  1. We are currently pinned to a follower AND the broker reported
     prr=-1 AND the TTL is still valid -> treat as heartbeat, refresh
     the 5-minute TTL, do NOT trip prrChanged. This is the common
     steady-state path for follower fetches.
  2. The broker is suggesting a replica that is in the local cooldown
     (existing behavior) -> ignore.
  3. Default -> existing behavior (switch / fall back / log).

A new connectedToFollower bool on the reader struct disambiguates
case 1 from "we are connected to the leader and it just told us to
fall back to itself". It is set in initialize() at the same two
points that already set the local onFollower flag for SeekDontCheck,
and reset on every initialize() call.

Net behavior:
- One preferred_replica_change per partition at startup (unchanged).
- Zero ttl_or_no_preference flips during steady state.
- Real TTL expiry every ~5 min still triggers a re-discovery cycle.
- All other prr transitions (cooldown, dial-fail, OffsetOutOfRange,
  NotLeaderForPartition) take the existing paths unchanged.
@GlebShipilov

Copy link
Copy Markdown
Author

@petedannemann, can you have a look, please?
I believe rack awareness functionality should be useful for kafka-go library

@aartal

aartal commented May 18, 2026

Copy link
Copy Markdown

Hi, we're running segmentio/kafka-go against Amazon MSK with rack awareness enabled and hit this exact gap. When broker-side rack awareness is switched on, consumers immediately throw Inconsistent Group Protocol errors. The existing RackAffinityGroupBalancer handles client-side assignment but doesn't cover the KIP-392 wire-level RackID in Fetch requests — which is exactly what this PR addresses.

Would love to see this reviewed and merged. Happy to help test against MSK if useful. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants