# Anatomy of a Kafka Lock Convoy

> How eight single-member consumers fetching 4,742 partitions each stalled a ten-broker Kafka 3.7 cluster one broker at a time, and why the metrics never noticed.

Published 2026-09-11 · Canonical: https://alex.bularca.me/posts/anatomy-of-a-kafka-lock-convoy/

How eight single-member consumers, each fetching 4,742 partitions in one request, brought a
ten-broker Kafka cluster to a standstill one broker at a time, why it only started after an
upgrade from the 2.x line, and why the brokers' own latency metrics never noticed.

> **TL;DR.** A very wide consumer fetch parks in `DelayedOperationPurgatory` as a single
> `DelayedFetch` registered under every partition it covers. Every produce, follower fetch and
> consumer fetch that completes on any of those partitions calls `checkAndComplete`, which takes
> that one operation's `ReentrantLock` and runs a completion check proportional to the fetch
> width. Since Kafka 2.7 that lock acquisition blocks instead of trying and moving on. With 864
> watched partitions on one broker, 30 of 36 request-handler threads queued behind one lock, the
> request queue hit its cap, and the network threads of the busiest listener stopped reading
> sockets. Broker-measured latency stayed low because time in an unread socket is not measured.
> A producer that awaited one message at a time under a per-batch timeout turned every slow
> minute into cancelled and re-sent batches, feeding the convoy. Stopping the eight consumers
> gave 2.8× throughput at a fifth of the latency within three minutes.

## The setup

The system is an event pipeline for high-volume financial-style transactions. An application
writes a transaction to its database and publishes an event to a message queue. A publishing
service consumes that queue in small batches and produces each event into a Kafka topic with 105
partitions on a ten-broker cluster, Kafka 3.7.0 in KRaft mode, deployed with an operator on
Kubernetes with two brokers per physical node. Downstream, several hundred consumer groups read
the resulting topics: analytics, rule engines, exports, and a replication job that copies
production topics into another environment.

Three details of that setup turned out to matter more than everything else combined:

- The replication job was eight consumer groups, **one member each**, subscribed by wildcard to
  285 topics and therefore **4,742 partitions**. Each member's fetch to a given broker spanned
  every partition that broker led.
- The publisher sent **one message per produce request**, awaited each delivery report before
  sending the next, and cancelled the entire batch on one shared timer.
- The cluster had moved from a Kafka 2.x release to 3.7.0 three months earlier.

<figure>
<svg viewBox="0 0 1040 250" role="img" aria-label="Pipeline: application writes to a message queue; a publisher batches and produces into a ten-broker Kafka cluster over TLS; analytics, rule-engine and replication consumers read the topics. The replication consumers are highlighted as eight members each fetching 4,742 partitions." xmlns="http://www.w3.org/2000/svg">
<defs><marker id="a0" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="currentColor"/></marker></defs>
<g font-family="IBM Plex Sans, Arial, sans-serif" font-size="14" fill="currentColor">
<rect x="20" y="80" width="150" height="70" rx="3" fill="none" stroke="currentColor"/>
<text x="95" y="110" text-anchor="middle" font-weight="600">Application</text>
<text x="95" y="130" text-anchor="middle" font-size="12" opacity=".7">transaction committed</text>
<line x1="170" y1="115" x2="215" y2="115" stroke="currentColor" stroke-width="1.5" marker-end="url(#a0)"/>
<rect x="218" y="80" width="130" height="70" rx="3" fill="none" stroke="currentColor"/>
<text x="283" y="110" text-anchor="middle" font-weight="600">Message queue</text>
<text x="283" y="130" text-anchor="middle" font-size="12" opacity=".7">ack per message</text>
<line x1="348" y1="115" x2="393" y2="115" stroke="currentColor" stroke-width="1.5" marker-end="url(#a0)"/>
<rect x="396" y="80" width="130" height="70" rx="3" fill="none" stroke="currentColor"/>
<text x="461" y="110" text-anchor="middle" font-weight="600">Publisher</text>
<text x="461" y="130" text-anchor="middle" font-size="12" opacity=".7">1 msg / request</text>
<line x1="526" y1="115" x2="571" y2="115" stroke="currentColor" stroke-width="1.5" marker-end="url(#a0)"/>
<text x="548" y="102" text-anchor="middle" font-size="11" opacity=".7">SASL_SSL</text>
<rect x="574" y="40" width="200" height="150" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="674" y="66" text-anchor="middle" font-weight="600">Kafka 3.7 · KRaft</text>
<text x="674" y="84" text-anchor="middle" font-size="12" opacity=".7">10 brokers · 2 per node</text>
<g fill="none" stroke="currentColor" opacity=".8">
<rect x="594" y="100" width="30" height="22" rx="2"/><rect x="630" y="100" width="30" height="22" rx="2"/><rect x="666" y="100" width="30" height="22" rx="2"/><rect x="702" y="100" width="30" height="22" rx="2"/><rect x="738" y="100" width="24" height="22" rx="2"/>
<rect x="594" y="130" width="30" height="22" rx="2"/><rect x="630" y="130" width="30" height="22" rx="2"/><rect x="666" y="130" width="30" height="22" rx="2"/><rect x="702" y="130" width="30" height="22" rx="2"/><rect x="738" y="130" width="24" height="22" rx="2"/>
</g>
<text x="674" y="176" text-anchor="middle" font-size="12" opacity=".7">≈ 9k partitions · 1.5k topics</text>
<line x1="774" y1="80" x2="819" y2="60" stroke="currentColor" stroke-width="1.5" marker-end="url(#a0)"/>
<line x1="774" y1="115" x2="819" y2="115" stroke="currentColor" stroke-width="1.5" marker-end="url(#a0)"/>
<line x1="774" y1="150" x2="819" y2="182" stroke="#C2410C" stroke-width="2.2" marker-end="url(#a0)"/>
<rect x="822" y="36" width="198" height="46" rx="3" fill="none" stroke="currentColor"/>
<text x="921" y="56" text-anchor="middle" font-weight="600">Analytics consumers</text>
<text x="921" y="73" text-anchor="middle" font-size="12" opacity=".7">hundreds of groups</text>
<rect x="822" y="93" width="198" height="46" rx="3" fill="none" stroke="currentColor"/>
<text x="921" y="113" text-anchor="middle" font-weight="600">Rule engines, exports</text>
<text x="921" y="130" text-anchor="middle" font-size="12" opacity=".7">latency-sensitive</text>
<rect x="822" y="156" width="198" height="74" rx="3" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="921" y="177" text-anchor="middle" font-weight="600" fill="#C2410C">Replication job</text>
<text x="921" y="196" text-anchor="middle" font-size="12" fill="#C2410C">8 groups, 1 member each</text>
<text x="921" y="214" text-anchor="middle" font-size="12" fill="#C2410C">4,742 partitions per member</text>
</g>
</svg>
<figcaption><b>The pipeline.</b> Everything in this post happens inside the brokers and in the publisher immediately in front of them. The replication job is an ordinary consumer; its <em>shape</em> is what made it dangerous.</figcaption>
</figure>

