Skip to content

GH-3708: EndpointMode.NativeAck — partitioned, parallel processing with native broker acks - #4040

Merged
jeremydmiller merged 5 commits into
mainfrom
gh-3708/native-ack-receiver
Aug 23, 2026
Merged

GH-3708: EndpointMode.NativeAck — partitioned, parallel processing with native broker acks#4040
jeremydmiller merged 5 commits into
mainfrom
gh-3708/native-ack-receiver

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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.

Broker ack timing Loss window Parallelism Group partitioning DB cost
Inline after handler success none ListenerCount only none none
NativeAck after handler success none MaximumParallelMessages ✔️ none
BufferedInMemory at receipt, before the handler crash loses buffered messages MaximumParallelMessages ✔️ none
Durable after the inbox insert none MaximumParallelMessages ✔️ inbox insert + mark-handled
opts.ListenToRabbitQueue("webhooks")
    .ProcessInParallelWithNativeAcks()
    .PartitionProcessingByGroupId(PartitionSlots.Five)
    .MaximumParallelMessages(10);

NativeAckReceiver

BufferedReceiver'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:

  • The channel is resolved per envelope, via GH-4010: resolve the deserialize stage's channel callback per envelope #4013's DeserializeFirst(pipeline, runtime, channelSource) overload rather than one bound IChannelCallback. With ListenerCount > 1 the receiver is shared across listeners, so a single bound channel would ack the wrong delivery. There is a test for it.
  • No ISupportNativeScheduling / ISupportDeadLetterQueue on the receiver. The channel is the listener, so those are the listener's native capabilities, exactly as for InlineReceiver. Implementing them here would route a terminal through in-memory state a crash would lose — which is the guarantee this mode exists to provide.
  • _completeBlock survives, 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

RabbitMqQueue is the first transport to override supportsNativeAck, and it qualifies on both counts the mode requires:

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 through IRabbitMqQueue to set Mode directly — 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-sourced LocalQueueConfiguration(Func<LocalQueue>) path that guard cannot see is already covered: LocalQueue does not override supportsNativeAck, so the Mode setter rejects it with a real message.

GH-4011

ListeningAgent.EnqueueDirectlyAsync is a type-switch ending in a throwing fallthrough. Every receiver that existed before this PR had a branch, so the fallthrough was unreachable; NativeAckReceiver made 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 a NativeAckReceiver so it cannot pass vacuously through the BufferedReceiver branch.

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.

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 errors
  • CoreTests — 2539 total, 0 failed, 2 skipped
  • RabbitMQ tests run against a real broker, including the one that matters: a node dies mid-flight having acked nothing, and a fresh node picks up all five messages on redelivery. Under BufferedInMemory those 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

jeremydmiller and others added 4 commits August 23, 2026 15:02
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>
@jeremydmiller
jeremydmiller merged commit 0149554 into main Aug 23, 2026
39 checks passed
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>
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.

ListeningAgent.EnqueueDirectlyAsync has no branch for a native-ack receiver

1 participant