Skip to content

Commit 879cd7a

Browse files
authored
Merge pull request #288 from Vulthil/fix/rabbitmq-dispatch-scoping
fix: scope dispatch plans per queue and serialize RPC replies through the consumer channel gate
2 parents 520dc9c + 5d866a8 commit 879cd7a

17 files changed

Lines changed: 556 additions & 139 deletions

docs/articles/messaging.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -859,8 +859,13 @@ internal sealed class MyHandlerFactory : IMessageHandlerFactory<Dispatch>
859859
```
860860

861861
Let `MessageExecutionRegistry<THandler>` assemble the per-message-type plans from the configured
862-
queues — it handles URN keying, polymorphic fan-out, deduplication, request-consumer uniqueness,
863-
and partition attachment:
862+
queues — it handles URN keying, polymorphic fan-out, deduplication, request-consumer uniqueness
863+
(at most one per message type per queue), and partition attachment. Plans are keyed by URN within
864+
a registry instance, so queues registered into the same instance accumulate their handlers into
865+
one plan per message type. Register every queue in a single instance only when your transport
866+
dispatches each produced message exactly once (as the in-memory test harness does); a transport
867+
that receives a distinct delivery per queue (as the RabbitMQ transport does) must build one
868+
registry per queue so a delivery dispatches only the handlers its own queue registered:
864869

865870
```csharp
866871
var registry = new MessageExecutionRegistry<Dispatch>(provider, new MyHandlerFactory());
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using RabbitMQ.Client;
2+
3+
namespace Vulthil.Messaging.RabbitMq.Consumers;
4+
5+
/// <summary>
6+
/// Publishes a message on a consumer worker's shared channel, serialized through the worker's channel gate
7+
/// alongside the delivery settles (acks, nacks, retry republishes and fault publishes) — RabbitMQ channels must
8+
/// not be used concurrently. Handed to <see cref="MessageHandler.DispatchAsync"/> in place of the raw channel so
9+
/// a request/reply handler's response publish cannot interleave frames with a parallel settle on the same channel.
10+
/// </summary>
11+
/// <param name="exchange">The exchange to publish to; an empty string targets the broker's default exchange.</param>
12+
/// <param name="routingKey">The routing key (the destination queue name when using the default exchange).</param>
13+
/// <param name="mandatory">Whether the broker should return the message when it cannot be routed.</param>
14+
/// <param name="basicProperties">The AMQP properties to publish with.</param>
15+
/// <param name="body">The serialized message body.</param>
16+
/// <returns>A task that completes once the publish has been written to the channel.</returns>
17+
internal delegate Task GatedPublisher(string exchange, string routingKey, bool mandatory, BasicProperties basicProperties, ReadOnlyMemory<byte> body);

src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using RabbitMQ.Client;
21
using RabbitMQ.Client.Events;
32
using Vulthil.Messaging.Queues;
43
using Vulthil.Messaging.Transport;
@@ -20,10 +19,11 @@ internal sealed record MessageHandler
2019
public required HandlerKind Kind { get; init; }
2120

2221
/// <summary>
23-
/// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response on the supplied channel.
24-
/// Consumer-kind handlers ignore the channel parameter. The envelope is non-null on the standard receive path
25-
/// (Vulthil-produced messages) and null on the bare-JSON compat path (external producers); the closure picks the
26-
/// appropriate <c>MessageContextFactory.CreateContext</c> overload.
22+
/// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response through
23+
/// the supplied <see cref="GatedPublisher"/>, which serializes the write with the worker's other channel
24+
/// operations. Consumer-kind handlers ignore the publisher parameter. The envelope is non-null on the standard
25+
/// receive path (Vulthil-produced messages) and null on the bare-JSON compat path (external producers); the
26+
/// closure picks the appropriate <c>MessageContextFactory.CreateContext</c> overload.
2727
/// </summary>
28-
public required Func<IServiceProvider, object, BasicDeliverEventArgs, MessageEnvelope?, IChannel, CancellationToken, Task> DispatchAsync { get; init; }
28+
public required Func<IServiceProvider, object, BasicDeliverEventArgs, MessageEnvelope?, GatedPublisher, CancellationToken, Task> DispatchAsync { get; init; }
2929
}

