GH-4049: ASB native scheduling, and NativeAck refused with sessions - #4064
Merged
Conversation
Preparatory work for EndpointMode.NativeAck on Azure Service Bus. The investigation on #4049 established that ASB needs no new listener class -- BatchedAzureServiceBusListener's receive loop is already non-blocking and it carries no settlement policy of its own -- so this is the two things that were actually missing. 1. ISupportNativeScheduling on BatchedAzureServiceBusListener. Without it a scheduled retry falls through to Storage.Inbox, breaking the storage-free criterion of the mode (#3708). Implemented the way the inline listener does it -- stamp ScheduledTime and re-publish, which the outgoing mapper turns into ServiceBusMessage.ScheduledEnqueueTime -- but routed through this listener's existing _defer block so the original delivery is settled first, per the GH-3494 (AO8) reasoning already recorded on that block. NativeSchedulingEnabled is gated to NativeAck alone: ReScheduleAsync prefers the listener over the channel, so claiming it unconditionally would take the reschedule away from BufferedReceiver and DurableReceiver, both of which own one already. 2. NativeAck combined with RequireSessions() is refused at bootstrap, Fatal. SessionSpecificListener.CompleteAsync throws, and the accept loop disposes the session receiver at the end of the iteration that created it -- under NativeAck, before any handler has run. The check lives in ListenerConfigurationValidator rather than the Mode setter because both settings are delayed configuration, so a setter guard would catch one fluent ordering and miss the reverse (the GH-3712 order dependence). A defensive throw guards the listener selection sites too, which ask about sessions ahead of the mode. Carries byte-identical copies of #4057's Endpoint.validateModeConfiguration() hook and its validator wiring so the two branches merge cleanly in either order. supportsNativeAck stays false on ASB queues and subscriptions; adoption is the follow-up. Tests open only that gate through a test-only subclass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…k-prep # Conflicts: # src/Wolverine/Configuration/ListenerConfigurationValidator.cs
Sending anything to an Azure Service Bus endpoint that listens with native acks threw "This sender has not been registered." on every batch. GH-3709 (#4061) remapped EndpointMode.NativeAck to InlineSendingAgent on the sending side. InlineSendingAgent is not an ISenderCallback, so EndpointCollection.CreateSendingAgent skips RegisterCallback -- but this transport still chose a BatchedSender, because its gate asked only about EndpointMode.Inline. Agent says inline, sender says batched, and BatchedSender refuses to send without a callback. This is the same mismatch the GH-3826 comment a few lines above already describes for TenantedSender, reached through the mode instead of tenancy, so the fix is the same: NativeAck takes the inline sender. Caught by native_ack_native_scheduling_4049.a_scheduled_retry_goes_back_through_the_broker, which is the first ASB test to both listen with native acks and publish to the same queue. Note the same gate exists in RedisStreamEndpoint.CreateSender, and Redis accepted NativeAck in #4056, so Redis is exposed too -- raised separately rather than fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeremydmiller
added a commit
that referenced
this pull request
Aug 24, 2026
Two conflicts, both because GH-4047's validateModeConfiguration hook (#4057) landed in the same two places this branch extends. Kept both on each side; they are complementary rather than competing. Endpoint.cs -- both sides add a distinct protected virtual member under a shared doc-comment opener. supportsRedelivery (this branch) asks "can a deferred message ever come back", validateModeConfiguration (main) asks "is this endpoint's final configuration coherent". Neither supersedes the other. ListenerConfigurationValidator.cs -- both sides add a check above the Inline-only early-out, and both belong there for the same reason: they describe constraints that hold in EVERY mode, not just Inline. Ordered with main's transport hook first so a Fatal transport constraint is reported ahead of this branch's advisory warning, matching how the same collision was resolved on #4057 and #4064. Verified after resolution: dotnet build wolverine.slnx -c Release -f net9.0 clean, CoreTests 2593 total / 0 failed.
jeremydmiller
added a commit
that referenced
this pull request
Aug 24, 2026
SQS, SNS, GCP Pub/Sub, Kafka, MQTT and HTTP all gated their inline sender on the literal `Mode == EndpointMode.Inline` and fell through to a BatchedSender otherwise -- the same shape that broke Redis Streams. None of them sets supportsNativeAck, so the Mode setter refuses NativeAck and none is broken today. Each would break the day it adopted the mode. Inert by construction: SendsInline is `Mode is Inline or NativeAck`, and NativeAck is unreachable for these six, so the predicate is exactly equivalent to what it replaces until one of them opts in. The value is that opting in then requires no second edit here, which is the failure GH-4073 was. Only CreateSender gates changed. Deliberately untouched: * PubsubEndpoint.buildListener -- the same literal on the LISTENING side, where SendsInline is the wrong question. * SqsListener's ExtendVisibilityWhileHandling check, likewise listening-side. * Endpoint.ModeIgnoresParallelism, which is about the execution block, not sending. * TcpEndpoint, whose supportsMode refuses Inline outright, so it never gets an inline agent and has no gate to widen. * Azure Service Bus, fixed separately on #4064. Verified: full wolverine.slnx clean on -f net9.0, 0 warnings. Wolverine.Http.Tests Transport 63/63 and Kafka configure_consumers_and_publishers 5/5 -- both directly exercise an inline-vs-batched sender gate changed here. The six transports' own CI jobs cover the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, and a welcome one: this branch and #4070 independently fixed the same GH-4061 sender-registration regression on the ASB gates. This branch used `Mode is EndpointMode.Inline or EndpointMode.NativeAck` inline at both call sites; #4070 introduced `Endpoint.SendsInline` and applied it across all seven transports. Kept main's central form. The explanation this branch carried in a comment at each site now lives in the xml-docs on SendsInline itself, which was the point of centralising it -- the per-transport version is exactly what invites the bug again the next time a mode is added. Verified after resolution: dotnet build wolverine.slnx -c Release -f net9.0 clean.
jeremydmiller
added a commit
that referenced
this pull request
Aug 24, 2026
#4070 fixed this regression independently and reached the same design -- an Endpoint.SendsInline predicate replacing the `Mode == EndpointMode.Inline` literal. It merged first, so everything this branch had in common with it is dropped in favor of main's version: * Endpoint.SendsInline -- main's declaration kept, mine removed. Note this auto-merged into TWO declarations of the same property (we added it in different places, so there was no textual conflict for git to report) and would not have compiled. * The Redis, SQS, SNS, Kafka and MQTT sender gates -- main's taken verbatim. * Azure Service Bus -- #4070 fixed it too, so the earlier "leave ASB to #4064" note is moot. What remains here is the delta #4070 does not cover: * The bootstrap guard in EndpointCollection.CreateSendingAgent. Main still has the silent `&&`, so a callback-requiring sender under an inline agent is still skipped quietly rather than refused. That is the part that made this expensive to diagnose: the throw lands on a block worker thread and surfaces only as a ~30s timeout far from the endpoint at fault. * Pub/Sub and HTTP, the two sender gates #4070 left on the literal. Inert today -- neither sets supportsNativeAck -- but they are the last two. * Five regression tests. #4070 shipped none, so nothing currently pins this behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erdtsieck
pushed a commit
to erdtsieck/wolverine
that referenced
this pull request
Aug 25, 2026
… NativeAck endpoints
Redis Streams is broken on main. Its native_ack_mode suite is red 4 of 6, and any
stream that both listens with native acks and is a send target -- the node's own
reply endpoint counts -- fails every outgoing batch with
InvalidOperationException: This sender has not been registered.
JasperFx#4061 (JasperFxGH-3709) remapped EndpointMode.NativeAck from BufferedSendingAgent to
InlineSendingAgent. InlineSendingAgent is not an ISenderCallback: unlike
SendingAgent, it drives a plain RetryBlock straight into Sender.SendAsync, with no
callback path at all. Meanwhile RedisStreamEndpoint.CreateSender still gated its
inline sender on `Mode == EndpointMode.Inline`, so NativeAck fell to the batched
branch. The agent said inline, the sender said batched, and CreateSendingAgent's
`sender is ISenderRequiresCallback && agent is ISenderCallback` quietly skipped
the registration. JasperFx#4056 (JasperFxGH-4046) made that reachable.
Three changes:
* Endpoint.SendsInline -- one definition of "this endpoint's outgoing side is an
inline agent". EndpointMode governs BOTH directions, so the modes that produce
an inline agent are not just Inline. Transports gate on this, not the literal.
* CreateSendingAgent refuses the mismatch at bootstrap instead of falling through.
The invariant is total -- BatchedSender is the only ISenderRequiresCallback in
the codebase and SendingAgent is the only ISenderCallback -- so a
callback-requiring sender under an inline agent is always a transport bug. The
message names the endpoint, both types, and the fix.
* Redis Streams gates on SendsInline.
The bug was already caught, which is the part worth recording: BatchedSender.SendAsync
posts to a block and returns, so the throw lands on a worker thread nothing awaits.
The block logs it and the caller sees only messages that never arrive. What surfaced
was a 30s TimeoutException far from the misconfigured endpoint, which on a
broker-backed suite reads as flakiness. Hence the bootstrap guard: with it, the same
defect fails in 147ms naming the fix, instead of after 2m39s naming nothing.
Rejected: making InlineSendingAgent implement ISenderCallback. It looks like the
cleaner central fix, but BatchedSender.SendAsync returns before the frame is written,
so an inline agent over a batched sender would reintroduce exactly the ack-before-send
window JasperFx#4061 closed. It silences the exception and restores the bug.
Audited every transport. RabbitMQ and Pulsar, the other two NativeAck adopters, both
always build fire-and-forget senders and are unaffected. SQS, SNS, GCP, Kafka, MQTT
and HTTP share the `Mode == EndpointMode.Inline` gate but none opts into NativeAck, so
each is latent rather than broken; the guard makes any future adoption fail loudly.
TCP refuses Inline outright. Azure Service Bus is fixed separately on JasperFx#4064.
Verified: Redis 160/160 (was 4 failing), CoreTests 2584/2584, RabbitMQ and Pulsar
native_ack_mode suites green, full wolverine.slnx builds clean on -f net9.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
References #4049. Not "Closes" — see Scope below.
Read this first: #4049 was re-scoped, and two of its original premises were wrong
The issue was filed as "NativeAck needs a new listener class for ASB and Pub/Sub". Investigation against
origin/maindisproved two of its premises, and the investigation comment on #4049 re-scoped it. This PR follows that comment, not the original body. The corrections, so this PR does not look like it is ignoring its own issue:The
AutoCompleteMessagesclaim was wrong. The issue said Azure Service Bus: inline processor leaves AutoCompleteMessages at the SDK default (true), so a failed dead-letter/defer can be auto-completed behind Wolverine's back #4018 turns it off on the inline processor and the batched path does not set it.BuildReceiverOptionsbuilds aServiceBusReceiverOptions, which has noAutoCompleteMessagesproperty at all — that belongs to the processor options. The batched path is peek-lock and non-auto-completing by construction, so there was never anything to turn off.ASB does not need a new listener class.
BatchedAzureServiceBusListener's receive loop is already non-blocking — it spawns its ownTask.Run(listenForMessages)and hands each batch toReceivedAsyncwithout awaiting handler completion — and it carries no settlement policy of its own:CompleteAsyncgoes straight toServiceBusReceiver.CompleteMessageAsync. It only looks like ack-at-receipt today becauseBufferedReceiverposts its complete block at receipt. Swap inNativeAckReceiverand the same class settles at handler completion, unchanged. So the issue's "third class or settlement-policy parameter?" question has a third answer for ASB: neither.Pub/Sub is a different problem — it has no per-message settlement primitive at all, in any mode — and is now tracked on NativeAck endpoint mode for GCP Pub/Sub #4052. Nothing here touches it.
What was actually left for ASB is the two things below.
1.
ISupportNativeSchedulingonBatchedAzureServiceBusListenerWithout it,
MessageContext.ReScheduleAsyncfalls through toStorage.Inbox, which breaks #3708's storage-free criterion for the mode and throws outright on a host with no persistence.Implemented the way the inline listener does it — stamp
Envelope.ScheduledTimeand re-publish through the requeue sender, which the outgoing mapper turns intoServiceBusMessage.ScheduledEnqueueTime, so the broker holds the message rather than Wolverine.Two choices worth a reviewer's attention:
_deferblock rather than sending directly, so the original delivery is settled before the copy goes out. This is the one deliberate divergence from the inline listener, which does not settle the original. Within this class the precedent is unambiguous: the_deferblock's own Azure Service Bus performance deep dive: PrefetchCount validation, session listener rework, settlement concurrency #3494 (AO8) comment records that leaving the original unsettled means the broker redelivers it at lock expiry, so every deferral costs a duplicate. (The inline listener's omission looks like a live defect since Azure Service Bus: inline processor leaves AutoCompleteMessages at the SDK default (true), so a failed dead-letter/defer can be auto-completed behind Wolverine's back #4018 turned offAutoCompleteMessagesthere — out of scope here, flagged separately.)NativeSchedulingEnabledis gated toEndpointMode.NativeAckalone.ReScheduleAsyncprefers the listener over the pipeline channel, so answeringtrueunconditionally would take the reschedule away from receivers that already own it:BufferedReceiver, which supplies an in-memory rescheduler, andDurableReceiver, whose envelope already has an inbox row that a republished copy under the same id would collide with on redelivery. That is exactly whyRedisStreamListeneropts out ofDurable.2. NativeAck + ASB sessions refused at bootstrap, Fatal
SessionSpecificListener.CompleteAsyncthrowsNotSupportedException, and the accept loop scopes the session receiver to a singleawait usingiteration — so under NativeAck the session lock is disposed before any handler has run. A lifetime mismatch, not a renewable timeout, so there is no tuning that rescues it.Where the check lives, and why it cannot live in the
ModesetterRequireSessions()andProcessInParallelWithNativeAcks()are both delayed configuration — each is anadd(e => …)lambda applied in registration order insideEndpoint.Compile(). A guard in theModesetter (or insupportsNativeAck, the only member that setter consults) therefore sees a different endpoint depending on which fluent call the user wrote first: sessions-first, it sees a session queue; native-acks-first, it sees a session-less one and waves the pair through. Same order-dependence class GH-3712 fixed forProcessInline()+MaximumParallelMessages().The check goes in
ListenerConfigurationValidator, above its non-Inline early-out, so it runs over the final compiled state.native_ack_with_sessions_4049.a_guard_in_the_mode_setter_would_be_ordering_dependentpins that premise as a test rather than a claim: the test subclass recordsOptions.RequiresSessionat the momentsupportsNativeAckis read, and assertstruefor one ordering andfalsefor the other.A defensive throw also guards the two listener-selection sites in
AzureServiceBusTransport.Listening, which ask aboutRequiresSessionahead of the mode and would otherwise take the session branch in silence.Relationship to #4057 — resolved: it merged, and the identical-copy strategy worked
This needs the general
Endpoint.validateModeConfiguration()hook that #4057 added for Pulsar. #4057 was still open when this branch was written, so rather than invent a second mechanism, it carried byte-identical copies of #4057's three core changes — the hook onEndpoint.cs, its wiring inListenerConfigurationValidator.cs, and thenative_ack_mode_gate.csadditions — applied at the same locations from the same base commit, on the theory that git resolves identical changes from a common ancestor without a conflict.#4057 has since merged, and that is exactly what happened. Merging the new
mainin produced no conflict in any of the three files, and this branch's copies simply merged away. The hook now comes from #4057 and appears exactly once.The diff against
mainis now purely Azure Service Bus — the transport plus its two new test files. Nothing insrc/Wolverine/remains.Scope — why not "Closes #4049"
Both halves the re-scoped issue asked for are done. But
supportsNativeAckdeliberately staysfalseonAzureServiceBusQueueandAzureServiceBusSubscription, so ASB still refusesProcessInParallelWithNativeAcks()in theModesetter today. Flipping it is the adoption follow-up — prefetch /MaximumMessagesToReceivesizing plus the compliance suite, gated on #4048 — and this PR is the prerequisite that has to be in place before that flip, so the issue should stay open until then.The tests open only that one gate, through a test-only subclass.
the_shipping_queue_still_refuses_native_acks_in_the_mode_setterpins the distinction explicitly, so a session-rejection test can never pass on the strength of the mode gate's ownInvalidOperationException— a different exception type, from a different place, at a different time.No user-facing docs, for the same reason: documenting a configuration nobody can currently write would be premature. Docs belong with the adoption PR.
3. A live regression on
mainthat this surfaced (#4061/ GH-3709)Not planned work — the end-to-end test found it, and it had to be fixed for ASB native acks to function at all.
Sending anything to an ASB endpoint that listens with native acks threw
InvalidOperationException: "This sender has not been registered."on every batch. Mechanism:mainwhile this branch was in flight) remappedEndpointMode.NativeAcktoInlineSendingAgentinEndpointCollection.buildSendingAgent.InlineSendingAgentis not anISenderCallback, soCreateSendingAgentskipsRegisterCallback— it only fires whensender is ISenderRequiresCallback && agent is ISenderCallback.BatchedSender, because its gate asked only aboutEndpointMode.Inline.BatchedSender.SendBatchAsyncrefuses to send without a callback.Agent says inline, sender says batched. This is precisely the mismatch the existing GH-3826 comment a few lines above already documents for
TenantedSender, reached through the mode rather than through tenancy — so the fix is the same one: NativeAck takes the inline sender.It went unnoticed because
native_ack_native_scheduling_4049.a_scheduled_retry_goes_back_through_the_brokeris the first ASB test that both listens with native acks and publishes to the same queue.Verification
dotnet build wolverine.slnx -c Release -f net9.0— the pinned form CI uses: clean, 0 warnings, 0 errors.main, which changed the same validator).native_ack_with_sessions_4049(12 tests) andnative_ack_native_scheduling_4049(3 tests, against the ASB emulator) — all green.Red baselines — what was broken, and what failed
Every test that claims to prove new behaviour was run against a deliberately broken implementation. The two rejection branches were baselined independently, so neither can be passing on the other's account:
AzureServiceBusEndpoint.validateModeConfiguration()→yield breakthe_host_refuses_to_start(5 failures)AssertSessionsAreCompatibleWithMode()→ body replaced withif (false)the_listener_selection_guard_refuses_the_paironly (1 failure)the_host_refuses_to_startNativeSchedulingEnabled→falsea_scheduled_retry_goes_back_through_the_broker, the latter onattempts[1].Envelope should not be same as— i.e. the in-memory fallback re-executed the sameEnvelopeinstance, which is precisely the distinction the test exists to drawbuffered_mode_still_leaves_scheduling_to_its_receiverScheduledTime = null)a_scheduled_retry_goes_back_through_the_broker, on the elapsed-time assertionThe sender fix in §3 has a natural before/after baseline rather than an injected one:
a_scheduled_retry_goes_back_through_the_brokerfailed twice with"This sender has not been registered."before it and passes after.A note on the local ASB runs. Another agent was running ASB suites against the same emulator throughout, restarting the container mid-run. That produced two rounds of spurious failures — 23
BufferedSendingAndReceivingCompliancetests failing at 0ms (fixture init), plussession_id_pinning,end_to_end_with_CloudEventsandend_to_end_with_conventional_routingtiming out. Every one of them passes on an uncontended re-run, and I have not claimed any contended run as a result in either direction. Contention here only ever causes timeouts, never false passes, so the green results above stand.The last row is what keeps the behavioural test from being tautological: it separates "a copy was republished" from "the broker held the copy for the requested delay". (It also turned up that the
ScheduledTimestamp insideMoveToScheduledUntilAsyncis redundant —ReScheduleAsyncsets it first — so it is kept only for consistency with the inline listener and for direct callers.)CI — 35 pass, 2 red, neither from this branch
CIAzureServiceBuspasses (13m21s), along withCIAzureServiceBusLeader,CIAzureServiceBusRouting,CIPulsarandCIRabbitMQ. That is the authoritative full ASB suite, and it counts for more than the local runs described above, which were repeatedly contaminated by a concurrent suite restarting the emulator.CIRedis— pre-existing breakage onmain, and this PR's investigation identified its cause. CI bisect onmainitself:maincommitCIRedis64cff90a9(#4056, which added Redis NativeAck)2ea4266b6(#4061, GH-3709 "send inline from NativeAck endpoints")The failures are exactly and only the native-ack class — 154 passed, 4 failed, all four in
Wolverine.Redis.Tests.native_ack_mode— and they are the same defect §3 fixes for Azure Service Bus. Reproduced locally, where the host log names it four times:RedisStreamEndpoint.CreateSender(line 344) still gates its inline sender onMode == EndpointMode.Inline, so a native-ack endpoint gets aBatchedSenderunder anInlineSendingAgentthat never registers the callback. Redis native-ack messaging is non-functional onmaintoday, andmain's own CI has been red since #4061. Raised separately rather than fixed here — the recurring per-transport patch may be the wrong answer next to a central guard, and Pulsar (#4057) should be audited for the same gate. Worth checking before the next release.CIMarten— conclusion iscancelled, notfailure, with no step marked failed;gh pr checksrenders that asfail. It hit the 20-minute cap. The in-job memory sampler from #3771 shows what happened and rules out the OOM hypothesis it was added to test:Byte-identical readings every 15 seconds for minutes, ~13.7 GB still available. A wedged suite, not memory exhaustion.
CIMartenis green onmain, andMartenTests.csprojhas no reference toWolverine.AzureServiceBus.An earlier commit also saw
CIPersistencego red; its SQL Server service container never came up on the runner (not ready after 120s ... TCP Provider, error: 35), so no test executed. It is green onmain, andPersistenceTestspasses locally here (105/105).The diff against
mainis confined tosrc/Transports/Azure/, and neitherMartenTestsnorPersistenceTestsnorWolverine.Redis.TestsreferencesWolverine.AzureServiceBus.🤖 Generated with Claude Code