Repository navigation
Keep a blocking receive loop on its dedicated thread past the first await (GH-4330 follow-up) - #5096
Merged
jeremydmiller merged 1 commit intoOct 20, 2026
Conversation
…wait JasperFxGH-4330 started a BackgroundReceiveLoop marked UsesBlockingIteration with TaskCreationOptions.LongRunning, so that the Kafka consumer's blocking Consume(CancellationToken) would not hold a thread-pool worker. The loop it started is async and awaits each iteration with ConfigureAwait(false), so the dedicated thread only ran it up to the first await that did not complete synchronously: for a Kafka listener, the receiver's work on the first record, which on a durable endpoint is the inbox insert. The thread then exited, the loop resumed on the pool thread that completed that await, and every later Consume(token) blocked a pool worker again, one per listener for as long as it waited for a record. A loop marked UsesBlockingIteration now runs a synchronous driver on the LongRunning thread that waits on each pass instead of awaiting it, so the loop is back on that thread before it calls the iteration again. The pass itself (heartbeat, iteration, EntityMissing handling, idle wait, failure backoff) is moved unchanged into one method that both drivers share, and loops without the flag still start on Task.Run and await it exactly as before. KafkaTopicGroupListener runs the same blocking Consume(token) loop and never set UsesBlockingIteration, so it now does.
There was a problem hiding this comment.
🟢 Approval recommended
The focused refactor preserves existing iteration handling, adds regression coverage, and has no identified blocking issues.
No open issues 🎉
What changed in this PR
Fixes the GH-4330 follow-up by keeping blocking Kafka receive iterations on a dedicated thread after asynchronous waits.
Changes:
- Shares iteration handling between async and dedicated-thread drivers.
- Enables dedicated-thread execution for Kafka topic groups.
- Adds regression tests for thread affinity, failure recovery, disposal, and the default async path.
| File | Description |
|---|---|
| src/Wolverine/Transports/BackgroundReceiveLoop.cs | Keeps blocking iterations on a dedicated thread while preserving shared lifecycle handling. |
| src/Transports/Kafka/Wolverine.Kafka/Internals/KafkaTopicGroupListener.cs | Enables the dedicated-thread driver for topic groups. |
| src/Testing/CoreTests/Transports/background_receive_loop_blocking_iteration_4330.cs | Adds threading and lifecycle regression coverage. |
Review effort: Balanced
This was referenced Oct 21, 2026
This was referenced Oct 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to GH-4330 (#4354), which set out to keep the Kafka listener's blocking
IConsumer.Consume(CancellationToken)off the thread pool. As shipped, it only does so for the loop's first pass.The problem
BackgroundReceiveLoop.Start()runs a loop markedUsesBlockingIterationasTask.Factory.StartNew(runAsync, ..., TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(). ButrunAsyncis async and awaits each iteration withConfigureAwait(false).KafkaListener.consumeOnceAsynccallsConsume(token)synchronously and then awaits_receiver.ReceivedAsync(...), which on a durable endpoint is a real inbox insert. The LongRunning thread therefore runs the loop only until the first await that does not complete synchronously. Then that thread exits, the loop continues on the pool thread that completed the await, and every laterConsume(token)blocks a pool worker until a record arrives. Each Kafka listener holds a pool worker for its whole lifetime, which GH-4330 was meant to prevent. The idle wait and the failure backoff in the loop do the same.KafkaTopicGroupListener(ListenToKafkaTopics) runs the same blockingConsume(token)loop and never setUsesBlockingIteration, so it was on the pool from the start.What we measured: a 1 vCPU container on .NET 10 with 3-4 durable Kafka listeners per process. The pool starts with one worker per CPU and adds roughly one every 500 ms. When a burst of 2,886 records over 12 partitions arrived, every HTTP and gRPC call in the process queued. p99 was about 650 ms, even for an endpoint that touches no database, against 2-20 ms between bursts. Removing the CPU quota did not help (600-780 ms).
DOTNET_ThreadPool_ForceMinWorkerThreads=16brought it back to 2-21 ms, and a raised minimum is our workaround for now.The change
BackgroundReceiveLoop: one pass of the loop (heartbeat, iteration, EntityMissing/status handling, then the idle wait or the failure backoff) moves unchanged intorunOnceAsync(), which returns false once the loop has been cancelled.runAsync()awaits that pass in a loop exactly as before. Loops withoutUsesBlockingIteration(SQS, Redis, the database queues) still start onTask.Runand behave as before.UsesBlockingIterationstartsrunOnDedicatedThread()withLongRunning. That driver waits on each pass withGetAwaiter().GetResult()instead of awaiting it, so the loop is back on its own thread before every iteration, andConsume(token)always blocks that thread. Whatever a pass awaits still completes on the pool. The thread has noSynchronizationContextand nothing posts back to it, so the wait cannot deadlock.StopAsyncdrain andDisposeAsyncwork as before:Consume(token)throws on cancellation, the pass returns false, and the driver exits. Exceptions go through the same log, backoff and status path, because both drivers run the same pass.KafkaTopicGroupListenernow setsUsesBlockingIteration = true, asKafkaListenerdoes.I chose this shape because it keeps a single copy of the loop logic and the existing
LongRunningstart, and leaves async loops alone. A single-threadedSynchronizationContextorTaskSchedulerfor the loop would also work. It would mean dropping the loop'sConfigureAwait(false)calls, though, and it would run the receiver's continuations on the consume thread too.Tests
New
CoreTests/Transports/background_receive_loop_blocking_iteration_4330.cs:the_blocking_part_of_every_iteration_runs_on_one_dedicated_thread: the iteration records its calling thread and then awaitsTask.Delay. Every recorded call must be on one non-pool thread. Onmainthe first call runs on the LongRunning thread and calls 2-5 run on pool threads, so the test fails. With this change it passes.failures_still_back_off_and_recover_on_the_dedicated_thread: the failure/backoff path keeps running, recovers, and stays off the pool. Fails onmain, passes with this change.dispose_stops_an_iteration_blocked_the_way_consume_blocks: the iteration blocks on the token and throws on cancellation, asConsume(token)does.DisposeAsyncreturns promptly and the status isStopped.a_loop_that_does_not_block_stays_on_the_pool: the default path is unchanged.End to end, I ran a throwaway probe (not part of this PR) against a real broker and a Postgres inbox. It used one durable
ListenToKafkaTopicand one durableListenToKafkaTopicsgroup, 60 records, andstatistics.interval.ms=100. librdkafka serves the statistics callback from insideConsume()on the calling thread, so I counted the callbacks that arrived on a pool thread after every record had been handled:mainRan locally on Windows. Only the .NET 10 runtime is installed here, so tests ran on net10.0, and the receive-loop classes also ran on the net9.0 build rolled forward to it:
dotnet build wolverine.slnx -c Release -f net9.0: 0 errors. The only warnings were the existing NU1902 audit warnings for MessagePack 3.1.7.background_receive_loop*,receive_loop_entity_missing_4215andreceive_loop_health_default_3236classes: 18/18 passed on net10.0 and net9.0. The new class passed 25 runs in a row.DOTNET_ENVIRONMENT=Developmentset, which turns on scope validation; they pass without it. The other three areevent_model_scaffold_4832, which compares multi-line string literals with generated code and fails in my Windows CRLF checkout.Category=Flaky), against thedocker-compose.ymlimages (confluent-local:8.0.6,postgres:17): 327/327 passed.