src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static MessageHandler ForRequestConsumer<TConsumer, TRequest, TResponse>(
5454
{
5555
RetryPolicy = retryPolicy,
5656
Kind = HandlerKind.RequestConsumer,
57-
DispatchAsync = async (sp, message, ea, envelope, channel, ct) =>
57+
DispatchAsync = async (sp, message, ea, envelope, publishAsync, ct) =>
5858
{
5959
var consumer = sp.GetRequiredService<TConsumer>();
6060
var publisher = sp.GetRequiredService<IPublisher>();
@@ -98,7 +98,7 @@ public static MessageHandler ForRequestConsumer<TConsumer, TRequest, TResponse>(
9898
reply = BuildFaultReply(exception.Message, exception.GetType().FullName ?? "Unknown", exception.StackTrace, jsonOptions, ea, envelope);
9999
}
100100

101-
await SendResponseAsync(ea, reply, channel, jsonOptions);
101+
await SendResponseAsync(ea, reply, publishAsync, jsonOptions);
102102
}
103103
};
104104

@@ -131,7 +131,7 @@ private static MessageEnvelope BuildFaultReply(
131131
return BuildReply(RpcFault.UrnUri, JsonSerializer.SerializeToElement(fault, jsonOptions), ea, requestEnvelope);
132132
}
133133

134-
private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageEnvelope reply, IChannel channel, JsonSerializerOptions jsonOptions)
134+
private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageEnvelope reply, GatedPublisher publishAsync, JsonSerializerOptions jsonOptions)
135135
{
136136
if (string.IsNullOrEmpty(ea.BasicProperties.ReplyTo))
137137
{
@@ -146,6 +146,6 @@ private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageEnv
146146
ContentType = RabbitMqConstants.ContentType,
147147
};
148148

149-
await channel.BasicPublishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, body);
149+
await publishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, body);
150150
}
151151
}

src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ namespace Vulthil.Messaging.RabbitMq.Consumers;
77
/// RabbitMQ adapter over <see cref="MessageExecutionRegistry{THandler}"/>. Delegates plan assembly to the core
88
/// registry and decorates each resolved plan with a <see cref="RabbitMqPlan"/> carrying the AMQP partition key
99
/// extractor. Wrappers are built once per plan (keyed by URN) during registration, so delivery-time lookups stay
10-
/// read-only.
10+
/// read-only. The bus builds one cache per queue, so a worker's plan lookups resolve only the handlers its own
11+
/// queue registered even when several queues consume the same message type.
1112
/// </summary>
1213
internal sealed class MessageTypeCache
1314
{

src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ internal sealed class RabbitMqConsumerWorker : IAsyncDisposable
2626
private readonly bool _partitioned;
2727
private readonly ConcurrentDictionary<ulong, Task> _inFlight = new();
2828

29-
// RabbitMQ channels must not be used concurrently. On a partitioned queue the lanes complete in parallel
30-
// and each settles its delivery (ack/nack/retry-republish/fault) on this shared channel, so every channel
31-
// write is serialized through this gate to avoid interleaved frames. Message processing stays parallel;
32-
// only the brief settle/publish frames are serialized.
29+
// RabbitMQ channels must not be used concurrently. Concurrent dispatch (and, on a partitioned queue, the
30+
// lanes completing in parallel) settles deliveries (ack/nack/retry-republish/fault) and publishes RPC
31+
// replies on this shared channel, so every channel write is serialized through this gate to avoid
32+
// interleaved frames. Message processing stays parallel; only the brief settle/publish frames are serialized.
3333
private readonly SemaphoreSlim _channelGate = new(1, 1);
34+
private readonly GatedPublisher _gatedPublisher;
3435

3536
private JsonSerializerOptions _jsonOptions => _messageConfigurationProvider.JsonSerializerOptions;
3637

@@ -54,6 +55,7 @@ public RabbitMqConsumerWorker(
5455
_logger = logger;
5556
_channelIndex = channelIndex;
5657
_partitioned = partitioned;
58+
_gatedPublisher = PublishThroughGateAsync;
5759
}
5860

5961
/// <summary>
@@ -77,6 +79,14 @@ private async Task OnChannelAsync(Func<ValueTask> channelOperation)
7779

7880
private Task NackAsync(BasicDeliverEventArgs ea) => OnChannelAsync(() => _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false));
7981

