Skip to content

fix(rabbitmq): bind handler queue to every handled-message exchange under FromHandlerType#2697

Merged
jeremydmiller merged 1 commit intomainfrom
issue-2681-rabbitmq-handler-type-multi-bindings
May 7, 2026
Merged

fix(rabbitmq): bind handler queue to every handled-message exchange under FromHandlerType#2697
jeremydmiller merged 1 commit intomainfrom
issue-2681-rabbitmq-handler-type-multi-bindings

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Closes #2681.

Summary

  • Under UseConventionalRouting(NamingSource.FromHandlerType), when a single handler type accepts two or more message types (typical for sagas or any aggregate-style handler), the handler-named queue ended up bound to only the FIRST message type's exchange. Messages published to the remaining exchanges silently never reached the handler queue.
  • Root cause: RabbitMqMessageRoutingConvention.ApplyListenerRoutingDefaults short-circuited on queue.HasBindings. The base MessageRoutingConvention.DiscoverListeners calls this method once per (handlerType, messageType) pair under FromHandlerType naming — so the second pass saw the binding the first pass had just added and silently skipped its own.
  • The original guard's intent was protecting user-configured custom bindings (set up in the ConfigureListeners lambda, which runs before ApplyListenerRoutingDefaults) from being double-bound on top of by the convention. Narrowing the guard to per-exchange dedup preserves that protected case while letting the multi-message-type case bind correctly.

What changed

src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqMessageRoutingConvention.cs:23-50:

// before
if (!queue.HasBindings)
{
    var identifier = _identifierForSender(messageType);
    if (identifier is null) return;
    var name = transport.MaybeCorrectName(identifier);
    var exchange = transport.Exchanges[name];
    queue.BindExchange(exchange.Name, exchange.Name);
}

// after
var identifier = _identifierForSender(messageType);
if (identifier is null) return;
var name = transport.MaybeCorrectName(identifier);
var exchange = transport.Exchanges[name];

if (queue.Bindings().Any(b => b.ExchangeName == exchange.Name))
{
    return; // already bound to THIS exchange — don't stack
}

queue.BindExchange(exchange.Name, exchange.Name);

The new guard is strictly weaker than the old one — every case the old guard silenced still gets silenced. The bug case is the only behavioural delta.

Tests

  • Bug_2681_handler_type_naming_binds_all_exchanges.handler_queue_is_bound_to_every_handled_message_exchange — primary reproducer. A single MultiMessageHandler handles MultiMessageFoo + MultiMessageBar; pre-fix the handler queue was bound only to whichever message-type exchange got processed second. Verified red on main (queue bound only to MultiMessageBar); green with the fix.
  • Bug_2681_handler_type_naming_binds_all_exchanges.both_message_exchanges_were_created — sanity check that both exchanges actually get registered (the bug was purely on the queue-binding side).
  • Bug_2681_custom_bindings_are_not_double_added.user_custom_binding_is_not_double_added_by_the_convention — guard that the new per-exchange dedup still suppresses the default convention binding when a user has pre-configured a custom binding to the same exchange via ConfigureListeners + BindToExchange<TMessage>(...).

Test plan

  • Reproducer red on main, green with fix
  • Full ConventionalRouting suite — 31/31 passed on net9.0

🤖 Generated with Claude Code

…nder FromHandlerType

When `UseConventionalRouting(NamingSource.FromHandlerType)` was used
and a single handler type accepted two or more message types
(typical for sagas or any aggregate-style handler), the
handler-named queue ended up bound to only the FIRST message type's
exchange. The remaining message types were silently dropped on the
floor — messages published to those exchanges never reached the
handler queue.

Root cause: `RabbitMqMessageRoutingConvention.ApplyListenerRoutingDefaults`
short-circuited on `queue.HasBindings`. The base
`MessageRoutingConvention.DiscoverListeners` calls this method once
per (handlerType, messageType) pair under FromHandlerType naming, so
the second pass saw the binding the first pass had just added and
skipped its own binding entirely.

The original guard's intent was protecting user-configured custom
bindings (set up via `RabbitMqConventionalListenerConfiguration` in
the `ConfigureListeners` lambda, which runs *before* this method) —
the convention shouldn't double-bind on top of an explicit user
binding. The fix narrows the guard to per-exchange dedup so the
multi-message-type case binds correctly while the protected case
still suppresses cleanly.

Closes #2681.

Tests:

- `Bug_2681_handler_type_naming_binds_all_exchanges.handler_queue_is_bound_to_every_handled_message_exchange` —
  primary reproducer. A single `MultiMessageHandler` handles
  `MultiMessageFoo` + `MultiMessageBar`; pre-fix the handler queue
  was bound only to whichever message-type exchange got processed
  second. Verified red against main, green with the fix.
- `Bug_2681_handler_type_naming_binds_all_exchanges.both_message_exchanges_were_created` —
  sanity check that both exchanges actually get registered (the bug
  was purely on the queue-binding side).
- `Bug_2681_custom_bindings_are_not_double_added.user_custom_binding_is_not_double_added_by_the_convention` —
  guard that the new per-exchange dedup still suppresses the
  default convention binding when a user has pre-configured a
  custom binding to the same exchange.
- Full `ConventionalRouting` suite: 31/31 passed on net9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <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.

RabbitMQ conventional routing with FromHandlerType naming doesn't bind to all exchanges

1 participant