Skip to content

GH-4135: report Received exactly once per receipt - #4141

Merged
jeremydmiller merged 2 commits into
mainfrom
gh-4135/single-received-per-envelope
Aug 26, 2026
Merged

GH-4135: report Received exactly once per receipt#4141
jeremydmiller merged 2 commits into
mainfrom
gh-4135/single-received-per-envelope

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4135.

The bug

A listener with PartitionProcessingByGroupId recorded two Received events for one envelope while executing it exactly once. Reproduced verbatim from the issue's repro.

Two branches of HandlerPipeline report Received, and they are meant to be mutually exclusive:

  • TryDeserializeEnvelope reports it from a finally block
  • executeAsync reports it directly when the envelope already carries a Message

Partitioned processing goes through both. ShardedExecutionBlock.DeserializeFirst deserializes up front — it has to, because receiver-side grouping rules (ByMessage, ByPropertyNamed, inferred grouping) resolve the group id off the deserialized message — and the envelope then reaches executeAsync already carrying a Message, taking the second branch. That matches the reporter's guess exactly.

It is bigger than the reported symptom

The issue reports that Received.SingleMessage<T>() fails against a system behaving correctly, with a message that reads as duplicate delivery and sends you hunting a routing bug that is not there — it cost the reporter a full bisect.

But IMessageTracker.Received is not only tracking. The same call:

  • increments _receivedCounter
  • posts RecordReceived to the CritterWatch accumulator
  • logs the receipt

So every partitioned listener has been double-counting received messages in production metrics and logging each receipt twice. The red test was the visible half.

The fix

A WasTrackedAsReceived guard on the envelope, rather than removing either call site — both are correct for the path they serve, and the deserialization one has to stay in its finally so a malformed payload is still reported as received before it is moved to the error queue. GlobalPartitionedInterceptor takes the same pre-deserialize route and is fixed by the same guard.

Verification

  • New duplicate_received_with_partitioning_4135 reproduces the issue's repro against a real broker. Confirmed red before, green after.
  • CoreTests 2648 passed / 0 failed.
  • Full RabbitMQ suite 514/515 — the one failure (drain_wait_for_prefetch) passes 2/2 in isolation on this branch and 2/2 on origin/main, and every full-suite run tonight produced exactly one, always different, failure under local load. Re-running the full suite to confirm non-reproduction.
  • dotnet build wolverine.slnx -c Release -f net9.0 clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HxbBpN6PmRB5CMSSTdGNr3

jeremydmiller and others added 2 commits August 26, 2026 05:42
A listener with PartitionProcessingByGroupId recorded TWO Received events for one
envelope while executing it exactly once.

Two branches of HandlerPipeline report Received, and they are meant to be mutually
exclusive: TryDeserializeEnvelope reports it from a finally block, and executeAsync
reports it directly when the envelope already carries a Message. Partitioned
processing goes through BOTH. ShardedExecutionBlock.DeserializeFirst deserializes
up front -- it has to, because receiver-side grouping rules resolve the group id
off the deserialized message -- and the envelope then reaches executeAsync already
carrying a Message, taking the second branch.

The reported symptom is that Received.SingleMessage<T>() fails against a system
behaving correctly, with a message that reads as duplicate delivery and sends you
looking for a routing bug that is not there. But IMessageTracker.Received is not
only tracking: the same call increments the received counter, posts RecordReceived
to the CritterWatch accumulator, and logs the receipt. So every partitioned
listener has been double-counting received messages in production metrics and
logging each receipt twice.

Fixed with a WasTrackedAsReceived guard on the envelope rather than by removing
either call site: both are correct for the path they serve, and the deserialization
one must stay in its finally so a malformed payload is still reported as received
before it is moved to the error queue. GlobalPartitionedInterceptor takes the same
pre-deserialize route and is fixed by the same guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxbBpN6PmRB5CMSSTdGNr3
Dump every tracked record for the probe message -- event type, sequence, service,
node and envelope id -- into the assertion message. One full-suite run produced a
Received count of 2 that did not reproduce in six isolated runs or in two further
full-suite runs, and with no record dump there was nothing to tell a genuine
broker redelivery apart from the defect this test exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxbBpN6PmRB5CMSSTdGNr3
@jeremydmiller
jeremydmiller merged commit da5cbcc into main Aug 26, 2026
39 checks passed
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.

PartitionProcessingByGroupId records two Received tracking events per envelope, so Received.SingleMessage<T>() fails on correct behaviour

1 participant