## The symptom, and why the metrics lied

From the publisher's point of view, produce calls started taking 30 to 240 seconds and failing.
From the broker's point of view, nothing was wrong: `Produce` total time p99 never exceeded
about 7.5 seconds during the worst episodes, and the request-handler idle ratio looked fine. For
five days the incident was debugged as a client-side regression.

The gap between those two views is the first lesson of this incident, and it is structural.
Kafka's request metrics start the clock when a network thread has read a complete request off
the socket and enqueued it. If the network thread is not reading, the request is not in any
metric. Meanwhile the handler idle metric on a broker with combined broker-and-controller roles
is unreliable because of a JMX bean collision between the two request pools, so the pods that
were actually stalling reported idle ratios above 1.0.

The metric that _did_ show it, in every episode, was
`kafka.network:type=RequestMetrics,name=LocalTimeMs,request=FetchFollower`: replica-fetch local
time rising from 1 to 2 ms to 10 to 90 ms on one broker at a time, while the follower-fetch rate
collapsed, handler CPU went down and the `Produce` purgatory size climbed. Followers touch every
partition, so they hit the mechanism first. The second was per-processor
`NetworkProcessorAvgIdlePercent`: the eight processors of the external TLS listener at zero idle
while the internal listener's processors were fine.

> **Why "idle at zero" meant blocked, not busy.** A busy network thread has a high `io-ratio`
> plus `io-wait-ratio`. A blocked one has both near zero: it is parked on a queue put, doing no
> I/O and not waiting on select either. On the stalled brokers the external listener's
> `io-ratio + io-wait-ratio` collapsed to about 0.1.

## The mechanism

### 1. A convoy, not a deadlock

In a deadlock nothing moves. Here everything moved, through one door. One handler held a lock
while it did a long piece of work. Every handler that needed the same lock parked behind it.
When the holder released, the next one took the lock and did the same long work. The pool never
stopped; it just spent almost all of its time queuing. Three `SIGQUIT` thread dumps of the
stalled broker, taken about ten seconds apart, showed 33, 32 and 30 of the 36 data-plane
handlers parked on the same lock object each time.

<figure>
<svg viewBox="0 0 1040 300" role="img" aria-label="Left: a healthy broker where 36 handler threads take many different locks and most are working. Right: the convoy, where 30 of 36 handlers queue behind one lock and one works at a time." xmlns="http://www.w3.org/2000/svg">
<g font-family="IBM Plex Sans, Arial, sans-serif" font-size="14" fill="currentColor">
<text x="20" y="28" font-weight="600">Healthy: many small locks</text>
<text x="540" y="28" font-weight="600">Convoy: one lock, one door</text>
<g fill="none" stroke="currentColor" opacity=".8">
<rect x="24" y="200" width="32" height="26" rx="2"/><rect x="62" y="200" width="32" height="26" rx="2"/><rect x="100" y="200" width="32" height="26" rx="2"/><rect x="138" y="200" width="32" height="26" rx="2"/><rect x="176" y="200" width="32" height="26" rx="2"/><rect x="214" y="200" width="32" height="26" rx="2"/><rect x="252" y="200" width="32" height="26" rx="2"/><rect x="290" y="200" width="32" height="26" rx="2"/><rect x="328" y="200" width="32" height="26" rx="2"/><rect x="366" y="200" width="32" height="26" rx="2"/><rect x="404" y="200" width="32" height="26" rx="2"/><rect x="442" y="200" width="32" height="26" rx="2"/>
</g>
<text x="249" y="252" text-anchor="middle" font-size="12" opacity=".7">one lock per waiting operation, few threads per lock</text>
<g fill="#2F855A">
<circle cx="40" cy="70" r="7"/><circle cx="78" cy="70" r="7"/><circle cx="116" cy="70" r="7"/><circle cx="154" cy="70" r="7"/><circle cx="192" cy="70" r="7"/><circle cx="230" cy="70" r="7"/><circle cx="268" cy="70" r="7"/><circle cx="306" cy="70" r="7"/><circle cx="344" cy="70" r="7"/><circle cx="382" cy="70" r="7"/><circle cx="420" cy="70" r="7"/><circle cx="458" cy="70" r="7"/>
<circle cx="40" cy="104" r="7"/><circle cx="78" cy="104" r="7"/><circle cx="116" cy="104" r="7"/><circle cx="154" cy="104" r="7"/><circle cx="192" cy="104" r="7"/><circle cx="230" cy="104" r="7"/><circle cx="268" cy="104" r="7"/><circle cx="306" cy="104" r="7"/><circle cx="344" cy="104" r="7"/><circle cx="382" cy="104" r="7"/><circle cx="420" cy="104" r="7"/><circle cx="458" cy="104" r="7"/>
<circle cx="40" cy="138" r="7"/><circle cx="78" cy="138" r="7"/><circle cx="116" cy="138" r="7"/><circle cx="154" cy="138" r="7"/><circle cx="192" cy="138" r="7"/><circle cx="230" cy="138" r="7"/><circle cx="268" cy="138" r="7"/><circle cx="306" cy="138" r="7"/><circle cx="344" cy="138" r="7"/>
</g>
<g fill="#B7791F"><circle cx="382" cy="138" r="7"/><circle cx="420" cy="138" r="7"/><circle cx="458" cy="138" r="7"/></g>
<line x1="249" y1="160" x2="249" y2="196" stroke="currentColor" stroke-width="1.2" stroke-dasharray="3 3"/>
<text x="249" y="50" text-anchor="middle" font-size="12" opacity=".7">36 handlers · 33 working, 3 waiting</text>
<text x="770" y="50" text-anchor="middle" font-size="12" opacity=".7">36 handlers · 1 working, 30 parked, 5 free</text>
<g fill="#C2410C">
<circle cx="560" cy="70" r="7"/><circle cx="598" cy="70" r="7"/><circle cx="636" cy="70" r="7"/><circle cx="674" cy="70" r="7"/><circle cx="712" cy="70" r="7"/><circle cx="750" cy="70" r="7"/><circle cx="788" cy="70" r="7"/><circle cx="826" cy="70" r="7"/><circle cx="864" cy="70" r="7"/><circle cx="902" cy="70" r="7"/>
<circle cx="560" cy="104" r="7"/><circle cx="598" cy="104" r="7"/><circle cx="636" cy="104" r="7"/><circle cx="674" cy="104" r="7"/><circle cx="712" cy="104" r="7"/><circle cx="750" cy="104" r="7"/><circle cx="788" cy="104" r="7"/><circle cx="826" cy="104" r="7"/><circle cx="864" cy="104" r="7"/><circle cx="902" cy="104" r="7"/>
<circle cx="560" cy="138" r="7"/><circle cx="598" cy="138" r="7"/><circle cx="636" cy="138" r="7"/><circle cx="674" cy="138" r="7"/><circle cx="712" cy="138" r="7"/><circle cx="750" cy="138" r="7"/><circle cx="788" cy="138" r="7"/><circle cx="826" cy="138" r="7"/><circle cx="864" cy="138" r="7"/><circle cx="902" cy="138" r="7"/>
</g>
<g fill="none" stroke="currentColor" opacity=".6"><circle cx="940" cy="70" r="7"/><circle cx="978" cy="70" r="7"/><circle cx="940" cy="104" r="7"/><circle cx="978" cy="104" r="7"/><circle cx="940" cy="138" r="7"/></g>
<circle cx="978" cy="138" r="7" fill="#2F855A"/>
<path d="M560 150 L750 196 M902 150 L790 196 M731 150 L770 196" stroke="#C2410C" stroke-width="1.2" fill="none"/>
<rect x="738" y="198" width="64" height="30" rx="2" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="770" y="218" text-anchor="middle" font-size="12" font-weight="600" fill="#C2410C">lock(M)</text>
<text x="770" y="252" text-anchor="middle" font-size="12" opacity=".7">one DelayedFetch, watched under 864 partition keys</text>
<text x="770" y="272" text-anchor="middle" font-size="12" opacity=".7">every partition event on the broker ends here</text>
</g>
</svg>
<figcaption><b>What the thread dumps showed.</b> Handler CPU went <em>down</em> while the broker was "busy", because parked threads burn nothing. The controller-plane handler pool on the same JVM was idle throughout; only the data plane was involved.</figcaption>
</figure>

