Skip to content

fix: RMQ.Sync producer selects confirms and subscribes handlers once per channel - #4576

Merged
iancooper merged 1 commit into
masterfrom
bugfix/4567-rmq-sync-confirm-resubscribe
Oct 10, 2026
Merged

iancooper merged 1 commit into
masterfrom
bugfix/4567-rmq-sync-confirm-resubscribe

Conversation

@iancooper

Copy link
Copy Markdown
Member

Symptom

The RMQ.Sync RmqMessageProducer subscribed its publisher-confirm handlers to BasicAcks/BasicNacks, and called ConfirmSelect(), on every Send. On a long-lived channel:

  • After N sends, each event held N copies of the handler. Each += copies the whole list, so the cost was O(N²).
  • Every ack frame invoked the settle handler N times. On a multiple=true frame, each of those calls scanned and sorted the pending confirmations.
  • Every send paid an extra synchronous confirm.select round trip.

Correctness was not affected. Since #4562, each confirmation is claimed with TryRemove before it is raised, so none were lost or repeated.

Confirmed root cause

The handler subscriptions and the ConfirmSelect() call in SendWithDelay had no guard for whether the channel was already set up. RmqMessageGateway.ConnectToBroker returns early while the channel is open, so every send reused the same IModel and added another subscription and another confirm.select. Nothing ever unsubscribed the handlers: not the IOException path, and not Dispose.

The cause was proven by tracing the code; the full trace is in bugfixes/0054-rmq-sync-confirm-resubscribe/bugfix.md.

Fix

  • The producer tracks the channel it set up (_confirmChannel) and compares by reference, not by null. The Sync gateway never sets Channel back to null: it replaces a closed channel with a new instance, including when the pooled connection is dropped with no IOException. On a new channel, the producer unsubscribes from the old one, calls ConfirmSelect(), then subscribes both handlers.
  • The handlers are removed from the channel that was actually subscribed, in two places:
    • in the IOException catch, before the connection is reset;
    • in Dispose's finally, before base.Dispose disposes the channel.
  • Dispose calls WaitForConfirms only when the current channel is the one in confirm mode, instead of relying on a _confirmsSelected flag that stayed true forever once set.

The issue suggested mirroring RMQ.Async. That is only partly right. RMQ.Async guards on Channel is not null, and its Channel is only set back to null on dispose, so after a reset it never subscribes the replacement channel. Copying that check would have broken confirms on Sync after any reset.

A first version detached the handlers after the confirmation callbacks finished. That broke When_gateway_cleanup_throws_should_release_its_connection: when WaitForConfirms threw, the detach was skipped, and a second Dispose then removed handlers from a disposed model, which throws ObjectDisposedException. Detaching in the finally fixes this.

Tests

The tests run against a live RabbitMQ broker and use the CoalescingConfirmsRmqChannel test double from #4562. The double now counts subscribers and ConfirmSelect calls, and records unsubscribes.

  • When_sending_several_messages_on_one_channel_should_select_confirms_once: was red (ConfirmSelect called 3 times, not 1).
  • When_the_channel_resets_should_move_confirm_handlers_to_the_new_channel: was red (the failed channel kept 2 handlers).
  • When_the_shared_connection_is_dropped_should_select_confirms_on_the_new_channel: covers silent replacement. It passed as soon as it was written, and stays as a guard against a null-style check.

Full Paramore.Brighter.RMQ.Sync.Tests run on net9.0 and net10.0: 200 passed and 1 skipped per framework. The only failures are 9 mTLS acceptance tests per framework, which need client certificates (./tests/generate-test-certs.sh) that were not present locally.

Out of scope: follow-ups found during diagnosis

  1. Sync, replaced channel: when the channel is replaced without an IOException, its delivery tags restart at 1 and can collide with confirmations still pending from the old channel. The TryAdd failure is ignored, so a new message's confirmation can be lost, and the new channel's ack can confirm the old message instead. RMQ.Async has the same gap.
  2. RMQ.Async: it never subscribes a replacement channel, so after any reset it raises no more publish confirmations.
  3. Sync, scheduled messages: on the scheduler path, a pending confirmation is recorded before the producer decides not to publish, so the next real publish collides with it.
  4. Sync, other publish errors: a failure other than IOException leaves its pending confirmation behind. Async cleans these up in a finally.

Fixes #4567

🤖 Generated with Claude Code

https://claude.ai/code/session_01SpjnqGWbARr3Z3ofGc6C2x

…per channel

Root cause: RmqMessageProducer subscribed OnPublishSucceeded/OnPublishFailed to
BasicAcks/BasicNacks and called ConfirmSelect() on every Send, with no
per-channel guard, so a long-lived channel accumulated one handler pair and one
confirm.select round trip per Send.

The producer now tracks the channel it set up and compares by reference, since
the Sync gateway replaces a closed channel rather than nulling it. It detaches
from that channel on IOException and in Dispose (before the base disposes the
channel), and only waits for confirms when the current channel is in confirm mode.

Fixes #4567

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpjnqGWbARr3Z3ofGc6C2x
@github-actions github-actions Bot added the Bug label Oct 10, 2026
@iancooper iancooper added 3 - Done .NET Pull requests that update .net code V10.X labels Oct 10, 2026
@iancooper
iancooper merged commit 2c5ae24 into master Oct 10, 2026
34 of 35 checks passed
@iancooper
iancooper deleted the bugfix/4567-rmq-sync-confirm-resubscribe branch October 10, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 - Done Bug .NET Pull requests that update .net code V10.X

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RMQ.Sync producer subscribes confirm handlers and calls ConfirmSelect on every Send

1 participant