82+
/// <summary>
83+
/// Publishes on the shared consumer channel through the channel gate. Handed to handler dispatch as the
84+
/// <see cref="GatedPublisher"/> so a request/reply response serializes with the acks, nacks and republishes
85+
/// settling other deliveries on this channel.
86+
/// </summary>
87+
private Task PublishThroughGateAsync(string exchange, string routingKey, bool mandatory, BasicProperties basicProperties, ReadOnlyMemory<byte> body)
88+
=> OnChannelAsync(() => _channel.BasicPublishAsync(exchange, routingKey, mandatory, basicProperties, body));
89+
8090
public async Task StartAsync(CancellationToken cancellationToken)
8191
{
8292
var consumer = new AsyncEventingBasicConsumer(_channel);
@@ -280,7 +290,7 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st
280290

281291
props.Expiration = delay.TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
282292

283-
await OnChannelAsync(() => _channel.BasicPublishAsync($"{_queueDefinition.Name}.Retry", ea.RoutingKey, true, props, ea.Body));
293+
await PublishThroughGateAsync($"{_queueDefinition.Name}.Retry", ea.RoutingKey, true, props, ea.Body);
284294
await AckAsync(ea);
285295
return;
286296
}
@@ -323,7 +333,7 @@ private async Task PublishFaultAsync(Exception ex, BasicDeliverEventArgs ea, IDi
323333
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
324334
};
325335