### 2. How a fetch parks in purgatory

A consumer fetch that cannot be answered immediately, because `fetch.min.bytes` is not yet
satisfied, becomes a `DelayedFetch` in the broker's `DelayedOperationPurgatory`. The purgatory
must wake it the moment data arrives on _any_ partition in the request, so it registers the same
operation object in a `Watchers` list keyed by `TopicPartition`, once per partition in the fetch.
A fetch covering 864 partitions on this broker is one object under 864 keys.

Whenever a handler finishes work on a partition, appending a produce, serving a follower fetch,
or reading for a consumer, `ReplicaManager` calls `purgatory.checkAndComplete(key)` for that
partition. That walks the partition's watch list and, for each operation, calls
`safeTryComplete()`, which takes the operation's lock and runs `tryComplete()`. For a
`DelayedFetch`, `tryComplete` iterates every partition in the fetch to compute accumulated
bytes, and if the threshold is met it calls `onComplete`, which reads the log for every partition
and builds the response, still under the same lock.

<figure>
<svg viewBox="0 0 1040 340" role="img" aria-label="Purgatory structure: a Watchers map keyed by topic-partition, with p17, p402, p88 and p3 all pointing to the same DelayedFetch M. Produce, replica-fetch and consumer-fetch completions on those partitions each call checkAndComplete, which calls safeTryComplete on M, which takes M's ReentrantLock and runs tryComplete over all 864 partitions." xmlns="http://www.w3.org/2000/svg">
<defs><marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="currentColor"/></marker></defs>
<g font-family="IBM Plex Mono, Menlo, monospace" font-size="12" fill="currentColor">
<text x="20" y="26" font-family="IBM Plex Sans, Arial, sans-serif" font-size="14" font-weight="600">DelayedOperationPurgatory[DelayedFetch] on one broker</text>
<!-- events -->
<g>
<rect x="20" y="58" width="196" height="32" rx="2" fill="none" stroke="currentColor"/><text x="118" y="79" text-anchor="middle">appendRecords(p17)</text>
<rect x="20" y="102" width="196" height="32" rx="2" fill="none" stroke="currentColor"/><text x="118" y="123" text-anchor="middle">fetch (follower) p402</text>
<rect x="20" y="146" width="196" height="32" rx="2" fill="none" stroke="currentColor"/><text x="118" y="167" text-anchor="middle">appendRecords(p88)</text>
<rect x="20" y="190" width="196" height="32" rx="2" fill="none" stroke="currentColor"/><text x="118" y="211" text-anchor="middle">fetch (consumer) p3</text>
<text x="118" y="248" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">… any completion on 864 of the</text>
<text x="118" y="264" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">≈ 990 partitions this broker leads</text>
</g>
<g stroke="currentColor" stroke-width="1.2" marker-end="url(#a2)">
<line x1="216" y1="74" x2="268" y2="74"/><line x1="216" y1="118" x2="268" y2="118"/><line x1="216" y1="162" x2="268" y2="162"/><line x1="216" y1="206" x2="268" y2="206"/>
</g>
<text x="242" y="46" text-anchor="middle" opacity=".7" font-size="11">checkAndComplete(key)</text>
<!-- watchers -->
<g>
<text x="350" y="46" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">Watchers, keyed by TopicPartition</text>
<g fill="none" stroke="currentColor">
<rect x="272" y="58" width="156" height="32" rx="2"/><rect x="272" y="102" width="156" height="32" rx="2"/><rect x="272" y="146" width="156" height="32" rx="2"/><rect x="272" y="190" width="156" height="32" rx="2"/>
</g>
<text x="350" y="79" text-anchor="middle">p17  → [ M ]</text><text x="350" y="123" text-anchor="middle">p402 → [ M ]</text><text x="350" y="167" text-anchor="middle">p88  → [ M ]</text><text x="350" y="211" text-anchor="middle">p3   → [ M ]</text>
<text x="350" y="248" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">864 keys, one object</text>
</g>
<g stroke="#C2410C" stroke-width="1.6" fill="none" marker-end="url(#a2)">
<path d="M428 74 C 500 74, 500 150, 566 150"/><path d="M428 118 C 500 118, 500 150, 566 150"/><path d="M428 162 C 500 162, 500 150, 566 150"/><path d="M428 206 C 500 206, 500 150, 566 150"/>
</g>
<text x="497" y="120" text-anchor="middle" font-size="11" fill="#C2410C">tryCompleteWatched</text>
<text x="497" y="136" text-anchor="middle" font-size="11" fill="#C2410C">→ M.safeTryComplete()</text>
<!-- M -->
<rect x="566" y="104" width="236" height="92" rx="3" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="684" y="128" text-anchor="middle" font-weight="600" fill="#C2410C" font-family="IBM Plex Sans, Arial, sans-serif" font-size="14">M: DelayedFetch</text>
<text x="684" y="148" text-anchor="middle" fill="#C2410C" font-size="11">lock = ReentrantLock</text>
<text x="684" y="166" text-anchor="middle" fill="#C2410C" font-size="11">fetchPartitionStatus: 864 entries</text>
<text x="684" y="184" text-anchor="middle" fill="#C2410C" font-size="11">consumer millions of msgs behind</text>
<line x1="802" y1="150" x2="826" y2="150" stroke="#C2410C" stroke-width="1.6" marker-end="url(#a2)"/>
<!-- work -->
<rect x="830" y="80" width="190" height="140" rx="3" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="925" y="104" text-anchor="middle" font-weight="600" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">inLock(lock) {</text>
<text x="925" y="126" text-anchor="middle" font-size="11">tryComplete():</text>
<text x="925" y="142" text-anchor="middle" font-size="11">for 864 partitions →</text>
<text x="925" y="158" text-anchor="middle" font-size="11">accumulated bytes ≥ min?</text>
<text x="925" y="180" text-anchor="middle" font-size="11">onComplete(): read logs,</text>
<text x="925" y="196" text-anchor="middle" font-size="11">build the 864-part response</text>
<text x="925" y="214" text-anchor="middle" font-weight="600" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">}</text>
<text x="520" y="312" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">Cost held under the lock ∝ fetch width · Threads colliding ∝ watched partitions · Arrival rate ∝ produce + fetch rate on those partitions</text>
</g>
</svg>
<figcaption><b>One object, one lock, hundreds of doors into it.</b> This is a correct and ordinary Kafka data structure. It assumes no single waiting operation is watched under hundreds of keys on one broker, and that its completion check is cheap. Both assumptions fail for a single-member consumer that fetches thousands of partitions while millions of messages behind.</figcaption>
</figure>

