Skip to content

GH-3533: Pin Azure Service Bus session listeners to specific session identifiers - #3534

Merged
jeremydmiller merged 1 commit into
mainfrom
feature/3533-asb-session-id-pinning
Jul 20, 2026
Merged

GH-3533: Pin Azure Service Bus session listeners to specific session identifiers#3534
jeremydmiller merged 1 commit into
mainfrom
feature/3533-asb-session-id-pinning

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #3533

Thanks @tudor-tesss for the well-scoped request — the mechanism you described (session id as a broker-enforced routing key) is exactly what this implements.

Problem

There was no way to have several competing consumers share a single Azure Service Bus queue/subscription while each only ever processes messages for a fixed set of session ids. The hand-rolled session listener always calls AcceptNextSessionAsync(), so a consumer locks whatever session is next available — the cross-delivery problem from the issue.

Solution

Add an opt-in ServiceBusSessionProcessor-based session listener and expose ServiceBusSessionProcessorOptions to users. Populating SessionIds pins the processor so it only ever locks those sessions; on a shared entity that makes the session id a broker-enforced routing key.

API

// Consumer node "A" — only ever locks the "A" session on a shared queue
opts.ListenToAzureServiceBusQueue("shared-orders")
    .RequireSessionsWithOnlyTheseIdentifiers("A");

// Consumer node "B"
opts.ListenToAzureServiceBusQueue("shared-orders")
    .RequireSessionsWithOnlyTheseIdentifiers("B");

// Producer routes with the session id (GroupId)
await bus.PublishAsync(new OrderPlaced(...), new DeliveryOptions { GroupId = "A" }); // only A receives

The general hook for the other knobs:

opts.ListenToAzureServiceBusQueue("orders")
    .ConfigureSessionProcessor(o =>
    {
        o.MaxConcurrentSessions = 8;
        o.MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10);
        o.SessionIdleTimeout = TimeSpan.FromSeconds(30);
    });

Both methods are available on queue and subscription listener configs.

What's in the box

  • ConfigureSessionProcessor on AzureServiceBusEndpoint — a multicast delegate (unlike inline ConfigureProcessor) so the SessionIds sugar and explicit customizations compose.
  • BuildSessionProcessorOptions mirrors BuildProcessorOptions: maps ListenerCountMaxConcurrentSessions, keeps in-session FIFO (MaxConcurrentCallsPerSession = 1), and re-asserts the acknowledgement-critical ReceiveMode = PeekLock / AutoCompleteMessages = false.
  • New InlineAzureServiceBusSessionListener (built on ServiceBusSessionProcessor) with full ISupportDeadLetterQueue / ISupportNativeScheduling / IReportConnectionState parity.
  • AzureServiceBusEnvelope gains a ProcessSessionMessageEventArgs complete/defer/dead-letter path (it does not share a base type with ProcessMessageEventArgs).

Safety / gating

The existing hand-rolled AcceptNextSession loop remains the default. The processor engine is only used when ConfigureSessionProcessor is set (directly or via RequireSessionsWithOnlyTheseIdentifiers). Existing session listeners are completely unaffected — this is purely additive. Wolverine reserves ReceiveMode and AutoCompleteMessages.

Tests

  • Unit coverage for BuildSessionProcessorOptions and both fluent methods (SessionIds population, ListenerCountMaxConcurrentSessions, PeekLock/AutoComplete re-assertion, multicast composition, and that a plain RequireSessions() still uses the legacy loop).
  • An emulator end-to-end test that reproduces the issue: a listener pinned to "A" receives exactly its messages, while a "B" message seeded onto the same shared queue is never delivered to it.

Docs added to the Azure Service Bus session-identifiers page + DocumentationSamples.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FKAxzuZ36VP6UPcTQf3MUs

…identifiers

Add an opt-in ServiceBusSessionProcessor-based session listener so competing
consumers on ONE shared queue/subscription can each be pinned to their own
session id(s). On a shared entity the session id becomes a broker-enforced
routing key, so a listener pinned to "A" never sees the messages meant for "B".

- ConfigureSessionProcessor(Action<ServiceBusSessionProcessorOptions>) on both
  queue and subscription listener configs (multicast, so it composes)
- RequireSessionsWithOnlyTheseIdentifiers(params string[]) sugar that populates
  ServiceBusSessionProcessorOptions.SessionIds
- BuildSessionProcessorOptions mirrors BuildProcessorOptions; reserves
  ReceiveMode=PeekLock and AutoCompleteMessages=false
- New InlineAzureServiceBusSessionListener with full dead-letter / native
  scheduling / connection-state parity; AzureServiceBusEnvelope gains a
  ProcessSessionMessageEventArgs complete/defer/dead-letter path

Gated: the legacy AcceptNextSession loop stays the default and only flips to the
processor when ConfigureSessionProcessor is set, so existing session listeners
are unaffected. Unit tests + an emulator end-to-end test reproducing the issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKAxzuZ36VP6UPcTQf3MUs
@jeremydmiller
jeremydmiller merged commit 768328a into main Jul 20, 2026
29 checks passed
erdtsieck pushed a commit to erdtsieck/wolverine that referenced this pull request Aug 3, 2026
…asperFxGH-3763)

19 [Trait("Category", "Flaky")] tags hid 65 of the 304 tests in this project --
21% of the suite never ran. Not one of them recorded a reason, a number, or an
issue link, so there was no way to tell from the code whether any had ever been
real.

git log -L on each tag line says most were never individually judged: 15 of the
19 went in on 2026-03-20/21 across four commits with messages like "Tag all
ConventionalRouting tests as Flaky" and "tag flaky AWS/Azure SB tests". That is a
bulk sweep, not 15 investigations. The other 4 were added later alongside real
PRs (JasperFx#2588, JasperFx#3103, JasperFx#3534) and are left tagged here to be judged one at a time.

There is now a specific reason to think the sweep was measuring infrastructure
rather than tests. JasperFxGH-3781 follow-up found the emulator readiness gate was
TCP-connecting to the AMQP port while provisioning goes to the management port,
which answers 503 for ~26 seconds after the socket opens -- so any class doing
provisioning near the start of a run could fail for reasons that had nothing to
do with it. Nine of the fifteen are ConventionalRouting discovery tests, which
are exactly that shape.

This commit only removes the tags. Whether they stay off is decided by what CI
says, per class, by name -- the RabbitMQ pass in JasperFx#3780 nearly untagged a class
that fails 2 of 20 every run because it was verified as part of a set rather than
individually.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116vfBcKwcjWn8msM4ZjkuA
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.

Add support for setting SessionIds through "ServiceBusSessionProcessorOptions"

1 participant