326-
await OnChannelAsync(() => _channel.BasicPublishAsync(exchange, routingKey, false, faultProps, faultBody));
336+
await PublishThroughGateAsync(exchange, routingKey, false, faultProps, faultBody);
327337
}
328338
catch (Exception faultEx)
329339
{
@@ -429,7 +439,7 @@ private async Task DispatchHandlersAsync(RabbitMqPlan plan, object message, Basi
429439

430440
foreach (var handler in plan.Handlers)
431441
{
432-
await handler.DispatchAsync(scope.ServiceProvider, message, ea, envelope, _channel, ea.CancellationToken);
442+
await handler.DispatchAsync(scope.ServiceProvider, message, ea, envelope, _gatedPublisher, ea.CancellationToken);
433443
}
434444
}
435445

src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,21 @@ public RabbitMqBus(
3737
/// <remarks>
3838
/// A failed start disposes any partially-created consumer workers and rethrows without faulting the readiness
3939
/// signal, so the hosting consumer service can retry a transient failure (such as a broker that is still coming
40-
/// up) while <see cref="RabbitMqBusStartupStatus.Ready"/> stays pending until a start attempt succeeds. A fresh
41-
/// type cache is built for every attempt (rather than kept as instance state) so a retry after a partial failure
42-
/// re-registers queues against an empty registry instead of appending to handlers (or request-consumer
43-
/// bookkeeping) left over from the attempt that failed.
40+
/// up) while <see cref="RabbitMqBusStartupStatus.Ready"/> stays pending until a start attempt succeeds. Fresh
41+
/// per-queue type caches are built for every attempt (rather than kept as instance state) so a retry after a
42+
/// partial failure re-registers each queue against an empty registry instead of appending to handlers (or
43+
/// request-consumer bookkeeping) left over from the attempt that failed.
4444
/// </remarks>
4545
public async Task StartAsync(CancellationToken cancellationToken = default)
4646
{
47-
var typeCache = new MessageTypeCache(_messageConfigurationProvider);
48-
4947
try
5048
{
5149
var queues = _messageConfigurationProvider.QueueDefinitions;
5250
MessagingLog.BusStarting(_logger, queues.Count);
5351

54-
await SetupTopology(queues, typeCache, cancellationToken);
55-
await StartConsumersAsync(queues, typeCache, cancellationToken);
52+
var typeCaches = BuildTypeCaches(queues);
53+
await SetupTopology(queues, typeCaches, cancellationToken);
54+
await StartConsumersAsync(queues, typeCaches, cancellationToken);
5655

5756
MessagingLog.BusStarted(_logger);
5857
_startupStatus.MarkStarted();
@@ -64,6 +63,25 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
6463
}
6564
}
6665

66+
/// <summary>
67+
/// Builds one <see cref="MessageTypeCache"/> per queue, each registering only that queue. Several queues may
68+
/// consume the same message type, and the broker delivers a distinct copy to each of them; scoping the plan
69+
/// cache to its queue keeps a delivery from also dispatching the consumers every other queue registered for
70+
/// that type.
71+
/// </summary>
72+
internal Dictionary<string, MessageTypeCache> BuildTypeCaches(IReadOnlyCollection<QueueDefinition> queues)
73+
{
74+
var typeCaches = new Dictionary<string, MessageTypeCache>(StringComparer.OrdinalIgnoreCase);
75+
foreach (var queue in queues)
76+
{
77+
var typeCache = new MessageTypeCache(_messageConfigurationProvider);
78+
typeCache.RegisterQueue(queue);
79+
typeCaches[queue.Name] = typeCache;
80+
}
81+
82+
return typeCaches;
83+
}
84+
6785
public Task WaitUntilReadyAsync(CancellationToken cancellationToken = default) =>
6886
_startupStatus.Ready.WaitAsync(cancellationToken);
6987

@@ -72,14 +90,13 @@ public Task WaitUntilReadyAsync(CancellationToken cancellationToken = default) =
7290
/// partition lanes in arrival order; parallelism comes from the lanes (bounded by <c>PrefetchCount</c>) rather
7391
/// than concurrent dispatch.
7492
/// </remarks>
75-
private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queues, MessageTypeCache typeCache, CancellationToken cancellationToken)
93+
private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queues, Dictionary<string, MessageTypeCache> typeCaches, CancellationToken cancellationToken)
7694
{
7795
var workerLogger = _loggerFactory.CreateLogger<RabbitMqConsumerWorker>();
7896

7997
foreach (var queue in queues)
8098
{
81-
typeCache.RegisterQueue(queue);
82-
99+
var typeCache = typeCaches[queue.Name];
83100
var partitioned = typeCache.IsQueuePartitioned(queue);
84101
var channelCount = partitioned ? 1 : queue.ChannelCount;
85102
var dispatchConcurrency = partitioned ? (ushort)1 : queue.ConcurrencyLimit;
@@ -116,7 +133,7 @@ private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queu
116133
/// here by convention with the faulted message's URN as the routing key, so a subscriber binds its queue with
117134
/// <c>"#"</c> to observe all faults or with a specific URN to filter by faulted message type.
118135
/// </remarks>
119-
private async Task SetupTopology(IReadOnlyCollection<QueueDefinition> queues, MessageTypeCache typeCache, CancellationToken cancellationToken)
136+
private async Task SetupTopology(IReadOnlyCollection<QueueDefinition> queues, Dictionary<string, MessageTypeCache> typeCaches, CancellationToken cancellationToken)
120137
{
121138
using var channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
122139

@@ -129,7 +146,7 @@ await channel.ExchangeDeclareAsync(
129146

130147
foreach (var queue in queues)
131148
{
132-
await SetupQueueTopology(queue, typeCache, channel, cancellationToken);
149+
await SetupQueueTopology(queue, typeCaches[queue.Name], channel, cancellationToken);
133150
MessagingLog.QueueDeclared(_logger, queue.Name, queue.Registrations.Count);
134151
}
135152
}

0 commit comments

Comments
 (0)