Skip to content

GH-3710: opt-in in-memory idempotency guard for non-durable endpoints - #4055

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3710/idempotency-guard
Aug 24, 2026
Merged

GH-3710: opt-in in-memory idempotency guard for non-durable endpoints#4055
jeremydmiller merged 1 commit into
mainfrom
gh-3710/idempotency-guard

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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's EndpointMode.NativeAck makes 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 IIncomingIdempotencyGuard plus a generational default implementation, opted into per endpoint:

opts.ListenToRabbitQueue("webhooks")
    .ProcessInParallelWithNativeAcks()
    .PartitionProcessingByGroupId(PartitionSlots.Five)
    .WithInMemoryIdempotency(window: 5.Minutes(), maxTracked: 100_000); // both optional, these are the defaults

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.handleDuplicateIncomingEnvelope does when the inbox INSERT hits the primary key. Logged at Debug rather than Error — 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 NativeAck exists for. Eviction is generational: two hash sets, both consulted on lookup, rotated on whichever comes first of window / 2 elapsing or maxTracked / 2 ids landing in the current generation. Hard ceiling of maxTracked, 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 least window / 2, at most window, 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, which MessageContext.CompleteAsync sets 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 existing opts.Policies.AllListeners(x => x.WithInMemoryIdempotency()).

Honors DurabilitySettings.MessageIdentity — under IdAndDestination the 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. Durable builds no guard and warns at startup (the inbox already does this, across restarts and across nodes). Local queues throw NotSupportedException where you call it, with a lazily-resolved fallback warning in ListenerConfigurationValidator.

JetStream and friends. Not implemented here, per the issue. The docs point at broker-side dedup where it exists — JetStream's Nats-Msg-Id duplicate window, SQS FIFO's MessageDeduplicationId, Pub/Sub's deduplication-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 the NativeAck and Buffered sections 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 the NativeAck shutdown bullet and the Buffered section.

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; both MessageIdentity modes; and a concurrency hammer asserting exactly one winner per id across 8 threads.
  • in_memory_idempotency_guard_on_receivers — redelivery acked and never executed under NativeAck and under BufferedInMemory; 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.
  • Full CoreTests: 2566 total, 0 failed, 2 skipped (pre-existing).

🤖 Generated with Claude Code

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
jeremydmiller merged commit 04488e5 into main Aug 24, 2026
39 checks passed
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.
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.
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.

Opt-in in-memory idempotency guard for non-durable endpoints

1 participant