Skip to content

Commit 4b8833a

Browse files
authored
Merge pull request #284 from Vulthil/fix/messaging-transport-lifecycle
Fix RabbitMQ transport startup and shutdown lifecycle bugs
2 parents 7a040b4 + 03ecdec commit 4b8833a

7 files changed

Lines changed: 281 additions & 23 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs
164164
}
165165
catch (Exception ex)
166166
{
167+
if (ex is OperationCanceledException && ea.CancellationToken.IsCancellationRequested)
168+
{
169+
return;
170+
}
171+
167172
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
168173
activity?.AddException(ex);
169174
await HandleFailureAsync(ex, ea, prepared.DiagnosticTypeName);

src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ internal sealed class RabbitMqBus : ITransport, IAsyncDisposable
1616
private readonly RabbitMqBusStartupStatus _startupStatus;
1717
private readonly ILogger<RabbitMqBus> _logger;
1818
private readonly ILoggerFactory _loggerFactory;
19-
private readonly MessageTypeCache _typeCache;
2019
private readonly List<RabbitMqConsumerWorker> _workers = [];
2120

2221
public RabbitMqBus(
@@ -33,23 +32,27 @@ public RabbitMqBus(
3332
_startupStatus = startupStatus;
3433
_logger = logger;
3534
_loggerFactory = loggerFactory;
36-
_typeCache = new MessageTypeCache(messageConfigurationProvider);
3735
}
3836

3937
/// <remarks>
4038
/// A failed start disposes any partially-created consumer workers and rethrows without faulting the readiness
4139
/// signal, so the hosting consumer service can retry a transient failure (such as a broker that is still coming
42-
/// up) while <see cref="RabbitMqBusStartupStatus.Ready"/> stays pending until a start attempt succeeds.
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.
4344
/// </remarks>
4445
public async Task StartAsync(CancellationToken cancellationToken = default)
4546
{
47+
var typeCache = new MessageTypeCache(_messageConfigurationProvider);
48+
4649
try
4750
{
4851
var queues = _messageConfigurationProvider.QueueDefinitions;
4952
MessagingLog.BusStarting(_logger, queues.Count);
5053

51-
await SetupTopology(queues, cancellationToken);
52-
await StartConsumersAsync(queues, cancellationToken);
54+
await SetupTopology(queues, typeCache, cancellationToken);
55+
await StartConsumersAsync(queues, typeCache, cancellationToken);
5356

5457
MessagingLog.BusStarted(_logger);
5558
_startupStatus.MarkStarted();
@@ -69,15 +72,15 @@ public Task WaitUntilReadyAsync(CancellationToken cancellationToken = default) =
6972
/// partition lanes in arrival order; parallelism comes from the lanes (bounded by <c>PrefetchCount</c>) rather
7073
/// than concurrent dispatch.
7174
/// </remarks>
72-
private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queues, CancellationToken cancellationToken)
75+
private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queues, MessageTypeCache typeCache, CancellationToken cancellationToken)
7376
{
7477
var workerLogger = _loggerFactory.CreateLogger<RabbitMqConsumerWorker>();
7578

7679
foreach (var queue in queues)
7780
{
78-
_typeCache.RegisterQueue(queue);
81+
typeCache.RegisterQueue(queue);
7982

80-
var partitioned = _typeCache.IsQueuePartitioned(queue);
83+
var partitioned = typeCache.IsQueuePartitioned(queue);
8184
var channelCount = partitioned ? 1 : queue.ChannelCount;
8285
var dispatchConcurrency = partitioned ? (ushort)1 : queue.ConcurrencyLimit;
8386

@@ -96,7 +99,7 @@ private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queu
9699
_serviceScopeFactory,
97100
queue,
98101
channel,
99-
_typeCache,
102+
typeCache,
100103
_messageConfigurationProvider,
101104
workerLogger,
102105
i,
@@ -113,7 +116,7 @@ private async Task StartConsumersAsync(IReadOnlyCollection<QueueDefinition> queu
113116
/// here by convention with the faulted message's URN as the routing key, so a subscriber binds its queue with
114117
/// <c>"#"</c> to observe all faults or with a specific URN to filter by faulted message type.
115118
/// </remarks>
116-
private async Task SetupTopology(IReadOnlyCollection<QueueDefinition> queues, CancellationToken cancellationToken)
119+
private async Task SetupTopology(IReadOnlyCollection<QueueDefinition> queues, MessageTypeCache typeCache, CancellationToken cancellationToken)
117120
{
118121
using var channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
119122

@@ -126,7 +129,7 @@ await channel.ExchangeDeclareAsync(
126129

127130
foreach (var queue in queues)
128131
{
129-
await SetupQueueTopology(queue, channel, cancellationToken);
132+
await SetupQueueTopology(queue, typeCache, channel, cancellationToken);
130133
MessagingLog.QueueDeclared(_logger, queue.Name, queue.Registrations.Count);
131134
}
132135
}
@@ -136,7 +139,7 @@ await channel.ExchangeDeclareAsync(
136139
/// active (others stand by for failover) so ordering survives across load-balanced consumers. Partitioned queues
137140
/// opt in automatically; any queue can request it explicitly.
138141
/// </remarks>
139-
private async Task SetupQueueTopology(QueueDefinition queue, IChannel channel, CancellationToken cancellationToken)
142+
private async Task SetupQueueTopology(QueueDefinition queue, MessageTypeCache typeCache, IChannel channel, CancellationToken cancellationToken)
140143
{
141144
await channel.ExchangeDeclareAsync(
142145
exchange: queue.Name,
@@ -152,7 +155,7 @@ await channel.ExchangeDeclareAsync(
152155
args.Add("x-queue-type", "quorum");
153156
}
154157

155-
if (queue.SingleActiveConsumer || _typeCache.IsQueuePartitioned(queue))
158+
if (queue.SingleActiveConsumer || typeCache.IsQueuePartitioned(queue))
156159
{
157160
args.Add("x-single-active-consumer", true);
158161
}

src/Vulthil.Messaging/ConsumerHostedService.cs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.Extensions.DependencyInjection;
12
using Microsoft.Extensions.Hosting;
23
using Microsoft.Extensions.Logging;
34

@@ -12,25 +13,42 @@ namespace Vulthil.Messaging;
1213
/// that is still coming up is a transient infrastructure condition rather than a reason to fault the host. The host's
1314
/// <c>BackgroundServiceExceptionBehavior</c> is left untouched, so a genuine fault raised once the transport is running
1415
/// still surfaces and stops the host as usual.
16+
/// <para>
17+
/// The generic host swallows exceptions thrown from inside <see cref="BackgroundService.ExecuteAsync"/> — even ones
18+
/// thrown synchronously before the first await — logging them and stopping the host without ever propagating them out
19+
/// of <c>IHost.StartAsync</c>. A hosted service constructor throwing is not subject to that: it fails DI resolution
20+
/// directly, so <c>IHost.StartAsync</c> throws it as-is. The clear "no transport registered" error therefore still
21+
/// needs to fire from the constructor to surface synchronously; it does so via <see cref="IServiceProviderIsService"/>,
22+
/// which answers whether <see cref="ITransport"/> is registered without constructing one. The transport instance
23+
/// itself is resolved lazily, inside the retry loop, so a transport whose construction depends on an unreachable
24+
/// resource (such as a broker connection) is treated as a retryable startup failure instead.
25+
/// </para>
1526
/// </remarks>
1627
internal sealed class ConsumerHostedService : BackgroundService
1728
{
29+
private const string NoTransportRegisteredMessage =
30+
"No messaging transport is registered. Call a transport extension such as .UseRabbitMq(...) " +
31+
"inside AddMessaging(...) (or .UseTestHarness() in a test).";
32+
1833
private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(1);
1934
private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(30);
2035

21-
private readonly ITransport _transport;
36+
private readonly IServiceProvider _serviceProvider;
2237
private readonly TimeProvider _timeProvider;
2338
private readonly ILogger<ConsumerHostedService> _logger;
2439

2540
public ConsumerHostedService(
26-
IEnumerable<ITransport> transports,
41+
IServiceProvider serviceProvider,
42+
IServiceProviderIsService serviceProviderIsService,
2743
TimeProvider timeProvider,
2844
ILogger<ConsumerHostedService> logger)
2945
{
30-
_transport = transports.LastOrDefault()
31-
?? throw new InvalidOperationException(
32-
"No messaging transport is registered. Call a transport extension such as .UseRabbitMq(...) " +
33-
"inside AddMessaging(...) (or .UseTestHarness() in a test).");
46+
if (!serviceProviderIsService.IsService(typeof(ITransport)))
47+
{
48+
throw new InvalidOperationException(NoTransportRegisteredMessage);
49+
}
50+
51+
_serviceProvider = serviceProvider;
3452
_timeProvider = timeProvider;
3553
_logger = logger;
3654
}
@@ -43,7 +61,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
4361
{
4462
try
4563
{
46-
await _transport.StartAsync(stoppingToken);
64+
await ResolveTransport().StartAsync(stoppingToken);
4765
return;
4866
}
4967
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -64,6 +82,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
6482
}
6583
}
6684

85+
private ITransport ResolveTransport() =>
86+
_serviceProvider.GetServices<ITransport>().LastOrDefault()
87+
?? throw new InvalidOperationException(NoTransportRegisteredMessage);
88+
6789
private async Task<bool> TryDelayAsync(TimeSpan delay, CancellationToken stoppingToken)
6890
{
6991
try
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
namespace Vulthil.Messaging.RabbitMq.Tests;
4+
5+
/// <summary>
6+
/// Adapts an <see cref="IServiceProvider"/> — typically an <c>AutoMocker</c> instance, which itself implements
7+
/// <see cref="IServiceProvider"/> — into an <see cref="IServiceScopeFactory"/> whose scopes resolve directly
8+
/// from it. Lets a test exercise a transport's real create-scope-per-delivery code path without standing up a
9+
/// full DI container.
10+
/// </summary>
11+
internal sealed class AutoMockerServiceScopeFactory(IServiceProvider serviceProvider) : IServiceScopeFactory
12+
{
13+
public IServiceScope CreateScope() => new PassthroughServiceScope(serviceProvider);
14+
15+
private sealed class PassthroughServiceScope(IServiceProvider serviceProvider) : IServiceScope
16+
{
17+
public IServiceProvider ServiceProvider { get; } = serviceProvider;
18+
19+
public void Dispose()
20+
{
21+
}
22+
}
23+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
using RabbitMQ.Client;
6+
using RabbitMQ.Client.Events;
7+
using Vulthil.Messaging.Abstractions.Consumers;
8+
using Vulthil.Messaging.RabbitMq.HealthChecks;
9+
using Vulthil.xUnit;
10+
11+
namespace Vulthil.Messaging.RabbitMq.Tests;
12+
13+
public sealed class RabbitMqBusStartupRetryTests : BaseUnitTestCase
14+
{
15+
private readonly Mock<IChannel> _channel = new();
16+
private readonly Lazy<RabbitMqBus> _lazyTarget;
17+
private IAsyncBasicConsumer? _capturedConsumer;
18+
19+
private RabbitMqBus Target => _lazyTarget.Value;
20+
21+
public RabbitMqBusStartupRetryTests()
22+
{
23+
_channel
24+
.Setup(c => c.BasicConsumeAsync(
25+
It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>(),
26+
It.IsAny<IDictionary<string, object?>>(), It.IsAny<IAsyncBasicConsumer>(), It.IsAny<CancellationToken>()))
27+
.Callback((string _, bool _, string _, bool _, bool _, IDictionary<string, object?> _, IAsyncBasicConsumer consumer, CancellationToken _) =>
28+
_capturedConsumer = consumer)
29+
.ReturnsAsync("consumer-tag");
30+
31+
Use(new RabbitMqBusStartupStatus());
32+
Use<ILoggerFactory>(NullLoggerFactory.Instance);
33+
Use<ILogger<RabbitMqBus>>(NullLogger<RabbitMqBus>.Instance);
34+
Use<IServiceScopeFactory>(new AutoMockerServiceScopeFactory(AutoMocker));
35+
36+
_lazyTarget = new(CreateInstance<RabbitMqBus>);
37+
}
38+
39+
protected override ValueTask Dispose() => _lazyTarget.IsValueCreated ? Target.DisposeAsync() : base.Dispose();
40+
41+
[Fact]
42+
public async Task StartAsyncFailThenRetrySucceedsWithoutDoublingHandlersOrRejectingTheRpcConsumer()
43+
{
44+
// Arrange
45+
var handlerHits = 0;
46+
Use(new RecordingConsumer(() => handlerHits++));
47+
Use<IEnumerable<IConsumeFilter<TestMessage>>>([]);
48+
49+
var provider = TestProviders.Build(cfg => cfg.ConfigureQueue("orders", queue =>
50+
{
51+
queue.AddConsumer<RecordingConsumer>();
52+
queue.AddRequestConsumer<RecordingRequestConsumer>();
53+
}));
54+
Use(provider);
55+
56+
GetMock<IConnection>()
57+
.SetupSequence(c => c.CreateChannelAsync(It.IsAny<CreateChannelOptions?>(), It.IsAny<CancellationToken>()))
58+
.ReturnsAsync(_channel.Object)
59+
.ThrowsAsync(new InvalidOperationException("connection blip"))
60+
.ReturnsAsync(_channel.Object)
61+
.ReturnsAsync(_channel.Object);
62+
63+
// Act
64+
await Should.ThrowAsync<InvalidOperationException>(() => Target.StartAsync(CancellationToken));
65+
await Target.StartAsync(CancellationToken);
66+
67+
await _capturedConsumer!.HandleBasicDeliverAsync(
68+
"consumer-tag",
69+
1,
70+
false,
71+
"orders",
72+
"orders",
73+
new BasicProperties { Type = typeof(TestMessage).FullName, Headers = new Dictionary<string, object?>() },
74+
JsonSerializer.SerializeToUtf8Bytes(new TestMessage("payload")),
75+
CancellationToken.None);
76+
77+
// Assert
78+
handlerHits.ShouldBe(1);
79+
}
80+
81+
internal sealed record TestMessage(string Value);
82+
internal sealed record TestRequest(string Query);
83+
internal sealed record TestResponse(string Result);
84+
85+
private sealed class RecordingConsumer(Action onConsume) : IConsumer<TestMessage>
86+
{
87+
public Task ConsumeAsync(IMessageContext<TestMessage> messageContext, CancellationToken cancellationToken = default)
88+
{
89+
onConsume();
90+
return Task.CompletedTask;
91+
}
92+
}
93+
94+
private sealed class RecordingRequestConsumer : IRequestConsumer<TestRequest, TestResponse>
95+
{
96+
public Task<TestResponse> ConsumeAsync(IMessageContext<TestRequest> messageContext, CancellationToken cancellationToken = default)
97+
=> Task.FromResult(new TestResponse($"Processed: {messageContext.Message.Query}"));
98+
}
99+
}

0 commit comments

Comments
 (0)