Here is the reconstructed stack the parked handlers shared, abridged from the dumps:

```text
"data-plane-kafka-request-handler-12" #… daemon prio=5 WAITING (parking)
  at jdk.internal.misc.Unsafe.park
  at java.util.concurrent.locks.LockSupport.park
  at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire
  at java.util.concurrent.locks.ReentrantLock.lock
  at kafka.utils.CoreUtils$.inLock
  at kafka.server.DelayedOperation.safeTryComplete                 <-- 30 of 36 threads here
  at kafka.server.DelayedOperationPurgatory$Watchers.tryCompleteWatched
  at kafka.server.DelayedOperationPurgatory.checkAndComplete
  at kafka.server.ReplicaManager.$anonfun$addCompletePurgatoryAction / tryCompleteActions
  at kafka.server.ReplicaManager.appendRecords          // or fetchMessages, for the follower/consumer variants
  at kafka.server.KafkaApis.handleProduceRequest
  at kafka.server.KafkaRequestHandler.run
```

Thirty of these per dump, three dumps, three distinct lock objects across the ten-second span as
one fetch completed and the next one from the same consumer parked. The single thread that was
not waiting was inside `DelayedFetch.tryComplete` or `onComplete`.

### 3. From parked handlers to a frozen listener

Handlers are the only threads that dequeue from `RequestChannel`. With thirty of them parked, the
queue reaches `queued.max.requests` (500 here) within minutes. Network processors block on the
queue's `put` and stop calling `select`. Kafka assigns processors per listener, so the listener
carrying the most requests fills the queue first and blocks first. On this cluster that was the
external `SASL_SSL` listener the publishers used; in-cluster clients on the plaintext listener
barely noticed.

<figure>
<svg viewBox="0 0 1040 290" role="img" aria-label="Broker request pipeline: three listeners each with eight network processors feed one RequestChannel queue capped at 500, which feeds 36 KafkaRequestHandler threads. With 30 handlers parked on one lock the queue is at 500 of 500 and the external listener's processors block on put." xmlns="http://www.w3.org/2000/svg">
<defs><marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="currentColor"/></marker></defs>
<g font-family="IBM Plex Sans, Arial, sans-serif" font-size="13" fill="currentColor">
<text x="20" y="28" font-size="14" font-weight="600">SocketServer → RequestChannel → KafkaRequestHandlerPool, during the stall</text>
<g font-size="12">
<rect x="20" y="60" width="210" height="60" rx="3" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="125" y="78" text-anchor="middle" font-weight="600" fill="#C2410C">Client listener (TLS)</text>
<text x="125" y="96" text-anchor="middle" fill="#C2410C" font-size="11">8 processors · idle 0.00</text>
<text x="125" y="111" text-anchor="middle" fill="#C2410C" font-size="11">blocked in requestQueue.put()</text>
<rect x="20" y="136" width="210" height="60" rx="3" fill="none" stroke="currentColor"/>
<text x="125" y="158" text-anchor="middle" font-weight="600">Internal listener (plaintext)</text>
<text x="125" y="178" text-anchor="middle" opacity=".7">8 processors · idle 0.6–0.9</text>
<rect x="20" y="212" width="210" height="60" rx="3" fill="none" stroke="currentColor"/>
<text x="125" y="234" text-anchor="middle" font-weight="600">Replica listener</text>
<text x="125" y="254" text-anchor="middle" opacity=".7">8 processors · slow</text>
</g>
<g stroke="currentColor" stroke-width="1.2" marker-end="url(#a3)">
<line x1="230" y1="90" x2="304" y2="150"/><line x1="230" y1="166" x2="304" y2="166"/><line x1="230" y1="242" x2="304" y2="182"/>
</g>
<rect x="308" y="120" width="250" height="92" rx="3" fill="none" stroke="currentColor"/>
<text x="433" y="142" text-anchor="middle" font-weight="600">RequestChannel.requestQueue</text>
<rect x="322" y="154" width="222" height="18" rx="2" fill="#C2410C"/>
<rect x="322" y="154" width="222" height="18" rx="2" fill="none" stroke="currentColor"/>
<text x="433" y="194" text-anchor="middle" font-size="12" fill="#C2410C">500 / 500 = queued.max.requests</text>
<line x1="558" y1="166" x2="612" y2="166" stroke="currentColor" stroke-width="1.2" marker-end="url(#a3)"/>
<rect x="616" y="60" width="404" height="212" rx="3" fill="none" stroke="currentColor"/>
<text x="818" y="84" text-anchor="middle" font-weight="600">36 data-plane handlers (num.io.threads)</text>
<g fill="#C2410C">
<circle cx="656" cy="118" r="7"/><circle cx="688" cy="118" r="7"/><circle cx="720" cy="118" r="7"/><circle cx="752" cy="118" r="7"/><circle cx="784" cy="118" r="7"/><circle cx="816" cy="118" r="7"/><circle cx="848" cy="118" r="7"/><circle cx="880" cy="118" r="7"/><circle cx="912" cy="118" r="7"/><circle cx="944" cy="118" r="7"/>
<circle cx="656" cy="150" r="7"/><circle cx="688" cy="150" r="7"/><circle cx="720" cy="150" r="7"/><circle cx="752" cy="150" r="7"/><circle cx="784" cy="150" r="7"/><circle cx="816" cy="150" r="7"/><circle cx="848" cy="150" r="7"/><circle cx="880" cy="150" r="7"/><circle cx="912" cy="150" r="7"/><circle cx="944" cy="150" r="7"/>
<circle cx="656" cy="182" r="7"/><circle cx="688" cy="182" r="7"/><circle cx="720" cy="182" r="7"/><circle cx="752" cy="182" r="7"/><circle cx="784" cy="182" r="7"/><circle cx="816" cy="182" r="7"/><circle cx="848" cy="182" r="7"/><circle cx="880" cy="182" r="7"/><circle cx="912" cy="182" r="7"/><circle cx="944" cy="182" r="7"/>
</g>
<g fill="none" stroke="currentColor" opacity=".6"><circle cx="976" cy="118" r="7"/><circle cx="976" cy="150" r="7"/><circle cx="976" cy="182" r="7"/><circle cx="656" cy="214" r="7"/><circle cx="688" cy="214" r="7"/></g>
<circle cx="720" cy="214" r="7" fill="#2F855A"/>
<text x="818" y="244" text-anchor="middle" font-size="12" opacity=".7">30 parked in safeTryComplete · 5 free</text>
<text x="818" y="262" text-anchor="middle" font-size="12" opacity=".7">1 inside DelayedFetch.tryComplete</text>
</g>
</svg>
<figcaption><b>Why the outside saw a frozen broker while the inside looked fine.</b> Time a request spends unread in a socket is invisible to every <code>RequestMetrics</code> timer. The publishers waited 30 to 240 seconds; the broker's own <code>Produce</code> p99 stayed under a few seconds.</figcaption>
</figure>

