fix(asb): de-quadratic session listeners, surface MaxConcurrentCalls, settle the original on a batched defer (GH-3494) - #3674
Merged
Conversation
… settle the original on a batched defer (GH-3494) AO2 -- ListeningAgent already builds Endpoint.ListenerCount listeners, and BOTH session paths then multiplied by ListenerCount again. AzureServiceBusSessionListener spawned ListenerCount accept loops per instance, so RequireSessions(5) opened 25 concurrent AcceptNextSessionAsync loops competing for the same sessions. The ServiceBusSessionProcessor path added in GH-3533 set MaxConcurrentSessions = ListenerCount on each of the ListenerCount processors, so RequireSessions(8) ran 64 concurrent sessions. Both now yield exactly n, which is what RequireSessions(n) documents. A user ConfigureSessionProcessor customization still overrides. AO3 -- Wolverine never set ServiceBusProcessorOptions.MaxConcurrentCalls, so every inline Azure Service Bus endpoint ran at the SDK default of one concurrent handler invocation, reachable only through the raw ConfigureProcessor hook (MaximumParallelMessages does not apply to inline endpoints). New MaximumConcurrentCalls(n) on the queue and subscription listener configurations, applied before ConfigureProcessor so that hook still wins. On the session processor path it maps to MaxConcurrentCallsPerSession and stays at 1 unless asked for, since raising it discards per-session FIFO ordering. AO8 -- the batched listener's defer block re-sent a copy without settling the original, which then sat locked until the lock expired and was redelivered: one deferral, two deliveries. Now completes first, matching the inline listener. AO4 (settlement concurrency) is deferred -- RetryBlock hard-codes a parallel count of 1, so that is a JasperFx change, not a Wolverine one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Jul 28, 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.
The Azure Service Bus deep dive (GH-3494), Wave 0 — the items the plan calls out as landable from
code review and tests, before a real namespace is available. No emulator timings are quoted:
§2 and §8 rule the emulator out as a source of publishable numbers, and AE2's PrefetchCount
validation still needs a real Standard/Premium namespace. Everything below is an exact count.
AO2 — the n² was real, on both session paths
ListeningAgentbuildsEndpoint.ListenerCountlisteners whenever that count exceeds 1(
ListeningAgent.cs:373-381). Both session implementations then multiplied byListenerCountagain.
Accept-loop listener (
AzureServiceBusSessionListener, the default whenConfigureSessionProcessoris unset) — concurrentAcceptNextSessionAsyncloops:RequireSessions(n)ServiceBusSessionProcessorpath (opt-in, added in #3533) — I did not expect to find this one.BuildSessionProcessorOptionssetMaxConcurrentSessions = endpoint.ListenerCounton each of theListenerCountprocessors:RequireSessions(n)Both now yield exactly
n, which is whatRequireSessions(n)is documented to mean, and a userConfigureSessionProcessor(o => o.MaxConcurrentSessions = ...)still overrides.build_session_processor_options_maps_listener_count_to_max_concurrent_sessionsasserted in #3533, merged four days ago. I rewrote that test to assert the total-concurrency
invariant instead, with the reasoning inline, and added one covering the user override. If #3533
intended 8x8 deliberately, say so and I'll revert this half.
AO3 — inline concurrency was 1, and unreachable
Wolverine never set
ServiceBusProcessorOptions.MaxConcurrentCalls, so every inline ASB endpoint ranat the SDK default of one concurrent handler invocation, changeable only through the raw
ConfigureProcessorhook (MaximumParallelMessagesdoes not apply to inline endpoints — it sizesWolverine's in-process worker queue, which inline bypasses).
Applied before
ConfigureProcessorso that hook still wins. On the session-processor path it maps toMaxConcurrentCallsPerSessionand stays at 1 unless explicitly asked for, since raising it discardsthe per-session FIFO ordering that is usually the point of sessions.
AO8 — one deferral produced two deliveries
The batched listener's
_deferblock re-sent a copy but never settled the original, which then satlocked until the lock expired and Azure Service Bus redelivered it.
InlineAzureServiceBusListeneralready did complete-then-resend; the batched path now matches.AO4 — deferred, needs an upstream change
Widening settlement concurrency is not a Wolverine-side change:
RetryBlockhard-codesnew Block<Item>(1, Block<Item>.Unbounded, executeAsync), so the per-messageCompleteMessageAsynccalls are serialized by construction. Noted in the plan.
Tests
12 new unit tests (accept-loop count across
RequireSessions0/1/5/10; theMaximumConcurrentCallssurface, its precedence against
ConfigureProcessor, and the session-processor mapping), plus thetwo rewritten session-processor tests.
The emulator suite is noisy on this box, so every failure was baselined against a clean
origin/mainworktree on the same emulator with the same filter:origin/mainorigin/mainwhen_using_handler_type_naming, isolatedTwo failure modes, both environmental and both reproduced on clean
main:Bugs—BrokerInitializationException: Unable to initialize the Broker asb in time. Emulator broker-init timeouts under the per-classDeleteAllEmulatorObjects+AutoProvisionchurn. The count moves around with test ordering(21 in the full run, 25 in the isolated subset, 23 on main) — it goes up when fewer tests run,
which is the opposite of a code regression. Nothing in this diff touches discovery or provisioning.
["One","Three","Two"],["Dummy 4.2","Dummy 4.1"]) — thesepassed in main's 34-test subset and failed in the branch's, which looked branch-specific until I
ran them in isolation: 3/3 failures on branch and 3/3 on clean main. The earlier main pass was
test-ordering luck. This matches the plan's own §8 note that sessions misbehave on the emulator.
Both endpoints use
RequireSessions()/RequireSessions(1), so AO2 spawns one accept loopbefore and after — that path is unchanged for them.
Operational note for anyone rerunning these:
AzureServiceBusTestingcallsDeleteAllEmulatorObjectsAsync()once per test process, so two ASB test processes against oneemulator wipe each other's entities. These runs have to be serial.
Still open on GH-3494
AO1 (
ServiceBusSessionProcessorrewrite of the accept-loop listener), AO4, AO5 (lock renewal on thebatched path), AO6/AE2 (PrefetchCount validation and guidance), and the whole real-namespace matrix.
🤖 Generated with Claude Code