Skip to content

GH-4330: keep the blocking Kafka consume loop off the thread pool - #4354

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4330-kafka-listener
Sep 6, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4330-kafka-listener

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4330. From the 2026-09-05 hot-path performance audit (GH-4316 wave).

A blocking loop on a pool thread. IConsumer.Consume(CancellationToken) blocks synchronously, and BackgroundReceiveLoop starts its iteration with Task.Run — so every Kafka listener permanently occupies a thread-pool worker for its entire lifetime. With several topics, or ListenerCount > 1, that is several workers removed from the same pool the handler pipeline and every other transport's continuations run on: classic starvation exactly under the load Kafka is chosen for.

BackgroundReceiveLoop gains an opt-in UsesBlockingIteration that starts the loop with TaskCreationOptions.LongRunning (a dedicated thread), and Kafka sets it. Deliberately opt-in rather than the default: the SQS, Redis, Pulsar and database-queue loops are genuinely async and release their thread at every await, so a dedicated thread there would waste one.

KafkaOffsetCommitter allocations. Track runs per consumed record and Complete per handled record, and both allocated a TopicPartition (a class, with its own hash/equals) purely to key the watermark lookup. The (topic, partition) set a listener sees is small and fixed by its partition assignment, so they're interned in a ConcurrentDictionary now.

What I deliberately did not do

Two items from the issue are not in this PR, and I want to be explicit rather than quietly drop them:

  1. Sharding the committer's _lock per partition. That lock also guards the flush loop's iteration over every watermark, so per-partition locking is a real concurrency refactor, not a mechanical change.
  2. Widening the batched-drain gate beyond Durable. The zero-timeout drain is indeed free, but the benefit outside Durable is amortized receiver bookkeeping — not the durable-inbox-insert win Kafka performance deep dive: measuring and reducing Wolverine overhead vs native Confluent.Kafka #3490 actually measured. The upside is speculative and the settlement interaction with NativeAck's lease tracking is subtle.

Both are legitimate candidates. Neither should land on reasoning alone — and today's #4353 regression, where exactly that kind of reasoning removed a load-bearing guard, is why.

Testing

  • dotnet build wolverine.slnx -c Release -f net9.0 clean (the pinned CI gate)
  • Full CoreTests: 2841 passed / 2 skipped / 0 failed (covers BackgroundReceiveLoop's own lifecycle tests on the unchanged default path)
  • The supervised CIKafka lane is the real gate for the listener change and is queued for tonight's local run

🤖 Generated with Claude Code

IConsumer.Consume(CancellationToken) blocks synchronously, and the loop
calling it runs on Task.Run -- so every Kafka listener permanently
occupies a thread-pool worker for its whole lifetime. Multiply by topics
and ListenerCount and it eats the same pool the handler pipeline and
every other transport's continuations share, which is classic
starvation under load.

BackgroundReceiveLoop gains an opt-in UsesBlockingIteration that starts
the loop with TaskCreationOptions.LongRunning (a dedicated thread) and
Kafka sets it. Deliberately opt-in: the SQS, Redis and database-queue
loops are genuinely async and release their thread at every await, so a
dedicated thread there would waste one.

KafkaOffsetCommitter allocated a TopicPartition -- a class -- on every
Track (per consumed record) and every Complete (per handled record),
purely to key the watermark lookup. The (topic, partition) set a
listener sees is small and fixed by its assignment, so they are interned
now.

NOT done, deliberately: sharding the committer's single _lock per
partition, and widening the batched-drain gate beyond Durable. The lock
also guards the flush loop's iteration over all watermarks, and the
drain's benefit outside Durable is amortized receiver bookkeeping rather
than the inbox-insert win GH-3490 actually measured. Both are real
candidates; neither should land on reasoning alone.

Closes #4330

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit fa4e4fc into main Sep 6, 2026
40 of 41 checks passed
jeremydmiller added a commit that referenced this pull request Sep 13, 2026
…rever (#4429)

StopAsync bounded the receive-loop drain (GH-3434) and then did two unbounded things, so
the bound bought nothing. _committer.Flush() is a synchronous _consumer.Commit(), and
_consumer.Close() is a synchronous P/Invoke into rd_kafka_consumer_close, which waits on
an infinite queue pop for the cgroup to finish revoke/commit/leave. librdkafka documents
that wait as "roughly limited to session.timeout.ms", but against an unreachable broker
or coordinator it never returns (librdkafka#4519, confluent-kafka-dotnet#2013). A
reporter's host was still wedged 20+ minutes later, past both DrainTimeout and
HostOptions.ShutdownTimeout, with IHost.StopAsync never completing.

The try/catch around Close() could never help: it catches exceptions, not a blocked
native call. Neither can a token -- IListener.StopAsync has none to thread, and a managed
token cannot interrupt a native P/Invoke regardless. Bounding the wait is the only lever,
so take the trade the receivers and the GCP Pub/Sub listener (#4071) already take: stop
within the drain budget and leave the rest unsettled for redelivery.

Both blocking steps now run under runTeardownWithinBudgetAsync, on a DEDICATED background
thread rather than the pool. If the step wedges, that thread is never coming back, and
abandoning a pool thread would permanently consume a worker shared by the handler pipeline
and every other transport -- the same amplifier GH-4354 removed from the consume loop,
reintroduced at shutdown.

An abandoned teardown also suppresses the later consumer Dispose. Confluent's ReleaseHandle
passes RD_KAFKA_DESTROY_F_NO_CONSUMER_CLOSE so disposing does not block, but the abandoned
thread still owns that handle, and destroying it underneath a live native call would trade
a hang for a crash.

Deliberately NOT done here: threading a CancellationToken through
DrainAsync/StopAndDrainAsync/IListener.StopAsync, which the issue offers as an option. That
is a breaking signature change across every transport's listener, and it would not fix this
anyway, because the blocked call is native.

Three tests, no broker -- a substituted IConsumer is the only way to reproduce "never
returns" deterministically. Each bounds its own wait, so a regression fails the test rather
than hanging the suite: the close is still attempted and StopAsync returns; an abandoned
close does not get its consumer disposed; and a healthy consumer is still closed AND
disposed, so the ordinary path is unchanged.


Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Kafka: blocking Consume pins a thread-pool worker per listener; offset committer contends one global lock; batch drain gated on Durable only

1 participant