### 4. Why it started after the upgrade

The replication consumers had run in this shape for months on the 2.x cluster. What changed is
how `DelayedOperation` behaves when a handler finds the lock already held. Abridged from the two
sources:

```scala
// Kafka 2.5 / 2.6 — DelayedOperation.maybeTryComplete (abridged)
private[server] def maybeTryComplete(): Boolean = {
  var retry = false
  var done  = false
  do {
    if (lock.tryLock()) {                       // non-blocking
      try { tryCompletePending.set(false); done = tryComplete() }
      finally { lock.unlock() }
      retry = tryCompletePending.get()
    } else {
      // someone else holds it: leave them a note and move on
      retry = !tryCompletePending.getAndSet(true)
    }
  } while (!isCompleted && retry)
  done
}

// Kafka 2.7 → 3.7 — DelayedOperation.safeTryComplete (KAFKA-8334)
private[server] def safeTryComplete(): Boolean = inLock(lock)(tryComplete())   // blocks
```

[KAFKA-8334](https://issues.apache.org/jira/browse/KAFKA-8334) fixed a real bug: with the
try-and-flag scheme, a completion could be missed when the flag was cleared at the wrong moment.
The fix is correct. It also changes the cost model: a handler that used to skip a held lock now
waits for it, and the wait includes the holder's `tryComplete` over every partition in the
operation. For ordinary fetches of a few dozen partitions nobody notices. For one fetch of 864
partitions, watched under 864 keys, with a produce rate of thousands per second onto those
partitions, the pool collapses into a queue.

### 5. The selector: which broker stalls

The convoy forms on whichever broker leads the most of the partitions the wide consumers read.
Balanced across ten brokers, each leads about 470 of the 4,742, and the lock's arrival rate stays
under what one thread can serve. The September hardware migration, a node retirement, several
large reassignments and finally a storage-controller failure kept concentrating leadership on
one broker at a time. The stall moved five times in eight days, and the ranking of replicated
partitions led per broker predicted the ranking of stall severity exactly.

<figure>
<svg viewBox="0 0 1040 330" role="img" aria-label="Bar chart of replicated partitions led per broker at the height of the 10 September stall. Brokers A to H: 864 blocked, 771 756 739 674 603 elevated, 258 and 77 clean. Dashed line at the balanced level of about 470. Two brokers were offline." xmlns="http://www.w3.org/2000/svg">
<g font-family="IBM Plex Mono, Menlo, monospace" font-size="12" fill="currentColor">
<text x="20" y="26" font-family="IBM Plex Sans, Arial, sans-serif" font-size="14" font-weight="600">Partitions led per broker among the 4,742 the replication job reads · 10 Sep 13:30</text>
<line x1="80" y1="60" x2="80" y2="280" stroke="currentColor" opacity=".4"/>
<line x1="80" y1="280" x2="1000" y2="280" stroke="currentColor"/>
<g opacity=".7" text-anchor="end"><text x="72" y="284">0</text><text x="72" y="235">200</text><text x="72" y="186">400</text><text x="72" y="137">600</text><text x="72" y="88">800</text></g>
<g stroke="currentColor" opacity=".12"><line x1="80" y1="231" x2="1000" y2="231"/><line x1="80" y1="182" x2="1000" y2="182"/><line x1="80" y1="133" x2="1000" y2="133"/><line x1="80" y1="84" x2="1000" y2="84"/></g>
<line x1="80" y1="165" x2="1000" y2="165" stroke="currentColor" stroke-width="1.5" stroke-dasharray="6 4" opacity=".8"/>
<text x="998" y="160" text-anchor="end" opacity=".8">balanced ≈ 470</text>
<rect x="110" y="69" width="70" height="211" fill="#C2410C"/><text x="145" y="62" text-anchor="middle">864</text><text x="145" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">A</text>
<rect x="220" y="92" width="70" height="188" fill="#B7791F"/><text x="255" y="85" text-anchor="middle">771</text><text x="255" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">B</text>
<rect x="330" y="95" width="70" height="185" fill="#B7791F"/><text x="365" y="88" text-anchor="middle">756</text><text x="365" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">C</text>
<rect x="440" y="99" width="70" height="181" fill="#B7791F"/><text x="475" y="92" text-anchor="middle">739</text><text x="475" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">D</text>
<rect x="550" y="115" width="70" height="165" fill="#B7791F"/><text x="585" y="108" text-anchor="middle">674</text><text x="585" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">E</text>
<rect x="660" y="133" width="70" height="147" fill="#B7791F"/><text x="695" y="126" text-anchor="middle">603</text><text x="695" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">F</text>
<rect x="770" y="217" width="70" height="63" fill="#2F855A"/><text x="805" y="210" text-anchor="middle">258</text><text x="805" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">G</text>
<rect x="880" y="261" width="70" height="19" fill="#2F855A"/><text x="915" y="254" text-anchor="middle">77</text><text x="915" y="300" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="13">H</text>
<text x="540" y="324" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">two further brokers were offline after a storage-controller failure when this sample was taken</text>
</g>
</svg>
<figcaption><b>Leadership is the selector.</b> When two repaired brokers rejoined and a leadership-only reassignment equalised leaders to about 980 per broker, Broker A fell to about 470 replicated partitions and released within the next two-minute scrape. That is a margin, not a fix: any restart, node loss or rebalance re-arms it.</figcaption>
</figure>

<p class="legend"><span class="hot">Blocked: client-listener processors idle 0.00, 30 handlers parked</span> · <span class="warm">Elevated: FetchFollower local p75 17–47 ms</span> · <span class="ok">Clean: ≤ 2 ms</span></p>

### 6. The amplifier: the producer

A slow broker explains slow produces. The publisher's design explains why slow produces became
eight days of failed batches and a growing backlog. Its produce loop, in shape:

```csharp
// one timer for the whole batch, sequential single-message produces
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(batchBudget));   // 120 s, later 240 s
foreach (var msg in batch)
{
    await producer.ProduceAsync(topicPartition, msg)
                  .ContinueWith(r => RecordResult(r, msg.Id), cts.Token);   // token cancels the continuation
}
```

Four consequences follow directly:

1. **Budget per batch, latency per message.** A 60-message batch fails whenever
   `60 × per-produce latency > budget`, even if every single produce succeeds. At 2 s each that
   is exactly 120 s.
2. **The real error is unobservable.** The token cancels the _continuation_, not the produce. The
   delivery report, and with it `Local: Message timed out` or the broker's error code, is never
   read. The log says only that a timer fired. Five days of debugging went into the wrong layer
   because of this.
3. **Duplicates.** The in-flight message stays in librdkafka's queue and may still be delivered;
   the batch is re-queued and the same message produced again. On the worst day, the ratio of
   queue deliveries to unique messages was about 1.74.
4. **Load.** Every failed batch is re-produced in full, one request per message, onto the very
   partitions under the convoy. The publisher alone accounted for about half the cluster's
   produce requests at 1.02 messages per request. librdkafka cannot coalesce what it never holds:
   with one outstanding message per loop, `linger.ms` has nothing to batch.

<figure>
<svg viewBox="0 0 1040 240" role="img" aria-label="Feedback loop: a slow broker makes the batch timer fire; the batch is cancelled and re-queued; the re-queued messages return as more one-message produce requests, which add lock acquisitions on the watched partitions and slow the broker further." xmlns="http://www.w3.org/2000/svg">
<defs><marker id="a5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="currentColor"/></marker></defs>
<g font-family="IBM Plex Sans, Arial, sans-serif" font-size="13" fill="currentColor">
<rect x="20" y="60" width="200" height="60" rx="3" fill="none" stroke="#C2410C" stroke-width="2"/>
<text x="120" y="85" text-anchor="middle" font-weight="600" fill="#C2410C">Broker slows</text><text x="120" y="104" text-anchor="middle" fill="#C2410C">lock convoy on its partitions</text>
<line x1="220" y1="90" x2="266" y2="90" stroke="currentColor" stroke-width="1.5" marker-end="url(#a5)"/>
<rect x="270" y="60" width="200" height="60" rx="3" fill="none" stroke="currentColor"/>
<text x="370" y="85" text-anchor="middle" font-weight="600">Batch token fires</text><text x="370" y="104" text-anchor="middle" opacity=".7">N × latency &gt; budget</text>
<line x1="470" y1="90" x2="516" y2="90" stroke="currentColor" stroke-width="1.5" marker-end="url(#a5)"/>
<rect x="520" y="60" width="200" height="60" rx="3" fill="none" stroke="currentColor"/>
<text x="620" y="85" text-anchor="middle" font-weight="600">Continuation cancelled</text><text x="620" y="104" text-anchor="middle" opacity=".7">delivery report never read</text>
<line x1="720" y1="90" x2="766" y2="90" stroke="currentColor" stroke-width="1.5" marker-end="url(#a5)"/>
<rect x="770" y="60" width="250" height="60" rx="3" fill="none" stroke="currentColor"/>
<text x="895" y="85" text-anchor="middle" font-weight="600">Batch re-queued, re-sent in full</text><text x="895" y="104" text-anchor="middle" opacity=".7">in-flight message duplicated</text>
<path d="M895 120 L895 180 L120 180 L120 124" stroke="#C2410C" stroke-width="1.5" fill="none" marker-end="url(#a5)"/>
<text x="508" y="202" text-anchor="middle" fill="#C2410C">more 1-message produce requests → more lock(M) acquisitions on the same partitions → broker slows further</text>
<text x="508" y="226" text-anchor="middle" opacity=".7" font-size="12">Observed: 75 minutes of continued batch failures after the brokers had fully recovered, purely from this path, on a backlog drain.</text>
</g>
</svg>
<figcaption><b>Two systems, one loop.</b> The consumers made the brokers fragile; the producer converted fragility into failures, duplicates and extra load. Each side can break the loop independently.</figcaption>
</figure>

The fix on the producer side is the standard shape, and it is worth spelling out because the
first version of the rewrite kept two of the old bugs. Produce everything in the batch without
awaiting each message, let each delivery handler complete its own `TaskCompletionSource`, await
the batch once with a bounded wait, and decide per message from the delivery report:

```csharp
var pending = batch.Select(msg =>
{
    var tcs = new TaskCompletionSource<DeliveryReport>(TaskCreationOptions.RunContinuationsAsynchronously);
    try
    {
        producer.Produce(topicPartition, msg, report => tcs.TrySetResult(report));   // non-blocking
    }
    catch (ProduceException<K, V> e)                                                // Local: Queue full, serializer errors
    {
        tcs.TrySetResult(DeliveryReport.Failed(e.Error));                          // per-message outcome, never abort the batch
    }
    return (msg, tcs.Task);
}).ToList();

var all = Task.WhenAll(pending.Select(p => p.Task));
if (await Task.WhenAny(all, Task.Delay(batchBudget)) != all)
{
    // budget exceeded: mark still-pending items failed with a *specific* reason;
    // do not cancel their continuations
}

foreach (var (msg, task) in pending)
    Ack(msg, task.IsCompletedSuccessfully && !task.Result.Error.IsError, task.Result?.Error);   // log Error.Code and PersistenceStatus
```

Two producer settings finish the job. `message.timeout.ms` should sit below the batch budget so
librdkafka fails stragglers _with a report_ before the application's timer does. And `linger.ms`
has to be raised deliberately: at roughly 430 messages per second per host spread over ten
brokers, the default 5 ms almost never coalesces, and the wire still carried 1.06 messages per
request after the rewrite. A 100 to 200 ms linger buys a four- to nine-fold reduction in request
count for a latency cost no analytics consumer will notice. Turn on `enable.idempotence` so that
the retries that remain cannot duplicate.

## The experiment

By the evening of the worst day, leadership had been re-balanced and no broker configuration had
changed for two hours. That made stopping the eight replication consumers a clean test: if the
convoy was the cause, the cluster should improve immediately, everywhere, and in real deliveries
rather than retries.

<figure>
<svg viewBox="0 0 1040 360" role="img" aria-label="Minute-by-minute chart 17:40 to 17:49. Bars: the publisher's produce requests per second, flat at 11,400 to 12,200 until 17:42, then 17,059 at 17:43 and 32,600 to 36,300 from 17:44. Line: cluster maximum produce p99 in milliseconds, 354 to 368 until 17:43, then 299, 120, 71, 60, 81, 55. A dashed marker between 17:42 and 17:43 shows when the eight consumers were stopped." xmlns="http://www.w3.org/2000/svg">
<g font-family="IBM Plex Mono, Menlo, monospace" font-size="12" fill="currentColor">
<text x="20" y="26" font-family="IBM Plex Sans, Arial, sans-serif" font-size="14" font-weight="600">Stopping the eight consumers, minute by minute (10 Sep, UTC)</text>
<!-- axes -->
<line x1="80" y1="60" x2="80" y2="280" stroke="currentColor" opacity=".4"/>
<line x1="960" y1="60" x2="960" y2="280" stroke="currentColor" opacity=".4"/>
<line x1="80" y1="280" x2="960" y2="280" stroke="currentColor"/>
<g opacity=".7" text-anchor="end"><text x="72" y="284">0</text><text x="72" y="229">10k</text><text x="72" y="174">20k</text><text x="72" y="119">30k</text><text x="72" y="64">40k</text></g>
<g opacity=".7" text-anchor="start"><text x="968" y="284">0</text><text x="968" y="229">100</text><text x="968" y="174">200</text><text x="968" y="119">300</text><text x="968" y="64">400 ms</text></g>
<text x="20" y="48" opacity=".7" font-size="11">produce req/s</text>
<text x="1020" y="48" opacity=".7" font-size="11" text-anchor="end">produce p99</text>
<g stroke="currentColor" opacity=".12"><line x1="80" y1="225" x2="960" y2="225"/><line x1="80" y1="170" x2="960" y2="170"/><line x1="80" y1="115" x2="960" y2="115"/><line x1="80" y1="60" x2="960" y2="60"/></g>
<!-- bars: x=80+i*88+14, w=60, h=v*0.0055 -->
<g fill="#C2410C" opacity=".85">
<rect x="94" y="214.7" width="60" height="65.3"/><rect x="182" y="217.3" width="60" height="62.7"/><rect x="270" y="213.1" width="60" height="66.9"/>
</g>
<g fill="#2F855A" opacity=".85">
<rect x="358" y="186.2" width="60" height="93.8"/><rect x="446" y="100.9" width="60" height="179.1"/><rect x="534" y="82.3" width="60" height="197.7"/><rect x="622" y="80.6" width="60" height="199.4"/><rect x="710" y="87.6" width="60" height="192.4"/><rect x="798" y="97.9" width="60" height="182.1"/><rect x="886" y="97.9" width="60" height="182.1"/>
</g>
<!-- p99 line: x=80+i*88+44, y=280-v*0.55 -->
<polyline fill="none" stroke="currentColor" stroke-width="2" points="124,85.3 212,78.7 300,77.6 388,90.8 476,115.5 564,214 652,241 740,247 828,235.5 916,249.8"/>
<g fill="currentColor"><circle cx="124" cy="85.3" r="3.5"/><circle cx="212" cy="78.7" r="3.5"/><circle cx="300" cy="77.6" r="3.5"/><circle cx="388" cy="90.8" r="3.5"/><circle cx="476" cy="115.5" r="3.5"/><circle cx="564" cy="214" r="3.5"/><circle cx="652" cy="241" r="3.5"/><circle cx="740" cy="247" r="3.5"/><circle cx="828" cy="235.5" r="3.5"/><circle cx="916" cy="249.8" r="3.5"/></g>
<!-- intervention marker between 17:42 and 17:43 -->
<line x1="344" y1="56" x2="344" y2="280" stroke="#C2410C" stroke-width="1.5" stroke-dasharray="5 4"/>
<text x="350" y="70" fill="#C2410C" font-size="11">consumers stopped 17:41:14–17:42:55</text>
<!-- x labels -->
<g opacity=".8" text-anchor="middle"><text x="124" y="300">17:40</text><text x="212" y="300">17:41</text><text x="300" y="300">17:42</text><text x="388" y="300">17:43</text><text x="476" y="300">17:44</text><text x="564" y="300">17:45</text><text x="652" y="300">17:46</text><text x="740" y="300">17:47</text><text x="828" y="300">17:48</text><text x="916" y="300">17:49</text></g>
<text x="520" y="340" text-anchor="middle" opacity=".7" font-family="IBM Plex Sans, Arial, sans-serif">Bars: publisher produce requests/s (left). Line: cluster max Produce p99 (right). Errors 0 every minute · 1.02 msgs/request.</text>
</g>
</svg>
<figcaption><b>2.8× throughput at a fifth of the latency, within three minutes, with nothing else changed.</b> Messages appended on the brokers tripled, so this is delivery, not retries. On the broker that had carried the early signature, follower-fetch local p75 went from 12 to 15 ms to 1 ms and its request queue from 500 to single digits in the same minute; <code>Produce</code> purgatory size collapsed on every broker.</figcaption>
</figure>

The mirror side of the same evening is instructive too: raising the publisher's instance count
from 11 to 20 and then to 30, an hour earlier, produced no measurable throughput and slightly
higher broker latency. More senders into the same full queue. Ninety handlers behind one lock are
still one lock.

## Timeline, compressed

| When                 | What                                                                                                                                                                                                         |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 11 Jun               | Cluster migrated from a 2.x release to 3.7.0. First broker-side lag incident two days later; two more in August, each "solved" by moving load off a slow node.                                               |
| 2 Sep 17:43          | Hardware migration concentrates leadership on two freshly moved brokers. One replication consumer closes and reopens (new client id), issuing a fresh full fetch across all 4,742 partitions.                |
| 2 Sep 17:46          | Publisher begins failing every batch at its 120 s budget. Onset.                                                                                                                                             |
| 3 – 7 Sep            | 1.6 – 1.85 M failed batch executions per day. Broker logs: not one WARN or ERROR during any stall. Consumers 2 – 40 h behind. A broker leaves for node retirement on 4 Sep; the cluster runs on nine.        |
| 5 Sep 08:00          | The publisher's upstream queue is cleared during the incident. About 36 hours of events never reach Kafka and are later re-sent from the source database. No dead-letter path existed.                       |
| 7 Sep 15:50          | Leadership of the hot topic moved off the stalled broker; failures drop 15× that second. The convoy moves to the next most-loaded broker for two hours, then subsides. Evening episodes on 8 and 9 Sep.      |
| 10 Sep 07:16 – 12:30 | Three multi-terabyte rebalances approved and stopped in turn; each landing of replicas on the most-loaded node triggers a short episode.                                                                     |
| 10 Sep 12:45         | Storage-controller failure on one node: two brokers crash, 14 partitions offline, 140 under min-ISR, `NOT_ENOUGH_REPLICAS` at 440/s. Eight brokers left.                                                     |
| 10 Sep 13:18         | Broker A, now leading 35 % more partitions, blocks completely with no CPU, disk or reassignment pressure.                                                                                                    |
| 10 Sep 14:10         | Three thread dumps. The lock holder is named; partitions led per broker match the stall ranking.                                                                                                             |
| 10 Sep 15:31 – 15:49 | Repaired node returns; preferred-leader election, then a leadership-only reassignment equalises leaders. Broker A releases within two minutes.                                                               |
| 10 Sep 16:48         | Last failed batch, after a 75-minute tail of failures at exactly the 240 s budget with no Kafka error: the amplifier alone.                                                                                  |
| 10 Sep 17:41         | Eight replication consumers stopped. Three minutes later: 2.8× throughput, ⅕ latency, zero errors. First clean night.                                                                                        |
| 11 Sep 08:06         | Rewritten producer deployed: non-blocking sends, per-message delivery reports, real error logged. Batches four times larger, twice as fast, zero errors. Wire still 1.06 msg/request until linger is raised. |

## What I would tell another Kafka operator

### Consumers

- **Budget partitions per consumer _member_, not per topic.** Any group that would read more than
  a few hundred partitions per member must be sharded before it is deployed. Treat it like
  replication factor: a reviewed number.
- Shard replication jobs across many members with the cooperative-sticky assignor and static
  membership (`group.instance.id`), so a member restart does not trigger a full rebalance and a
  fresh full-width fetch. With 16 members the widest fetch on any broker here would be about 55
  keys.
- Split wildcard subscriptions by topic family. A wildcard that silently grows with every new
  topic is how a fetch width becomes unknowable.
- A consumer that is millions of messages behind should be completing fetches immediately. If it
  parks in purgatory at all, its `fetch.min.bytes` / `fetch.max.wait.ms` are wrong for its width.
  Cap `fetch.max.bytes` and `max.partition.fetch.bytes` so the response built under the lock is
  small.
- Watch for consumer recreation. Two of the eight groups here recreated their consumer every one
  to two minutes; every recreation is a new fetch session and a full, non-incremental fetch, with
  the broker's session cache already full at `max.incremental.fetch.session.cache.slots=1000`.
- Prefer a purpose-built replicator (MirrorMaker 2 with `tasks.max` per topic family) over a
  hand-rolled single-consumer copy job. Replication tools partition the work by design and expose
  lag and throughput per task.

### Brokers

- **Request quotas for wide principals.** A `request_percentage` quota on the replication
  principal would have throttled one badly shaped client instead of starving everyone behind the
  shared handler pool.
- Keep leadership balanced automatically and alert on it: `PreferredReplicaImbalanceCount > 100`,
  or any broker leading more than 1.3× the average. Never leave a broker at 1,600 leaders for
  hours because the rebalancing tool's self-healing is broken.
- Rack awareness (`broker.rack` = physical node) when running two brokers per host. One
  controller failure here turned into 14 offline partitions and 140 under min-ISR because both
  replicas of a partition shared a host.
- Do not treat `num.io.threads` or `queued.max.requests` as the fix for a convoy. More threads
  behind one lock are more parked threads. Check release notes after 3.7 for purgatory changes
  before treating an upgrade as a fix, and know that a _correct_ upgrade can change the cost of an
  existing pattern.
- Isolate broker CPU from co-tenant workloads (requests, ideally Guaranteed QoS) and keep
  reassignments out of peak hours with two approvers. Several evening episodes here were timed by
  a co-located workload's CPU bursts and by rebalances approved during the incident.

### Producers

- Never await one message at a time under a batch-wide timeout. Produce with delivery handlers,
  await once, decide per message.
- Always read the delivery report. The error you are not logging is the one you will spend five
  days looking for elsewhere.
- Set `message.timeout.ms` below any application budget so librdkafka fails stragglers with a
  reportable reason before your own timer does.
- Raise `linger.ms` deliberately and enable idempotence. At low per-connection rates the default
  linger coalesces nothing, and every retry without idempotence is a potential duplicate.
- Retries are load. Back-pressure and pausing replay jobs when produce latency rises beat
  re-sending whole batches into a struggling broker.

## Detection: PromQL that would have caught it in minutes

```text
# Earliest and most specific: follower-fetch local time on any broker (normal 1–2 ms)
histogram_quantile(0.75, sum by (pod, le) (rate(kafka_network_requestmetrics_localtimems_bucket{request="FetchFollower"}[3m]))) > 10

# A blocked listener: its processors' idle collapses to ~0 while handler CPU also falls
min by (pod, listener) (kafka_network_processor_idle_percent) < 0.2

# Request queue at cap (unreliable on combined broker-controller pods: bean collision reports 0)
kafka_network_requestchannel_requestqueuesize >= 0.9 * 500

# Produce purgatory climbing on one pod — the companion signal in every episode
kafka_server_delayedoperationpurgatory_purgatorysize{delayedOperation="Produce"} > 400

# The selector: leadership concentration
kafka_controller_kafkacontroller_preferredreplicaimbalancecount > 100
max(kafka_server_replicamanager_leadercount) / avg(kafka_server_replicamanager_leadercount) > 1.3

# The lock holder's shape: partitions per consumer member (kafka_exporter)
count by (consumergroup) (kafka_consumergroup_lag) / on (consumergroup) sum by (consumergroup) (kafka_consumergroup_members) > 300
```

When the first of these fires, take three thread dumps ten seconds apart on the affected broker.
If the data-plane handlers are parked in `DelayedOperation.safeTryComplete` under
`Watchers.tryCompleteWatched`, it is this mechanism, and the immediate mitigation is to move
leadership off that broker or pause the widest consumer.

## Closing

Nothing in this incident was exotic. A replication job with an unusual but legal shape, a Kafka
fix that closed a real bug, a hardware migration that moved leaders around, and a producer
written the way most first Kafka producers are written. Individually, each had been fine for
months. Together, with the lock semantics of Kafka 2.7 and later, they formed a convoy that no
single dashboard showed. The two things I would want any team running Kafka to take from it are:
_partitions per consumer member is a capacity number_, and _read your delivery reports_.

---

Mechanism confirmed from three thread dumps of the stalled broker, per-episode Prometheus
metrics, broker logs, and the `DelayedOperation.scala` source for Kafka 2.5.0 and 3.7.0. Numbers
are as measured at the time. Kafka internals described here are those of 3.7.0; check the release
you run.