GH-3710: opt-in in-memory idempotency guard for non-durable endpoints - #4055
Merged
Conversation
The durable inbox deduplicates incoming messages on the primary key of wolverine_incoming. Every non-durable mode had nothing at all, and GH-3708's NativeAck mode makes that gap matter: it is at-least-once by design and produces a burst of redeliveries -- bounded by the broker's prefetch depth -- on every rolling deploy, because whatever the drain cannot finish is simply never settled. Adds IIncomingIdempotencyGuard plus a generational default implementation, opt in per endpoint with IListenerConfiguration.WithInMemoryIdempotency(window, maxTracked). Default OFF; when it is off the receivers pay one null check per delivery and nothing else. - Eviction is generational: two hash sets, both consulted on lookup, rotated on whichever comes first of window/2 elapsing or maxTracked/2 ids in the current generation. Memory is hard-bounded at maxTracked with no per-entry timestamps and no LRU bookkeeping, which is what makes it safe on the flood workload NativeAck exists for. In-flight ids are tracked separately -- so a duplicate arriving while the original is still executing is dropped rather than queued to run again -- and generationally too, purely as a leak stop. - Honors DurabilitySettings.MessageIdentity: under IdAndDestination the key includes the destination, so the Modular Monolith shape still works. - Wired into NativeAckReceiver, BufferedReceiver and InlineReceiver. A duplicate is settled with the broker and dropped without reaching the handler, mirroring DurableReceiver.handleDuplicateIncomingEnvelope, logged at Debug rather than Error because redelivery is expected here. Only a delivery the broker will not undo (Envelope.HasBeenAcked -- handler success or a native dead-letter move) is remembered; anything nacked, requeued or deferred releases the id, since remembering it would suppress its own retry. - The guard is owned by the Endpoint rather than the receiver, so it survives the receiver rebuilds -- listener restart, back-pressure recovery -- that are themselves a source of redeliveries. - Inert on Durable (the inbox already does this, better) and refused on local queues; both say so rather than being silently ignored. Docs get a new "In-Memory Idempotency Guard" section in the listeners guide, next to the modes it applies to, stating plainly that the guard is per-process and in memory: a restart forgets everything and a sibling node never knew. The promise is at-least-once with best-effort dedup, not exactly-once. Also points at broker-side dedup (JetStream Nats-Msg-Id, SQS FIFO, Pub/Sub) where it exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeremydmiller
added a commit
that referenced
this pull request
Aug 24, 2026
Conflict in ListenerConfigurationValidator.Validate: both sides add an independent check ahead of the Inline-only early-out. GH-4047 adds the transport-facing validateModeConfiguration() hook (Pulsar's cumulative-ack and hot-tail rejections); GH-3710, merged as #4055, adds the in-memory idempotency guard warnings for Durable endpoints and local queues. Both are kept. They are unrelated rules that happen to want the same position in the method, not competing versions of one rule. The transport hook runs first so a Fatal transport constraint is reported ahead of advisory warnings. Rebase was not an option: the repository ruleset blocks non-fast-forward pushes, so main is merged into the branch and pushed normally. Verified after resolution: dotnet build wolverine.slnx -c Release -f net9.0 clean, CoreTests 2579 total / 0 failed.
This was referenced Aug 24, 2026
jeremydmiller
added a commit
that referenced
this pull request
Aug 24, 2026
Two conflicts, both from main's GH-3710 in-memory idempotency guard (#4055) and GH-4047's validateModeConfiguration hook (#4057) landing in the same files this branch extends. Endpoint.cs -- both sides add a distinct protected virtual member under a shared doc-comment opener. Kept both: holdsExpiringLease (this branch) and validateModeConfiguration (main) answer different questions and neither supersedes the other. NativeAckReceiver.cs -- two conflicts. 1. Constructor: both sides assign a new field. Kept both. 2. The failure path, which needed thought rather than concatenation. Main releases the idempotency id before nacking, so a redelivery is allowed to run. This branch suppresses the nack entirely when the lease was lost mid-execution, because the broker is already redelivering and every defer path settles-then-republishes, so nacking would add a second copy. Resolved by releasing the id UNCONDITIONALLY and only then gating the nack. The release is if anything more necessary in the lease-lost case: the broker is redelivering regardless, and a remembered id would suppress the very attempt meant to replace this one. Sequencing them the other way -- release only on the nack path -- would have turned a lost lease into a lost message. Verified after resolution: dotnet build wolverine.slnx -c Release -f net9.0 clean, CoreTests 2604 total / 0 failed.
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.
Closes #3710
What
The durable inbox deduplicates incoming messages on the primary key of
wolverine_incoming. Every non-durable mode had nothing at all — verified in the issue, and still true. #3708'sEndpointMode.NativeAckmakes that gap matter: it is at-least-once by design and produces a burst of duplicate deliveries — bounded by the broker's prefetch depth — on every rolling deploy, because whatever graceful drain cannot finish within the drain timeout is simply never settled.This adds
IIncomingIdempotencyGuardplus a generational default implementation, opted into per endpoint:Default off. When it is off, the receivers pay one null check per delivery and nothing else.
Design notes
Where it sits. Same semantics as the durable path, minus the database: a duplicate is settled with the broker and dropped without reaching the handler, exactly like
DurableReceiver.handleDuplicateIncomingEnvelopedoes when the inbox INSERT hits the primary key. Logged atDebugrather thanError— in a mode that never settles at receipt, redelivery is expected operational noise.Bounding. An unbounded set of seen ids would be a leak on precisely the flood workload
NativeAckexists for. Eviction is generational: two hash sets, both consulted on lookup, rotated on whichever comes first ofwindow / 2elapsing ormaxTracked / 2ids landing in the current generation. Hard ceiling ofmaxTracked, no per-entry timestamps, no LRU bookkeeping, O(1) everywhere. The price — stated in the docs — is that an id's lifetime is a range (at leastwindow / 2, at mostwindow, less under a flood) rather than a number.In-flight vs processed. Tracked separately, so a duplicate arriving while the original is still executing is dropped rather than queued to run again. In-flight ids are generational too, purely as a leak stop.
Success vs failure. Only a delivery that reached a terminal the broker will not undo is remembered —
Envelope.HasBeenAcked, whichMessageContext.CompleteAsyncsets on handler success and which a native dead-letter move also sets. Anything nacked, requeued, or deferred during a drain releases the id, because remembering it would suppress its own retry and turn a failure into a lost message.Per-endpoint, and it survives receiver rebuilds. The guard is owned by the
Endpoint, not the receiver: receivers are rebuilt on listener restart and back-pressure recovery, and those are exactly the moments that produce redeliveries. Global opt-in is the existingopts.Policies.AllListeners(x => x.WithInMemoryIdempotency()).Honors
DurabilitySettings.MessageIdentity— underIdAndDestinationthe key includes the destination, so the Modular Monolith shape (one process, same id at two listeners) still works.Refused or inert where it is meaningless.
Durablebuilds no guard and warns at startup (the inbox already does this, across restarts and across nodes). Local queues throwNotSupportedExceptionwhere you call it, with a lazily-resolved fallback warning inListenerConfigurationValidator.JetStream and friends. Not implemented here, per the issue. The docs point at broker-side dedup where it exists — JetStream's
Nats-Msg-Idduplicate window, SQS FIFO'sMessageDeduplicationId, Pub/Sub'sdeduplication-id— as strictly better than an in-process guard, and note that this guard is chiefly for RabbitMQ classic/quorum queues, which have nothing.The honest limit, and where it is documented
New "In-Memory Idempotency Guard" section in
docs/guide/messaging/listeners.md, sitting between theNativeAckandBufferedsections so a user choosing a non-durable mode reads it in place, plus a row in the "which settings apply in which mode" matrix and pointers from theNativeAckshutdown bullet and theBufferedsection.It says out loud, in a warning block, that an in-memory guard does not survive a restart: the deploy that causes the redelivery burst also empties the guard on the node that starts up, a sibling node never knew, and a slot failover hands the queue to a node starting empty. The promise is at-least-once with best-effort dedup, not exactly-once; hard dedup means the durable inbox. It also notes that a duplicate slipping through is a cost/liveness issue rather than a correctness one for #3708's ordering guarantee — a duplicate still runs in its group's sequential lane.
Tests
generational_idempotency_guard— rotation across both generations against an injected clock (survives one rotation, forgotten after two); bounded memory under 50,000 unique ids with a ceiling of 100; in-flight ids bounded even when nothing ever completes; in-flight duplicate rejected; released id re-runnable; bothMessageIdentitymodes; and a concurrency hammer asserting exactly one winner per id across 8 threads.in_memory_idempotency_guard_on_receivers— redelivery acked and never executed underNativeAckand underBufferedInMemory; both modes run the duplicate again with the guard off; concurrent duplicate dropped while the original is parked in the handler; and a deferred delivery not remembered, re-run through a rebuilt receiver on the same endpoint.listener_configuration_validation— opt-in only, tuning flows through to the guard, the defaults, the durable warning, and the local-queue refusal.Broker-level redelivery integration testing on RabbitMQ (forcing a redelivery by closing the connection with unacked deliveries) is not in this PR.
Verification
dotnet build wolverine.slnx -c Release -f net9.0— clean, 0 warnings.🤖 Generated with Claude Code