GH-3708: EndpointMode.NativeAck — partitioned, parallel processing with native broker acks - #4040
Merged
Merged
Conversation
BufferedReceiver's execution block with InlineReceiver's channel wiring. An incoming delivery is enqueued into an in-memory (optionally group-partitioned) block and deliberately NOT settled; the broker delivery stays unacknowledged until the handler pipeline reaches a terminal, at which point the completion continuation settles it natively against the listener. The single line that separates this mode from BufferedInMemory is the ABSENCE of a _completeBlock.PostAsync() on receipt. * The pipeline channel is resolved PER ENVELOPE via GH-4013's DeserializeFirst(pipeline, runtime, channelSource) overload, because with ListenerCount > 1 the receiver is shared across listeners and a single bound IChannelCallback would settle the wrong delivery. There is a test for that. * _completeBlock survives, but only for envelopes that never reach the pipeline at all -- an already-expired delivery has to be acked so the broker stops redelivering something nobody will ever process. * Scheduling and dead-lettering are deliberately NOT implemented here. The channel is the listener, so those are the listener's native capabilities, the same as InlineReceiver. Implementing ISupportNativeScheduling here would route a terminal through an in-memory structure a crash would lose, which is the guarantee this mode exists to provide. * DrainAsync carries the same re-entrancy guard as the other receivers (a drain triggered from inside the pipeline must not wait on the block), and it is explicitly NOT a correctness requirement that the block empties: anything still queued was never acked, so closing the channel hands it back to the broker. The cost is redelivery bounded by the prefetch window, not loss. Quantifying that under a rolling deploy is GH-3713. Still unreachable in production: no transport overrides supportsNativeAck yet. RabbitMQ opts in at step 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EnqueueDirectlyAsync is a type-switch over receiver implementations ending in a throwing fallthrough. Every receiver that existed before GH-3708 had a branch, so the fallthrough was unreachable; NativeAckReceiver made it reachable. This is the durability agent's re-entry point -- DLQ replay (GH-1942) and scheduled-message firing -- so without a branch, a NativeAck endpoint inside an application that also has persistence configured threw InvalidOperationException("There is no active, local queue ...") on any replay targeting it. Durable outbox for sending plus native acks on one flooding listener is a legitimate combination, not an exotic one. The branch mirrors the InlineReceiver case, which already solves the same problem: a receiver whose settlement rides a listener rather than a local queue. RetryOnInlineChannelCallback is the right wrapper because these envelopes come from the message store rather than a broker delivery -- there is no delivery tag to settle, so completion marks the inbox row handled first and only then forwards to the real listener. The test was red-baselined against the exact InvalidOperationException before the branch was added, and asserts the receiver really is a NativeAckReceiver so it cannot pass vacuously through the BufferedReceiver branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o reach it RabbitMqQueue is the first transport to override supportsNativeAck. It qualifies on both counts the mode requires: * Deliveries settle individually. GH-3706 made every ack multiple: false, which is load-bearing here -- under multiple: true an out-of-order completion would silently ack every lower delivery tag still in flight, turning the mode's no-loss guarantee into silent loss. * GH-3687's DeliveredOn / CanSettle plumbing already makes a completion-time ack safe when it arrives from an arbitrary worker thread rather than the consumer callback, and refuses to settle against a stale or closed channel. Queues only, not exchanges or topics: native acks are a listening concept. ProcessInParallelWithNativeAcks() is pulled forward from step 6 because without it the opt-in is unreachable -- there is no fluent Mode() on listener configuration, and a test casting through IRabbitMqQueue to set Mode directly would be documenting a missing API rather than a working one. It throws eagerly for a local queue, matching ProcessInline()'s guard from GH-4022; the lazily-sourced LocalQueueConfiguration path that guard cannot see is already covered, because LocalQueue does not override supportsNativeAck and the Mode setter rejects it with a real message. Tests, against a real broker: the endpoint really is in NativeAck mode with back pressure off, messages flow end to end, and -- the guarantee the mode exists for -- a node that dies mid-flight having acked nothing loses none of them, with a fresh node picking up all five on redelivery. Each test owns its own queue. A single shared queue let the redelivery test's messages land in another test's assertion, since the tracking bag is static and the broker outlives the host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GH-3712 landed a bootstrap validator and a per-mode settings matrix written for a three-mode world. Both were wrong the moment a fourth mode existed. * The validator's fatal message for Inline + PartitionProcessingByGroupId() now points at ProcessInParallelWithNativeAcks(), which exists for exactly the combination the user was reaching for, instead of only offering the two modes that involve giving up native acks. * The CoreTests assertion pinning that message text is updated. It was written in GH-3712 and is precisely the debt that PR predicted. * ServerlessEndpointsMustBeInlinePolicy now warns before downgrading a NativeAck endpoint. Coercion is correct for Serverless -- there is no long-running process to hold an execution block -- but partitioned processing is a guarantee the user asked for, and dropping it in silence is the failure mode GH-3712 exists to prevent. * The listener docs gain a NativeAck column in the settings matrix and a section for the mode: the missing-cell table from the issue, the guarantee stated exactly (intra-group concurrency protection is hard; original delivery order is NOT), the three consequences of never acking at receipt, and the default-closed transport opt-in with Kafka named as out of scope and why. The docs say out loud that a rolling deploy produces duplicate deliveries bounded by prefetch depth. Handlers should already be idempotent under at-least-once, but the number is not zero and a user should not discover that in production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a_global_partitioned_topology_rejects_native_ack asserted the message contained "companion local queue". That phrase appears in the Inline rejection at GlobalPartitionedMessageTopology.cs:55 as well, so the assertion could not tell the two branches apart: it would have passed even if NativeAck fell through to the Inline guard and the mode-specific rejection had been lost entirely. Verified by simulating exactly that -- making the NativeAck branch throw the Inline message. The old assertion passes; the new one fails. Now asserts the mode name (which the Inline message cannot contain) and that the two branches produce different messages. Both are semantic rather than prose, so a rewording of either message -- as #4041 is doing to this one right now -- cannot silently hollow the test out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 23, 2026
This was referenced Aug 24, 2026
erdtsieck
pushed a commit
to erdtsieck/wolverine
that referenced
this pull request
Aug 24, 2026
…guides JasperFx#4040 documented the mode in listeners.md -- the settings matrix column and the mode's own section. Two other pages talk about exactly the trade-off this mode changes and did not know it existed. * RabbitMQ performance guide: NativeAck joins "Choosing the endpoint mode", and the prefetch section explains that for this mode the prefetch window IS the back pressure and is also the bound on how many deliveries a dying node hands back -- so raising it trades throughput for redelivery. * Partitioning guide: a tip on "Partitioned Processing at any Endpoint" laying out what each mode costs for partitioned work -- Durable pays the database, Buffered acks before the handler and loses on crash, NativeAck pays neither, and Inline cannot do it at all and now throws rather than ignoring it. Says plainly that no non-durable mode promises original delivery order under failure. 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.
Completes #3708 (steps 3-6 of the build order; step 1-2 landed in #4032). Also closes #4011.
Fills the empty cell in the mode matrix: Buffered's throughput and partitioning with Inline's no-loss guarantee, and no database involvement.
InlineListenerCountonlyNativeAckMaximumParallelMessagesBufferedInMemoryMaximumParallelMessagesDurableMaximumParallelMessagesNativeAckReceiverBufferedReceiver's execution block with InlineReceiver's channel wiring. The single line that defines the mode is the absence of a
_completeBlock.PostAsync()on receipt — the delivery stays unacknowledged until the pipeline settles it.Three decisions worth review attention:
DeserializeFirst(pipeline, runtime, channelSource)overload rather than one boundIChannelCallback. WithListenerCount > 1the receiver is shared across listeners, so a single bound channel would ack the wrong delivery. There is a test for it.ISupportNativeScheduling/ISupportDeadLetterQueueon the receiver. The channel is the listener, so those are the listener's native capabilities, exactly as forInlineReceiver. Implementing them here would route a terminal through in-memory state a crash would lose — which is the guarantee this mode exists to provide._completeBlocksurvives, but only for already-expired deliveries. Something no handler will ever process still has to be acked so the broker stops redelivering it.RabbitMQ opts in
RabbitMqQueueis the first transport to overridesupportsNativeAck, and it qualifies on both counts the mode requires:multiple: false, which is load-bearing here — undermultiple: truean out-of-order completion would silently ack every lower delivery tag still in flight, turning the no-loss guarantee into silent loss.DeliveredOn/CanSettleplumbing refuses to settle against a stale or closed channel.Queues only, not exchanges or topics — native acks are a listening concept.
ProcessInParallelWithNativeAcks()Pulled forward from step 6 into step 5 out of necessity: without it the opt-in is unreachable. There is no fluent
Mode()on listener configuration, and the first cut of the RabbitMQ test cast throughIRabbitMqQueueto setModedirectly — which documents a missing API rather than a working one.It throws eagerly for a local queue, matching
ProcessInline()'s guard from #4027. The lazily-sourcedLocalQueueConfiguration(Func<LocalQueue>)path that guard cannot see is already covered:LocalQueuedoes not overridesupportsNativeAck, so theModesetter rejects it with a real message.GH-4011
ListeningAgent.EnqueueDirectlyAsyncis a type-switch ending in a throwing fallthrough. Every receiver that existed before this PR had a branch, so the fallthrough was unreachable;NativeAckReceivermade it reachable. This is the durability agent's re-entry point — DLQ replay (#1942), scheduled-message firing — so without a branch, a NativeAck endpoint in an application that also has persistence configured threw on any replay targeting it. Durable outbox for sending plus native acks on one flooding listener is a legitimate combination, not an exotic one.The test was red-baselined against the exact
InvalidOperationException("There is no active, local queue ...")before the branch was added, and asserts the receiver really is aNativeAckReceiverso it cannot pass vacuously through theBufferedReceiverbranch.Repaying the GH-3712 debt
#4017 landed a bootstrap validator and a per-mode settings matrix written for a three-mode world; both were wrong the moment a fourth mode existed.
Inline+PartitionProcessingByGroupId()now points atProcessInParallelWithNativeAcks()— the mode that exists for exactly the combination the user was reaching for — instead of only offering the two modes that mean giving up native acks. The CoreTests assertion pinning that text is updated; it is precisely the debt GH-3712: validate or warn on silently-ignored listener configuration combos #4017 predicted.ServerlessEndpointsMustBeInlinePolicynow warns before downgrading a NativeAck endpoint. Coercion is correct for Serverless, but partitioned processing is a guarantee the user asked for, and dropping it in silence is the exact failure mode Validate or warn on silently-ignored listener configuration combos (Inline + partitioning/parallelism) #3712 exists to prevent.NativeAckcolumn and a section for the mode.The guarantee, stated exactly
Protection against intra-group concurrency is the hard guarantee. Strict sequential processing in original delivery order is not. A failed or redelivered message re-enters its lane later, never concurrently. That is the honest contract for native-ack retry semantics on every broker; users needing strict order under failure keep the durable inbox. This is in the docs in those words.
Also documented rather than left to be discovered: a rolling deploy produces duplicate deliveries bounded by prefetch depth. Graceful drain processes what it can within the drain timeout; whatever it cannot is never settled and gets redelivered. Handlers should already be idempotent under at-least-once, but the number is not zero. Quantifying it under chaos is #3713.
Verification
dotnet build wolverine.slnx -c Release -f net9.0— clean, 0 warnings, 0 errorsBufferedInMemorythose five are gone.That redelivery test initially "failed" by proving itself — its redelivered messages showed up in a different test's assertion, because the tracking bag is static and all three tests shared one queue while the broker outlives the host. Each test now owns its queue.
Not in this PR
#3715 (NativeAck for other transports — ASB, SQS, Pub/Sub, NATS JetStream, Redis Streams and Pulsar are all natural fits), #3713 (the 5-node chaos reproduction), and #3709, whose premise needs re-scoping against what actually landed here.
🤖 Generated with Claude Code