diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/MessageBusOutboxWorker.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/MessageBusOutboxWorker.cs
index a68f1c90deb..9e8ae4a6ce9 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/MessageBusOutboxWorker.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/MessageBusOutboxWorker.cs
@@ -12,6 +12,7 @@ internal sealed class PostgresMessageBusOutboxWorker(PostgresOutboxProcessor pro
{
private readonly object _lock = new();
private ContinuousTask? _task;
+ private Task _processing = Task.CompletedTask;
///
/// Starts the outbox processing background task. This call is idempotent: invoking it again
@@ -28,14 +29,15 @@ public Task StartAsync(CancellationToken cancellationToken)
return Task.CompletedTask;
}
- _task = new ContinuousTask(processor.ProcessAsync);
+ _task = new ContinuousTask(ProcessAsync);
}
return Task.CompletedTask;
}
///
- /// Stops the outbox processing background task and waits for it to complete gracefully.
+ /// Stops the outbox processing background task and waits until the messages that were being dispatched
+ /// are either removed from the outbox or released.
///
/// A token that signals when shutdown should be forced.
public async Task StopAsync(CancellationToken cancellationToken)
@@ -54,5 +56,18 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
await task.DisposeAsync();
+
+ // The continuous task stops waiting once it is cancelled, while the processor still removes the
+ // messages a transport has already accepted. The wait ends early when the shutdown is forced.
+ await Volatile.Read(ref _processing)
+ .WaitAsync(cancellationToken)
+ .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ }
+
+ private Task ProcessAsync(CancellationToken cancellationToken)
+ {
+ var processing = processor.ProcessAsync(cancellationToken);
+ Volatile.Write(ref _processing, processing);
+ return processing;
}
}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
index 40a7e4314c9..900b5fccf31 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxProcessor.cs
@@ -1,8 +1,10 @@
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ObjectPool;
using Mocha.Middlewares;
+using Mocha.Threading;
using Npgsql;
namespace Mocha.Outbox;
@@ -13,6 +15,12 @@ namespace Mocha.Outbox;
///
public sealed class PostgresOutboxProcessor
{
+ private static readonly TimeSpan s_pauseDelay = TimeSpan.FromMilliseconds(100);
+ private static readonly TimeSpan s_maxPauseDelay = TimeSpan.FromSeconds(10);
+
+ // Stays below the longest delay a timer accepts, which is about 24.8 days.
+ private static readonly TimeSpan s_maxPollingInterval = TimeSpan.FromDays(24);
+
private readonly ILogger _logger;
private readonly IServiceProvider _services;
private readonly IMessagingRuntime _runtime;
@@ -66,59 +74,103 @@ internal PostgresOutboxProcessor(
/// A token that signals when the processor should stop.
public async Task ProcessAsync(CancellationToken cancellationToken)
{
- while (!cancellationToken.IsCancellationRequested)
+ var workers = new Task[_options.MaxConcurrentMessages];
+ for (var i = 0; i < workers.Length; i++)
{
- using var joinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ workers[i] = ProcessWorkerAsync(cancellationToken);
+ }
+
+ await Task.WhenAll(workers);
+ }
+
+ private async Task ProcessWorkerAsync(CancellationToken cancellationToken)
+ {
+ using var worker = new WorkerState(_options.DispatchTimeout, cancellationToken);
+ Task? signaled = null;
+ while (!cancellationToken.IsCancellationRequested)
+ {
try
{
- var signaled = _signal.WaitAsync(joinedCts.Token);
+ signaled ??= _signal.WaitAsync(cancellationToken);
- var nextPollingInterval = await ProcessPendingAsync(cancellationToken);
+ var pass = await ProcessPendingAsync(worker, cancellationToken);
- if (nextPollingInterval is not null)
+ if (pass.IsPaused)
{
- _logger.OutboxProcessorSleeping(nextPollingInterval.Value);
+ // The released message is claimed again after the pause, with or without a signal.
+ await worker.Backoff.WaitAsync(cancellationToken);
+ continue;
+ }
+
+ if (pass.NextPollingInterval is { } nextPollingInterval)
+ {
+ _logger.OutboxProcessorSleeping(nextPollingInterval);
- await Task.WhenAny(Task.Delay(nextPollingInterval.Value, cancellationToken), signaled);
+ try
+ {
+ await signaled.WaitAsync(nextPollingInterval, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ continue;
+ }
}
else
{
await signaled;
}
+
+ signaled = null;
}
- catch (OperationCanceledException)
+ catch (Exception) when (cancellationToken.IsCancellationRequested)
{
- joinedCts.TryCancel();
+ // The host is stopping.
}
- catch
+ catch (OperationCanceledException) when (signaled is { IsCanceled: true })
{
- joinedCts.TryCancel();
- throw;
+ signaled = null;
+ }
+ catch (Exception ex)
+ {
+ // A failure outside of a dispatch, such as a lost database connection, pauses this worker only.
+ if (signaled is { IsFaulted: true })
+ {
+ signaled = null;
+ }
+
+ _logger.UnexpectedErrorWhileProcessingOutboxEvent(ex);
+ await worker.Backoff.WaitAsync(cancellationToken);
}
}
}
- private async ValueTask ProcessPendingAsync(CancellationToken cancellationToken)
+ private async ValueTask ProcessPendingAsync(WorkerState worker, CancellationToken cancellationToken)
{
await using var scope = _services.CreateAsyncScope();
await using var connection = _options.CreateConnection(scope.ServiceProvider);
await connection.OpenAsync(cancellationToken);
- while (true)
+ while (!cancellationToken.IsCancellationRequested)
{
using var activity = OpenTelemetry.Source.StartActivity(
"Process Message Outbox",
ActivityKind.Consumer,
new ActivityContext());
- if (!await ProcessEventAsync(connection, cancellationToken))
+ switch (await ProcessEventAsync(connection, worker, cancellationToken))
{
- return await GetNextPollingIntervalAsync(connection, cancellationToken);
- }
+ case ClaimResult.Empty:
+ return new PassResult(
+ IsPaused: false,
+ await GetNextPollingIntervalAsync(connection, cancellationToken));
- cancellationToken.ThrowIfCancellationRequested();
+ case ClaimResult.Released:
+ return new PassResult(IsPaused: true, NextPollingInterval: null);
+ }
}
+
+ return default;
}
private async Task GetNextPollingIntervalAsync(
@@ -131,123 +183,214 @@ public async Task ProcessAsync(CancellationToken cancellationToken)
var result = await command.ExecuteScalarAsync(cancellationToken);
- return result is not null and not DBNull ? ((DateTime)result - DateTimeOffset.UtcNow).Duration() : null;
+ if (result is null or DBNull)
+ {
+ return null;
+ }
+
+ var interval = ((DateTime)result - DateTimeOffset.UtcNow).Duration();
+
+ return interval < s_maxPollingInterval ? interval : s_maxPollingInterval;
}
- private async Task ProcessEventAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
+ private async Task ProcessEventAsync(
+ NpgsqlConnection connection,
+ WorkerState worker,
+ CancellationToken cancellationToken)
{
using var activity = OpenTelemetry.Source.StartActivity(
"Process Message Outbox Event",
ActivityKind.Producer,
new ActivityContext());
+ // An exception before the commit rolls the claim back when the transaction is disposed.
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
- try
+ Guid id;
+ int timesSent;
+ DispatchOutcome outcome;
+
+ // Lock an event for processing and increment TimesSent in case of failure
+ await using (var command = connection.CreateCommand())
{
- // Lock an event for processing and increment TimesSent in case of failure
- await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = _options.Queries.ProcessEvent;
await command.PrepareAsync(cancellationToken);
- try
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+
+ if (!await reader.ReadAsync(cancellationToken))
{
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ return ClaimResult.Empty;
+ }
- if (await reader.ReadAsync(cancellationToken))
- {
- var id = reader.GetGuid(0);
- var envelope = Serializer.ReadMessageEnvelopeSafe(reader, 1, _logger);
- var timesSent = reader.GetInt32(2);
+ id = reader.GetGuid(0);
+ var envelope = Serializer.ReadMessageEnvelopeSafe(reader, 1, _logger);
+ timesSent = reader.GetInt32(2);
- // A message this instance cannot read or route stays in the outbox and is
- // retried after its backoff.
- if (envelope is null)
- {
- _logger.CouldNotDeserializeMessageBody(id);
- LogIfExhausted(id, timesSent);
+ if (TryResolve(id, envelope, out var messageType, out var endpoint, out outcome))
+ {
+ var dispatchToken = worker.StartDispatch();
- return true;
- }
+ try
+ {
+ await SendAsync(envelope, endpoint, messageType, dispatchToken);
- if (envelope.DeliverBy is { } deadline && deadline <= _timeProvider.GetUtcNow())
- {
- await reader.CloseAsync();
- await DeleteEventAsync(connection, id, transaction, cancellationToken);
- _logger.OutboxMessageExpired(id);
- return true;
- }
+ worker.Backoff.Reset();
+ outcome = DispatchOutcome.Sent;
+ }
+ catch (Exception ex)
+ {
+ outcome = OnDispatchFailed(ex, id, envelope, timesSent, dispatchToken, cancellationToken);
+ }
+ finally
+ {
+ worker.EndDispatch();
+ }
+ }
+ }
- var messageType = GetMessageType(envelope.MessageType);
+ switch (outcome)
+ {
+ case DispatchOutcome.Sent:
+ // The transport has the message, so its row is removed whatever the token says.
+ await DeleteEventAsync(connection, id, transaction, CancellationToken.None);
+ await transaction.CommitAsync(CancellationToken.None);
+ return ClaimResult.Processed;
+
+ case DispatchOutcome.Expired:
+ await DeleteEventAsync(connection, id, transaction, cancellationToken);
+ await transaction.CommitAsync(CancellationToken.None);
+ _logger.OutboxMessageExpired(id);
+ return ClaimResult.Processed;
+
+ case DispatchOutcome.Released:
+ // The rollback restores the attempt count and the position of the row.
+ await transaction.RollbackAsync(CancellationToken.None);
+ return ClaimResult.Released;
+
+ default:
+ // The commit stores the incremented attempt count and the backoff of the row.
+ await transaction.CommitAsync(CancellationToken.None);
+ LogIfExhausted(id, timesSent);
+ return ClaimResult.Processed;
+ }
+ }
- if (messageType is null)
- {
- _logger.CouldNotResolveMessageType(id, envelope.MessageType);
- LogIfExhausted(id, timesSent);
+ ///
+ /// Resolves what a dispatch of the message needs. When the message cannot be dispatched,
+ /// says how its claim ends.
+ ///
+ private bool TryResolve(
+ Guid id,
+ [NotNullWhen(true)] MessageEnvelope? envelope,
+ [NotNullWhen(true)] out MessageType? messageType,
+ [NotNullWhen(true)] out DispatchEndpoint? endpoint,
+ out DispatchOutcome outcome)
+ {
+ messageType = null;
+ endpoint = null;
+ outcome = DispatchOutcome.Failed;
- return true;
- }
+ // A message this instance cannot read or route stays in the outbox and is
+ // retried after its backoff.
+ if (envelope is null)
+ {
+ _logger.CouldNotDeserializeMessageBody(id);
+ return false;
+ }
- var endpoint = ResolveDispatchEndpoint(envelope);
+ if (envelope.DeliverBy is { } deadline && deadline <= _timeProvider.GetUtcNow())
+ {
+ outcome = DispatchOutcome.Expired;
+ return false;
+ }
- if (endpoint is null)
- {
- _logger.CouldNotDetermineDestination(id, envelope.DestinationAddress);
- LogIfExhausted(id, timesSent);
+ messageType = GetMessageType(envelope.MessageType);
- return true;
- }
+ if (messageType is null)
+ {
+ _logger.CouldNotResolveMessageType(id, envelope.MessageType);
+ return false;
+ }
- await SendAsync(envelope, endpoint, messageType, cancellationToken);
+ endpoint = ResolveDispatchEndpoint(envelope);
- await reader.CloseAsync();
+ if (endpoint is null)
+ {
+ _logger.CouldNotDetermineDestination(id, envelope.DestinationAddress);
+ return false;
+ }
- await DeleteEventAsync(connection, id, transaction, cancellationToken);
+ return true;
+ }
- return true;
- }
- else
- {
- return false;
- }
- }
- finally
- {
- try
- {
- await transaction.CommitAsync(cancellationToken);
- }
- catch
- {
- // Commit failed (e.g., connection lost). Attempt rollback.
- // If commit actually succeeded server-side, the message stays
- // with times_sent incremented - safe, just causes a retry.
- try
- {
- await transaction.RollbackAsync(CancellationToken.None);
- }
- catch
- {
- /* swallow */
- }
- }
- }
+ ///
+ /// Logs why the dispatch did not complete and returns whether the attempt counts.
+ ///
+ private DispatchOutcome OnDispatchFailed(
+ Exception exception,
+ Guid id,
+ MessageEnvelope envelope,
+ int timesSent,
+ CancellationToken dispatchToken,
+ CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ _logger.OutboxDispatchCancelled(
+ exception,
+ id,
+ envelope.MessageId,
+ envelope.MessageType,
+ envelope.DestinationAddress);
+
+ return DispatchOutcome.Released;
}
- catch (Exception ex)
+
+ if (dispatchToken.IsCancellationRequested)
+ {
+ _logger.OutboxDispatchTimedOut(
+ exception,
+ id,
+ envelope.MessageId,
+ envelope.MessageType,
+ envelope.DestinationAddress,
+ _options.DispatchTimeout);
+
+ return DispatchOutcome.Released;
+ }
+
+ if (exception is TransportUnavailableException)
{
- // Log only - no RollbackAsync here (commit already handled in finally)
- _logger.UnexpectedErrorWhileProcessingOutboxEvent(ex);
- throw;
+ _logger.OutboxTransportUnavailable(
+ exception,
+ id,
+ envelope.MessageId,
+ envelope.MessageType,
+ envelope.DestinationAddress);
+
+ return DispatchOutcome.Released;
}
+
+ _logger.OutboxDispatchFailed(
+ exception,
+ id,
+ envelope.MessageId,
+ envelope.MessageType,
+ envelope.DestinationAddress,
+ timesSent,
+ _options.MaxAttempts);
+
+ return DispatchOutcome.Failed;
}
private void LogIfExhausted(Guid id, int timesSent)
{
- if (timesSent >= PostgresMessageOutboxQueries.MaxAttempts)
+ if (timesSent >= _options.MaxAttempts)
{
- _logger.OutboxMessageExhausted(id, PostgresMessageOutboxQueries.MaxAttempts);
+ _logger.OutboxMessageExhausted(id, _options.MaxAttempts);
}
}
@@ -371,6 +514,70 @@ private async Task DeleteEventAsync(
await command.ExecuteNonQueryAsync(cancellationToken);
}
+
+ private enum ClaimResult
+ {
+ // No message was eligible.
+ Empty,
+
+ // A message was claimed and its outcome was stored.
+ Processed,
+
+ // A message was claimed and handed back unchanged.
+ Released
+ }
+
+ private enum DispatchOutcome
+ {
+ // The transport accepted the message.
+ Sent,
+
+ // The delivery deadline of the message has passed.
+ Expired,
+
+ // The attempt failed and counts.
+ Failed,
+
+ // The attempt was cut short and does not count.
+ Released
+ }
+
+ private readonly record struct PassResult(bool IsPaused, TimeSpan? NextPollingInterval);
+
+ // The state one worker keeps across the messages it dispatches.
+ private sealed class WorkerState(TimeSpan dispatchTimeout, CancellationToken stopToken) : IDisposable
+ {
+ private CancellationTokenSource? _dispatchCancellation;
+
+ // The pause between two passes after a released claim or a failure of the worker.
+ public ExponentialBackoff Backoff { get; } = new(int.MaxValue, s_pauseDelay, s_maxPauseDelay);
+
+ // Returns the token of the next dispatch, which the dispatch timeout and a host stop cancel.
+ public CancellationToken StartDispatch()
+ {
+ if (dispatchTimeout == Timeout.InfiniteTimeSpan)
+ {
+ return stopToken;
+ }
+
+ _dispatchCancellation ??= CancellationTokenSource.CreateLinkedTokenSource(stopToken);
+ _dispatchCancellation.CancelAfter(dispatchTimeout);
+
+ return _dispatchCancellation.Token;
+ }
+
+ public void EndDispatch()
+ {
+ // A source that was cancelled cannot be reset, so the next dispatch gets a new one.
+ if (_dispatchCancellation is { } cancellation && !cancellation.TryReset())
+ {
+ cancellation.Dispose();
+ _dispatchCancellation = null;
+ }
+ }
+
+ public void Dispose() => _dispatchCancellation?.Dispose();
+ }
}
internal static partial class Logs
@@ -390,7 +597,7 @@ internal static partial class Logs
"Could not resolve a dispatch endpoint for the destination {DestinationAddress} of message with ID {Id}. The message will be retried.")]
public static partial void CouldNotDetermineDestination(this ILogger logger, Guid id, string? destinationAddress);
- [LoggerMessage(4, LogLevel.Information, "Outbox processor is sleeping for {NextPollingInterval}.")]
+ [LoggerMessage(4, LogLevel.Debug, "Outbox processor is sleeping for {NextPollingInterval}.")]
public static partial void OutboxProcessorSleeping(this ILogger logger, TimeSpan nextPollingInterval);
[LoggerMessage(5, LogLevel.Error, "An unexpected error occurred while processing outbox event")]
@@ -410,6 +617,57 @@ internal static partial class Logs
[LoggerMessage(8, LogLevel.Information, "Expired outbox message with ID {Id} was discarded.")]
public static partial void OutboxMessageExpired(this ILogger logger, Guid id);
+
+ [LoggerMessage(
+ 9,
+ LogLevel.Warning,
+ "The dispatch of outbox message {Id} (message ID {MessageId}, type {MessageType}) to {DestinationAddress} failed because the transport is unavailable. The attempt is not counted and the message is dispatched again.")]
+ public static partial void OutboxTransportUnavailable(
+ this ILogger logger,
+ Exception exception,
+ Guid id,
+ string? messageId,
+ string? messageType,
+ string? destinationAddress);
+
+ [LoggerMessage(
+ 10,
+ LogLevel.Warning,
+ "The dispatch of outbox message {Id} (message ID {MessageId}, type {MessageType}) to {DestinationAddress} did not complete within the dispatch timeout of {DispatchTimeout}. The attempt is not counted and the message is dispatched again.")]
+ public static partial void OutboxDispatchTimedOut(
+ this ILogger logger,
+ Exception exception,
+ Guid id,
+ string? messageId,
+ string? messageType,
+ string? destinationAddress,
+ TimeSpan dispatchTimeout);
+
+ [LoggerMessage(
+ 11,
+ LogLevel.Debug,
+ "The dispatch of outbox message {Id} (message ID {MessageId}, type {MessageType}) to {DestinationAddress} was cancelled because the host is stopping. The attempt is not counted.")]
+ public static partial void OutboxDispatchCancelled(
+ this ILogger logger,
+ Exception exception,
+ Guid id,
+ string? messageId,
+ string? messageType,
+ string? destinationAddress);
+
+ [LoggerMessage(
+ 12,
+ LogLevel.Error,
+ "The dispatch of outbox message {Id} (message ID {MessageId}, type {MessageType}) to {DestinationAddress} failed on attempt {Attempt} of {MaxAttempts}.")]
+ public static partial void OutboxDispatchFailed(
+ this ILogger logger,
+ Exception exception,
+ Guid id,
+ string? messageId,
+ string? messageType,
+ string? destinationAddress,
+ int attempt,
+ int maxAttempts);
}
file static class Serializer
@@ -427,16 +685,4 @@ file static class Serializer
return null;
}
}
-
- public static void TryCancel(this CancellationTokenSource cts)
- {
- try
- {
- cts.Cancel();
- }
- catch
- {
- // ignore
- }
- }
}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs
index 1e16b12ac83..5d2da5b2bde 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/OutboxServiceCollectionExtensions.cs
@@ -16,6 +16,9 @@ namespace Mocha.Outbox;
///
public static class OutboxServiceCollectionExtensions
{
+ // Stays below the longest delay a timer accepts, which is about 24.8 days.
+ private static readonly TimeSpan s_maxDispatchTimeout = TimeSpan.FromDays(24);
+
///
/// Registers the full Postgres outbox pipeline: table info discovery from the EF Core model,
/// the , a hosted background worker, and a scoped
@@ -43,7 +46,15 @@ public static IEntityFrameworkCoreBuilder UsePostgresOutbox(
ArgumentNullException.ThrowIfNull(configure);
var contextType = builder.ContextType;
- builder.Services.AddOptions(builder.Name).Configure(configure);
+ builder.Services.AddOptions(builder.Name)
+ .Configure(configure)
+ .Validate(o => o.MaxConcurrentMessages > 0, "MaxConcurrentMessages must be greater than zero.")
+ .Validate(
+ o => o.DispatchTimeout == Timeout.InfiniteTimeSpan
+ || (o.DispatchTimeout > TimeSpan.Zero && o.DispatchTimeout <= s_maxDispatchTimeout),
+ "DispatchTimeout must be greater than zero and at most 24 days, or Timeout.InfiniteTimeSpan.")
+ .Validate(o => o.MaxAttempts > 0, "MaxAttempts must be greater than zero.")
+ .Validate(o => o.MaxRetryDelay > TimeSpan.Zero, "MaxRetryDelay must be greater than zero.");
builder
.Services.AddOptions(builder.Name)
@@ -58,7 +69,8 @@ public static IEntityFrameworkCoreBuilder UsePostgresOutbox(
builder
.Services.AddOptions(builder.Name)
- .Configure>((options, tableInfoMonitor) =>
+ .Configure, IOptionsMonitor>(
+ (options, tableInfoMonitor, outboxOptionsMonitor) =>
{
options.CreateConnection = services =>
{
@@ -67,7 +79,11 @@ public static IEntityFrameworkCoreBuilder UsePostgresOutbox(
};
var tableInfo = tableInfoMonitor.Get(builder.Name);
- options.Queries = PostgresMessageOutboxQueries.From(tableInfo.Outbox);
+ var outboxOptions = outboxOptionsMonitor.Get(builder.Name);
+ options.Queries = PostgresMessageOutboxQueries.From(tableInfo.Outbox, outboxOptions);
+ options.MaxConcurrentMessages = outboxOptions.MaxConcurrentMessages;
+ options.DispatchTimeout = outboxOptions.DispatchTimeout;
+ options.MaxAttempts = outboxOptions.MaxAttempts;
});
builder.Services.AddSingleton(sp =>
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxOptions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxOptions.cs
index a444c38cf27..e982753dc27 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxOptions.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxOptions.cs
@@ -17,4 +17,20 @@ internal sealed class PostgresMessageOutboxOptions
/// Gets or sets a factory that creates a closed connection using services from the current scope.
///
public Func CreateConnection { get; set; } = null!;
+
+ ///
+ /// Gets or sets the maximum number of messages dispatched concurrently.
+ ///
+ public int MaxConcurrentMessages { get; set; } = 1;
+
+ ///
+ /// Gets or sets the longest time a single dispatch may take, or
+ /// for no limit.
+ ///
+ public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Gets or sets the number of failed dispatch attempts after which a message is no longer retried.
+ ///
+ public int MaxAttempts { get; set; } = 10;
}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxQueries.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxQueries.cs
index d6126db7e7a..c6e30e2d99b 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxQueries.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresMessageOutboxQueries.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Mocha.EntityFrameworkCore.Postgres;
namespace Mocha.Outbox;
@@ -8,10 +9,9 @@ namespace Mocha.Outbox;
///
internal sealed class PostgresMessageOutboxQueries
{
- ///
- /// The number of dispatch attempts after which a message is no longer retried.
- ///
- public const int MaxAttempts = 10;
+ // Two to the power of 30 seconds is about 34 years, and a larger exponent would overflow for a high
+ // attempt count.
+ private const int MaxBackoffExponent = 30;
///
/// Gets or sets the SQL statement to insert a new message envelope into the outbox table.
@@ -38,9 +38,17 @@ internal sealed class PostgresMessageOutboxQueries
/// Creates a new instance with SQL queries built from the provided table metadata.
///
/// The outbox table info containing column and table names.
+ /// The outbox options that supply the attempt limit and the retry delay cap.
/// A fully initialized instance.
- public static PostgresMessageOutboxQueries From(OutboxTableInfo t)
+ public static PostgresMessageOutboxQueries From(OutboxTableInfo t, PostgresOutboxOptions options)
{
+ var maxAttempts = options.MaxAttempts.ToString(CultureInfo.InvariantCulture);
+ var maxRetryDelay = options.MaxRetryDelay.TotalSeconds.ToString("R", CultureInfo.InvariantCulture);
+ var retryDelay =
+ $"""
+ INTERVAL '1 second' * LEAST(POWER(2, LEAST("{t.TimesSent}", {MaxBackoffExponent})), {maxRetryDelay})
+ """;
+
return new PostgresMessageOutboxQueries
{
InsertEnvelope = $"""
@@ -50,19 +58,19 @@ public static PostgresMessageOutboxQueries From(OutboxTableInfo t)
NextPollingInterval = $"""
SELECT MIN(
- "{t.CreatedAt}" + INTERVAL '1 seconds' * POWER(2, "{t.TimesSent}")
+ "{t.CreatedAt}" + {retryDelay}
) AS "NextWakeUpTime"
FROM {t.QualifiedTableName}
- WHERE "{t.TimesSent}" < {MaxAttempts};
+ WHERE "{t.TimesSent}" < {maxAttempts};
""",
ProcessEvent = $"""
UPDATE {t.QualifiedTableName}
SET "{t.TimesSent}" = "{t.TimesSent}" + 1,
- "{t.CreatedAt}" = NOW() + INTERVAL '1 second' * POWER(2, "{t.TimesSent}")
+ "{t.CreatedAt}" = NOW() + {retryDelay}
WHERE "{t.Id}" = (
SELECT "{t.Id}" FROM {t.QualifiedTableName}
- WHERE "{t.TimesSent}" < {MaxAttempts} AND "{t.CreatedAt}" <= NOW()
+ WHERE "{t.TimesSent}" < {maxAttempts} AND "{t.CreatedAt}" <= NOW()
ORDER BY "{t.CreatedAt}", "{t.Id}"
FOR UPDATE SKIP LOCKED
LIMIT 1
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresOutboxOptions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresOutboxOptions.cs
index cc70588790a..4817c5d880d 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresOutboxOptions.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Outbox/PostgresOutboxOptions.cs
@@ -10,4 +10,29 @@ public sealed class PostgresOutboxOptions
/// transaction and throws an without one. The default is true.
///
public bool RequireTransaction { get; set; } = true;
+
+ ///
+ /// Gets or sets the maximum number of messages dispatched concurrently, which must be positive and defaults to one.
+ /// Values greater than one allow dispatches to complete out of order.
+ ///
+ public int MaxConcurrentMessages { get; set; } = 1;
+
+ ///
+ /// Gets or sets the longest time a single dispatch may take before it is cancelled and the message is
+ /// released for a later attempt that does not count towards . The default is
+ /// 30 seconds, and switches the timeout off.
+ ///
+ public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Gets or sets the number of failed dispatch attempts after which a message is no longer retried and
+ /// stays in the outbox table. The value must be at least one and defaults to 10.
+ ///
+ public int MaxAttempts { get; set; } = 10;
+
+ ///
+ /// Gets or sets the upper bound of the delay before a failed message is dispatched again, which doubles
+ /// with every failed attempt starting at one second. The value must be positive and defaults to 10 minutes.
+ ///
+ public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(10);
}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Scheduling/ScheduledMessageDispatcher.cs b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Scheduling/ScheduledMessageDispatcher.cs
index 78df7cd4304..7572a22abc4 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Scheduling/ScheduledMessageDispatcher.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Scheduling/ScheduledMessageDispatcher.cs
@@ -128,6 +128,7 @@ private async Task ProcessMessageAsync(NpgsqlConnection connection, Cancel
new ActivityContext());
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+ var sent = false;
try
{
@@ -182,9 +183,12 @@ private async Task ProcessMessageAsync(NpgsqlConnection connection, Cancel
{
await SendAsync(envelope, endpoint, messageType, cancellationToken);
+ // The transport has the message, so its row is removed whatever the token says.
+ sent = true;
+
await reader.CloseAsync();
- await DeleteMessageAsync(connection, id, transaction, cancellationToken);
+ await DeleteMessageAsync(connection, id, transaction, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -208,7 +212,7 @@ private async Task ProcessMessageAsync(NpgsqlConnection connection, Cancel
{
try
{
- await transaction.CommitAsync(cancellationToken);
+ await transaction.CommitAsync(sent ? CancellationToken.None : cancellationToken);
}
catch
{
diff --git a/src/Mocha/src/Mocha.Hosting/Configuration/MessageBusHealthCheckOptions.cs b/src/Mocha/src/Mocha.Hosting/Configuration/MessageBusHealthCheckOptions.cs
index fe866abcae0..6f19d486223 100644
--- a/src/Mocha/src/Mocha.Hosting/Configuration/MessageBusHealthCheckOptions.cs
+++ b/src/Mocha/src/Mocha.Hosting/Configuration/MessageBusHealthCheckOptions.cs
@@ -2,5 +2,7 @@ namespace Mocha.Hosting;
internal sealed class MessageBusHealthCheckOptions
{
- public Uri? Endpoint { get; set; }
+ public Uri? Endpoint { get; init; }
+
+ public required TimeSpan Timeout { get; init; }
}
diff --git a/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheck.cs b/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheck.cs
index a2e8987e08c..1c2d3043cfd 100644
--- a/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheck.cs
+++ b/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheck.cs
@@ -1,24 +1,84 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
-using Microsoft.Extensions.Options;
+using Mocha.Events;
namespace Mocha.Hosting;
-internal sealed class MessageBusHealthCheck(IMessageBus dispatcher, IOptions options)
+internal sealed class MessageBusHealthCheck(
+ IMessageBus dispatcher,
+ IMessagingRuntime runtime,
+ TimeProvider timeProvider,
+ MessageBusHealthCheckOptions options)
: IHealthCheck
{
public async Task CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
- var sendOptions = options.Value.Endpoint is { } endpoint
- ? new SendOptions { Endpoint = endpoint }
- : SendOptions.Default;
+ var failureStatus = context.Registration.FailureStatus;
- var request = new HealthRequest("Health Check");
- var response = await dispatcher.RequestAsync(request, sendOptions, cancellationToken);
+ if (GetTransportHealthFailure(failureStatus) is { } unhealthy)
+ {
+ return unhealthy;
+ }
+
+ var timeout = options.Timeout;
+ var sendOptions = new SendOptions
+ {
+ Endpoint = options.Endpoint,
+ ExpirationTime = timeProvider.GetUtcNow().Add(timeout)
+ };
+
+ HealthResponse response;
+ using var timeoutSource = new CancellationTokenSource(timeout, timeProvider);
+ using var requestSource = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ timeoutSource.Token);
+
+ try
+ {
+ var request = new HealthRequest("Health Check");
+ response = await dispatcher.RequestAsync(request, sendOptions, requestSource.Token);
+ }
+ catch (OperationCanceledException)
+ when (timeoutSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ return new HealthCheckResult(failureStatus, CreateTimeoutDescription(timeout));
+ }
+ catch (ResponseTimeoutException ex)
+ {
+ return new HealthCheckResult(failureStatus, CreateTimeoutDescription(ex.Timeout), ex);
+ }
+
+ if (GetTransportHealthFailure(failureStatus) is { } failedDuringRequest)
+ {
+ return failedDuringRequest;
+ }
return response.Message == "OK"
? HealthCheckResult.Healthy("Message Bus is healthy.")
- : HealthCheckResult.Unhealthy("Message Bus is unhealthy.");
+ : new HealthCheckResult(failureStatus, "Message Bus is unhealthy.");
+ }
+
+ private HealthCheckResult? GetTransportHealthFailure(HealthStatus failureStatus)
+ {
+ string? description = null;
+ Dictionary? reasons = null;
+
+ foreach (var transport in runtime.Transports)
+ {
+ if (transport.HealthFailureReason is { } reason)
+ {
+ description ??= $"Transport '{transport.Name}' is unhealthy. {reason}";
+ reasons ??= [];
+ reasons[transport.Name] = reason;
+ }
+ }
+
+ return description is null
+ ? null
+ : new HealthCheckResult(failureStatus, description, exception: null, reasons);
}
+
+ private static string CreateTimeoutDescription(TimeSpan timeout)
+ => $"Message Bus did not answer the health request within {timeout}.";
}
diff --git a/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheckExtensions.cs b/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheckExtensions.cs
index 8d0832aa5eb..8225d264f4c 100644
--- a/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheckExtensions.cs
+++ b/src/Mocha/src/Mocha.Hosting/Health/MessageBusHealthCheckExtensions.cs
@@ -8,6 +8,9 @@ namespace Mocha.Hosting;
///
public static class MessageBusHealthCheckExtensions
{
+ private static readonly string[] s_defaultTags = ["ready"];
+ private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
+
///
/// Registers the built-in health request handler so the message bus can respond to health check requests.
///
@@ -20,25 +23,52 @@ public static IMessageBusHostBuilder AddHealthCheck(this IMessageBusHostBuilder
}
///
- /// Adds a message bus health check to the ASP.NET Core health checks system, tagged with "ready" and "live".
+ /// Adds a message bus check to the ASP.NET Core health checks system.
///
///
- /// The check sends a through the message bus and verifies the response.
- /// When an is provided, the request is routed to that specific endpoint
- /// rather than using the default routing.
+ /// The check requires every local transport to be healthy and a to be answered
+ /// within . Each unhealthy transport is listed with its reason in the result data.
///
/// The health checks builder to extend.
- /// An optional endpoint URI to target with the health check request.
+ ///
+ /// The endpoint the health request is sent to, or to use default routing.
+ ///
+ /// The name of the health check.
+ ///
+ /// The status reported when the check fails, or for .
+ ///
+ /// The tags of the health check, or for the tag "ready".
+ ///
+ /// The time within which the health request must be answered, or for 10 seconds.
+ /// The health request expires after this time.
+ ///
/// The same instance for chaining.
- public static IHealthChecksBuilder AddMessageBus(this IHealthChecksBuilder builder, Uri? endpoint = null)
+ /// is zero or negative.
+ public static IHealthChecksBuilder AddMessageBus(
+ this IHealthChecksBuilder builder,
+ Uri? endpoint = null,
+ string name = "MessageBus",
+ HealthStatus? failureStatus = null,
+ IEnumerable? tags = null,
+ TimeSpan? timeout = null)
{
- if (endpoint is not null)
+ if (timeout is { } value)
{
- builder.Services.Configure(o => o.Endpoint = endpoint);
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value, TimeSpan.Zero, nameof(timeout));
}
- builder.AddCheck("MessageBus", HealthStatus.Unhealthy, ["ready", "live"]);
+ var options = new MessageBusHealthCheckOptions { Endpoint = endpoint, Timeout = timeout ?? s_defaultTimeout };
- return builder;
+ // The check applies the timeout itself, so the registration carries none.
+ return builder.Add(
+ new HealthCheckRegistration(
+ name,
+ services => new MessageBusHealthCheck(
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetService() ?? TimeProvider.System,
+ options),
+ failureStatus,
+ tags ?? s_defaultTags));
}
}
diff --git a/src/Mocha/src/Mocha.Threading/ExponentialBackoff.cs b/src/Mocha/src/Mocha.Threading/ExponentialBackoff.cs
index 2f370208139..0fb300109d4 100644
--- a/src/Mocha/src/Mocha.Threading/ExponentialBackoff.cs
+++ b/src/Mocha/src/Mocha.Threading/ExponentialBackoff.cs
@@ -4,10 +4,9 @@ namespace Mocha.Threading;
/// Implements an exponential backoff strategy with jitter to manage retry delays.
///
///
-/// Uses a randomized multiplier derived from the base delay to avoid the thundering herd problem
-/// when multiple consumers retry simultaneously. The delay doubles with each attempt, capped at
-/// a configurable maximum. Call after a successful operation to restart the
-/// backoff sequence.
+/// The delay doubles with each attempt up to a configurable maximum, and each wait is randomized
+/// between half of that value and the full value. Call after a successful
+/// operation to restart the backoff sequence.
///
public sealed class ExponentialBackoff
{
@@ -21,7 +20,7 @@ public sealed class ExponentialBackoff
/// Creates a new exponential backoff instance with the specified retry limits and delay bounds.
///
/// The maximum number of retry attempts before returns false.
- /// The base delay used to compute the randomized multiplier for each backoff interval.
+ /// The base delay, which doubles with each retry.
/// The upper bound on the computed delay; no single wait will exceed this duration.
public ExponentialBackoff(int maxRetries, TimeSpan delay, TimeSpan maxDelay)
{
@@ -64,24 +63,43 @@ public async Task WaitAsync(CancellationToken cancellationToken)
}
///
- /// Computes the next backoff delay using the current power level and a randomized jitter multiplier.
+ /// Computes the randomized delay for the next retry.
///
///
- /// A representing the delay, clamped to the configured maximum delay.
+ /// A representing the delay, which never exceeds the configured maximum delay.
///
public TimeSpan CalculateDelay()
+ => CalculateDelay(_power, TimeSpan.FromMilliseconds(_delayInMs), TimeSpan.FromMilliseconds(_maxDelayInMs));
+
+ ///
+ /// Computes a randomized exponential delay for a failed attempt. The result lies between half of
+ /// the exponential delay and the exponential delay, which is capped at .
+ ///
+ /// The number of the failed attempt, starting at 1.
+ ///
+ /// The base delay, which doubles with each attempt. The first attempt waits between one and two base delays.
+ ///
+ /// The upper bound on the computed delay.
+ ///
+ /// or is negative.
+ ///
+ public static TimeSpan CalculateDelay(int attempt, TimeSpan delay, TimeSpan maxDelay)
{
- // we use a random multiplier because we don't want to have a thundering herd problem
- var multiplier = Random.Shared.Next(_delayInMs / 2, _delayInMs);
- if (multiplier == 0)
+ if (delay < TimeSpan.Zero)
+ {
+ throw ThrowHelper.DelayMustNotBeNegative(nameof(delay), delay);
+ }
+
+ if (maxDelay < TimeSpan.Zero)
{
- multiplier = 1;
+ throw ThrowHelper.DelayMustNotBeNegative(nameof(maxDelay), maxDelay);
}
- var waitTimeInMs = Math.Pow(2, _power) * multiplier;
+ // delay * 2^attempt
+ var exponentialDelayInMs = Math.ScaleB(delay.TotalMilliseconds, Math.Max(attempt, 1));
+ var ceilingInMs = Math.Min(maxDelay.TotalMilliseconds, exponentialDelayInMs);
- // we make sure that we don't exceed the max delay
- return TimeSpan.FromMilliseconds(Math.Min(_maxDelayInMs, waitTimeInMs));
+ return TimeSpan.FromMilliseconds(ceilingInMs * (0.5 + Random.Shared.NextDouble() / 2));
}
///
diff --git a/src/Mocha/src/Mocha.Threading/ThrowHelper.cs b/src/Mocha/src/Mocha.Threading/ThrowHelper.cs
new file mode 100644
index 00000000000..458d19ea76f
--- /dev/null
+++ b/src/Mocha/src/Mocha.Threading/ThrowHelper.cs
@@ -0,0 +1,7 @@
+namespace Mocha.Threading;
+
+internal static class ThrowHelper
+{
+ public static ArgumentOutOfRangeException DelayMustNotBeNegative(string parameterName, TimeSpan delay)
+ => new(parameterName, delay, "The delay must not be negative.");
+}
diff --git a/src/Mocha/src/Mocha.Transport.AzureServiceBus/AzureServiceBusReceiveEndpoint.cs b/src/Mocha/src/Mocha.Transport.AzureServiceBus/AzureServiceBusReceiveEndpoint.cs
index 4478be16a3b..1f5ab3d7736 100644
--- a/src/Mocha/src/Mocha.Transport.AzureServiceBus/AzureServiceBusReceiveEndpoint.cs
+++ b/src/Mocha/src/Mocha.Transport.AzureServiceBus/AzureServiceBusReceiveEndpoint.cs
@@ -15,6 +15,8 @@ namespace Mocha.Transport.AzureServiceBus;
public sealed class AzureServiceBusReceiveEndpoint(AzureServiceBusMessagingTransport transport)
: ReceiveEndpoint(transport)
{
+ private static readonly TimeSpan s_temporaryResourceCleanupTimeout = TimeSpan.FromSeconds(30);
+
private MessageProcessor? _processor;
private QueueHeartbeat? _heartbeat;
private ILogger _logger = null!;
@@ -228,7 +230,8 @@ private Task OnProcessorError(ProcessErrorEventArgs args)
/// Stops when the messaging runtime deactivates this endpoint, then disposes the heartbeat and
/// processor resources. Disposal is best-effort: a failure while releasing either resource is
/// logged as a warning and does not fail the stop. For a temporary endpoint, also removes the
- /// Mocha-owned, auto-provisioned forwarding subscriptions that route to this endpoint's queue.
+ /// Mocha-owned, auto-provisioned forwarding subscriptions that route to this endpoint's queue,
+ /// whether or not is canceled.
///
protected override async ValueTask OnStopAsync(
IMessagingRuntimeContext context,
@@ -240,11 +243,10 @@ protected override async ValueTask OnStopAsync(
{
await _processor.StopProcessingAsync(cancellationToken);
}
-
- if (Configuration.IsTemporary)
- {
- await CleanupTemporaryResourcesAsync(cancellationToken);
- }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The stop no longer waits for the processor. Disposing the processor below ends the processing.
}
finally
{
@@ -276,6 +278,29 @@ protected override async ValueTask OnStopAsync(
_processor = null;
}
}
+
+ if (Configuration.IsTemporary)
+ {
+ await CleanupTemporaryResourcesAsync();
+ }
+ }
+
+ ///
+ /// Removes the temporary resources of this endpoint within a fixed time. A cleanup that
+ /// does not complete in time is logged as a warning and does not fail the stop.
+ ///
+ private async Task CleanupTemporaryResourcesAsync()
+ {
+ using var timeout = new CancellationTokenSource(s_temporaryResourceCleanupTimeout);
+
+ try
+ {
+ await CleanupTemporaryResourcesAsync(timeout.Token);
+ }
+ catch (OperationCanceledException ex) when (timeout.IsCancellationRequested)
+ {
+ _logger.TemporaryResourceCleanupTimedOut(ex, Queue.Name, s_temporaryResourceCleanupTimeout);
+ }
}
///
@@ -402,6 +427,15 @@ public static partial void TemporaryQueueCleanupFailed(
Exception exception,
string queueName);
+ [LoggerMessage(
+ LogLevel.Warning,
+ "Deleting the temporary resources of Azure Service Bus queue '{QueueName}' did not complete within {Timeout}")]
+ public static partial void TemporaryResourceCleanupTimedOut(
+ this ILogger logger,
+ Exception exception,
+ string queueName,
+ TimeSpan timeout);
+
[LoggerMessage(
LogLevel.Warning,
"Failed to dispose the keep-alive heartbeat for Azure Service Bus queue '{QueueName}'")]
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs b/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
index 0b424018883..4bd82caaa9a 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
@@ -11,6 +11,8 @@ namespace Mocha.Transport.Postgres;
///
public sealed class PostgresMessagingTransport : MessagingTransport
{
+ private static readonly TimeSpan s_unregisterTimeout = TimeSpan.FromSeconds(5);
+
private readonly Action _configure;
private readonly PostgresBackgroundTaskScheduler _backgroundTasks = new();
private IReadOnlyPostgresSchemaOptions _schemaOptions = null!;
@@ -428,7 +430,9 @@ protected override async ValueTask OnAfterStopAsync(CancellationToken cancellati
{
try
{
- await ConsumerManager.UnregisterAsync(cancellationToken);
+ // Unregistering is cleanup, so it does not observe the stop token.
+ using var timeout = new CancellationTokenSource(s_unregisterTimeout);
+ await ConsumerManager.UnregisterAsync(timeout.Token);
}
catch
{
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/PostgresReceiveEndpoint.cs b/src/Mocha/src/Mocha.Transport.Postgres/PostgresReceiveEndpoint.cs
index 066b5eccb81..f62e0f71224 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/PostgresReceiveEndpoint.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/PostgresReceiveEndpoint.cs
@@ -310,7 +310,9 @@ protected override async ValueTask OnStopAsync(
{
try
{
- await _pollingTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
+ // The loop is already cancelled. The wait covers its unwinding and the release of its
+ // leased messages, so it does not observe the stop token.
+ await _pollingTask.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQDefaultQueueOptions.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQDefaultQueueOptions.cs
index cb044fb4e6f..5171886ea41 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQDefaultQueueOptions.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQDefaultQueueOptions.cs
@@ -7,7 +7,7 @@ public sealed class RabbitMQDefaultQueueOptions
{
///
/// Gets or sets the default queue type (Classic, Quorum, or Stream).
- /// When set, all auto-provisioned queues will use this queue type unless explicitly overridden.
+ /// When no queue type is configured, RabbitMQ selects the queue type.
///
public string? QueueType { get; set; }
@@ -29,22 +29,22 @@ public sealed class RabbitMQDefaultQueueOptions
public Dictionary Arguments { get; set; } = new();
///
- /// Applies these defaults to a queue configuration, without overriding explicitly set values.
- /// Quorum and stream queue types are not applied to queues that have auto-delete or exclusive
- /// set, since those properties are incompatible with these queue types. Default arguments are
- /// also skipped for incompatible queues because they may contain queue-type-specific settings
- /// (e.g. x-delivery-limit is only valid for quorum queues).
+ /// Applies queue defaults without overriding explicitly configured values.
+ /// Auto-delete and exclusive queues skip queue type and argument defaults; non-durable queues skip them
+ /// when the defaults specify quorum or stream.
///
internal void ApplyTo(RabbitMQQueueConfiguration configuration)
{
configuration.Durable ??= Durable;
configuration.AutoDelete ??= AutoDelete;
- // Quorum and stream queues do not support auto-delete or exclusive properties.
- // Skip applying queue type and default arguments for incompatible configurations
- // (e.g. reply queues) since default arguments are often queue-type-specific.
+ var queueType = GetQueueType();
+
var isIncompatibleWithQueueType =
- configuration.AutoDelete is true || configuration.Exclusive is true;
+ configuration.AutoDelete is true
+ || configuration.Exclusive is true
+ || (configuration.Durable is false
+ && queueType is RabbitMQQueueType.Quorum or RabbitMQQueueType.Stream);
if (isIncompatibleWithQueueType)
{
@@ -66,4 +66,26 @@ internal void ApplyTo(RabbitMQQueueConfiguration configuration)
}
}
}
+
+ ///
+ /// Applies quorum replication settings to a generated reply queue.
+ /// Message limits and queue lifecycle defaults are not applied.
+ ///
+ internal void ApplyToReplyQueue(RabbitMQQueueConfiguration configuration)
+ {
+ if (GetQueueType() is not RabbitMQQueueType.Quorum)
+ {
+ return;
+ }
+
+ configuration.Arguments ??= new Dictionary();
+ configuration.Arguments.TryAdd("x-queue-type", RabbitMQQueueType.Quorum);
+ if (Arguments.TryGetValue("x-quorum-initial-group-size", out var groupSize))
+ {
+ configuration.Arguments.TryAdd("x-quorum-initial-group-size", groupSize);
+ }
+ }
+
+ private string? GetQueueType()
+ => QueueType ?? (Arguments.TryGetValue("x-queue-type", out var value) ? value as string : null);
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQTransportConfiguration.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQTransportConfiguration.cs
index bf2313345ac..9252804c589 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQTransportConfiguration.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Configurations/RabbitMQTransportConfiguration.cs
@@ -63,6 +63,19 @@ public RabbitMQTransportConfiguration()
/// Gets or sets the bus-level defaults applied to all auto-provisioned queues and exchanges.
///
public RabbitMQBusDefaults Defaults { get; set; } = new();
+
+ ///
+ /// Gets or sets how long the transport keeps retrying while the broker cannot be reached before its start fails,
+ /// or null for two minutes.
+ ///
+ public TimeSpan? ConnectTimeout { get; set; }
+
+ ///
+ /// Gets or sets how long a publish waits for the broker's confirmation before it fails with
+ /// , or null for two minutes.
+ /// waits until the caller cancels.
+ ///
+ public TimeSpan? PublishTimeout { get; set; }
}
///
@@ -115,4 +128,15 @@ public async ValueTask CreateAsync(CancellationToken cancellationTo
var connection = await factory.CreateConnectionAsync(cancellationToken);
return connection;
}
+
+ ///
+ /// Creates a new connection, named unless the factory sets a name.
+ ///
+ internal async ValueTask CreateAsync(string clientProvidedName, CancellationToken cancellationToken)
+ {
+ var connection = await factory.CreateConnectionAsync(
+ factory.ClientProvidedName ?? clientProvidedName,
+ cancellationToken);
+ return connection;
+ }
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/ChannelOpenBlockedException.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/ChannelOpenBlockedException.cs
new file mode 100644
index 00000000000..2a8e1b5ab85
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/ChannelOpenBlockedException.cs
@@ -0,0 +1,8 @@
+namespace Mocha.Transport.RabbitMQ;
+
+///
+/// Thrown when the broker did not answer a request to open a channel because it blocked the connection.
+/// The client keeps the channel number of the request until the connection is replaced.
+///
+internal sealed class ChannelOpenBlockedException(string message, Exception innerException)
+ : TimeoutException(message, innerException);
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/PublishDeadline.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/PublishDeadline.cs
new file mode 100644
index 00000000000..f07d3748049
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/PublishDeadline.cs
@@ -0,0 +1,51 @@
+namespace Mocha.Transport.RabbitMQ;
+
+///
+/// The cancellation of one publish: its token is cancelled when the publish timeout elapses or when the
+/// caller cancels. Instances are reused, so a publish allocates none.
+///
+internal sealed class PublishDeadline : IDisposable
+{
+ private readonly CancellationTokenSource _source = new();
+ private CancellationTokenRegistration _caller;
+
+ ///
+ /// Gets the token that the operations of the publish observe.
+ ///
+ public CancellationToken Token => _source.Token;
+
+ ///
+ /// Gets whether the token was cancelled, by the timeout or by the caller.
+ ///
+ public bool IsCancellationRequested => _source.IsCancellationRequested;
+
+ ///
+ /// Starts the timeout and follows the caller's token.
+ ///
+ public void Start(TimeSpan timeout, CancellationToken callerToken)
+ {
+ _source.CancelAfter(timeout);
+ if (callerToken.CanBeCanceled)
+ {
+ _caller = callerToken.UnsafeRegister(static state => ((CancellationTokenSource)state!).Cancel(), _source);
+ }
+ }
+
+ ///
+ /// Stops the timeout and stops following the caller's token. Returns false when the token was
+ /// cancelled, after which the instance cannot be used again.
+ ///
+ public bool TryReset()
+ {
+ _caller.Dispose();
+ _caller = default;
+ return _source.TryReset();
+ }
+
+ ///
+ public void Dispose()
+ {
+ _caller.Dispose();
+ _source.Dispose();
+ }
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.Logging.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.Logging.cs
new file mode 100644
index 00000000000..d0a0772f7ae
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.Logging.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.Logging;
+using RabbitMQ.Client;
+
+namespace Mocha.Transport.RabbitMQ;
+
+internal sealed partial class RabbitMQConnectionManager
+{
+ ///
+ /// Gets the properties that identify the connection in structured logs: service, instance, transport,
+ /// connection name and role, broker host, port and virtual host.
+ ///
+ public IReadOnlyDictionary LogContext { get; init; } = new Dictionary();
+
+ ///
+ /// Begins a logger scope that carries .
+ ///
+ public IDisposable? BeginLogScope() => _logger.BeginScope(LogContext);
+}
+
+internal static partial class Logs
+{
+ [LoggerMessage(LogLevel.Debug, "Connecting RabbitMQ connection {ConnectionName} (attempt {Attempt})")]
+ public static partial void ConnectionAttemptStarted(this ILogger logger, string connectionName, int attempt);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "RabbitMQ connection {ConnectionName} is ready after {Elapsed} (attempt {Attempt})")]
+ public static partial void ConnectionReady(
+ this ILogger logger,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ Message = "RabbitMQ connection {ConnectionName} is unavailable for {Elapsed}: attempt {Attempt} failed. "
+ + "Retrying in {Delay}")]
+ public static partial void ConnectionAttemptFailed(
+ this ILogger logger,
+ Exception exception,
+ LogLevel level,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed,
+ TimeSpan delay);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "RabbitMQ connection {ConnectionName} could not be established after {Elapsed} and {Attempt} attempts. "
+ + "The start fails")]
+ public static partial void ConnectionFailed(
+ this ILogger logger,
+ Exception exception,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "RabbitMQ connection {ConnectionName} was lost: {ReplyCode} {ReplyText} (initiator: {Initiator}, "
+ + "cause: {Cause}). Reconnecting in the background")]
+ public static partial void ConnectionLost(
+ this ILogger logger,
+ string connectionName,
+ ushort replyCode,
+ string? replyText,
+ ShutdownInitiator initiator,
+ string? cause);
+
+ [LoggerMessage(LogLevel.Warning, "Closing RabbitMQ connection {ConnectionName}: {Reason}")]
+ public static partial void ConnectionAborted(this ILogger logger, string connectionName, string reason);
+
+ [LoggerMessage(LogLevel.Information, "RabbitMQ connection {ConnectionName} is closed")]
+ public static partial void ConnectionStopped(this ILogger logger, string connectionName);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "RabbitMQ connection {ConnectionName} is blocked by the broker: {Reason}. Publishes wait until the "
+ + "broker unblocks it")]
+ public static partial void ConnectionBlocked(this ILogger logger, string connectionName, string reason);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "RabbitMQ connection {ConnectionName} was unblocked by the broker after {Elapsed}")]
+ public static partial void ConnectionUnblocked(this ILogger logger, string connectionName, TimeSpan elapsed);
+
+ [LoggerMessage(LogLevel.Debug, "Closing a discarded RabbitMQ connection of {ConnectionName} failed")]
+ public static partial void ConnectionCloseFailed(this ILogger logger, Exception exception, string connectionName);
+
+ [LoggerMessage(LogLevel.Error, "A callback of RabbitMQ connection {ConnectionName} failed. Detail: {Detail}")]
+ public static partial void ConnectionCallbackFailed(
+ this ILogger logger,
+ Exception exception,
+ string connectionName,
+ string detail);
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.cs
new file mode 100644
index 00000000000..90395bc6672
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManager.cs
@@ -0,0 +1,767 @@
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Mocha.Threading;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+
+namespace Mocha.Transport.RabbitMQ;
+
+///
+/// Owns one RabbitMQ connection: connects it at start, hands it out while it is ready, and replaces it in
+/// the background after it was lost.
+///
+internal sealed partial class RabbitMQConnectionManager : IAsyncDisposable
+{
+ private static readonly TimeSpan s_initialRetryDelay = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan s_maxRetryDelay = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(5);
+ private static readonly CancellationToken s_cancelled = new(canceled: true);
+
+ private readonly ILogger _logger;
+ private readonly Func> _connectionFactory;
+ private readonly Func _setUpConnectionAsync;
+ private readonly CancellationTokenSource _disposeCts = new();
+ private readonly object _sync = new();
+
+ // The ready connection, or null while there is none.
+ private IConnection? _connection;
+
+ // Completes with the next ready connection, and is replaced when that connection is lost.
+ private TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // The connect routine that runs or ran last. At most one runs at a time.
+ private Task _connecting = Task.CompletedTask;
+ // Completed while the ready connection is not blocked by the broker, and replaced while it is.
+ private TaskCompletionSource _unblocked = CreateCompletedSource();
+
+ private Task? _disposeTask;
+ private long _unavailableSince;
+ private long _blockedSince;
+ private bool _isDisposed;
+
+ ///
+ /// Creates a connection manager.
+ ///
+ /// The logger for connection lifecycle events.
+ /// The name that identifies the connection in logs.
+ /// The clock used for elapsed times and retry delays.
+ /// Creates a new RabbitMQ connection.
+ ///
+ /// Prepares a new connection before it is handed out. A failure discards the connection and counts as a
+ /// failed attempt.
+ ///
+ public RabbitMQConnectionManager(
+ ILogger logger,
+ string connectionName,
+ TimeProvider timeProvider,
+ Func> connectionFactory,
+ Func setUpConnectionAsync)
+ {
+ _logger = logger;
+ _connectionFactory = connectionFactory;
+ _setUpConnectionAsync = setUpConnectionAsync;
+ ConnectionName = connectionName;
+ TimeProvider = timeProvider;
+ }
+
+ ///
+ /// Gets the name that identifies the connection in logs.
+ ///
+ public string ConnectionName { get; }
+
+ ///
+ /// Gets the clock used for elapsed times and retry delays.
+ ///
+ public TimeProvider TimeProvider { get; }
+
+ ///
+ /// Gets or sets how long keeps retrying while the broker cannot be reached.
+ ///
+ public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromMinutes(2);
+
+ ///
+ /// Gets or sets how long one connection attempt may take before it is abandoned and retried.
+ ///
+ public TimeSpan AttemptTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Gets the ready connection, or null while there is none.
+ ///
+ public IConnection? Connection => Volatile.Read(ref _connection) is { IsOpen: true } connection ? connection : null;
+
+ ///
+ /// Gets whether a ready connection exists.
+ ///
+ public bool IsConnected => Connection is not null;
+
+ ///
+ /// Gets whether the broker blocked the ready connection. The broker does not read from a blocked
+ /// connection, so publishes and requests on it stay unanswered until the broker unblocks it.
+ ///
+ public bool IsBlocked => Volatile.Read(ref _blockedSince) != 0 && IsConnected;
+
+ ///
+ /// Waits while the broker blocks the ready connection. The wait also ends when the connection is lost.
+ ///
+ public Task WaitWhileBlockedAsync(CancellationToken cancellationToken)
+ {
+ var unblocked = Volatile.Read(ref _unblocked).Task;
+ return unblocked.IsCompleted ? Task.CompletedTask : unblocked.WaitAsync(cancellationToken);
+ }
+
+ ///
+ /// Gets whether the manager was disposed.
+ ///
+ public bool IsDisposed => Volatile.Read(ref _isDisposed);
+
+ ///
+ /// Gets the ready connection, waiting while there is none. The wait ends when a connection becomes
+ /// ready, when is cancelled, or when the manager is disposed.
+ ///
+ public ValueTask GetConnectionAsync(CancellationToken cancellationToken)
+ => Volatile.Read(ref _connection) is { IsOpen: true } connection
+ ? new ValueTask(connection)
+ : WaitForConnectionAsync(cancellationToken);
+
+ private async ValueTask WaitForConnectionAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ Task? ready = null;
+ Task? connecting = null;
+ IConnection? closed = null;
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+ if (_connection is { } current)
+ {
+ if (current.IsOpen)
+ {
+ return current;
+ }
+
+ closed = current;
+ }
+ else
+ {
+ if (_connecting.IsCompleted)
+ {
+ // The manager was not started, or its start failed.
+ _unavailableSince = TimeProvider.GetTimestamp();
+ _connecting = Task.Run(() => ConnectWithRetryAsync(deadline: null, CancellationToken.None));
+ }
+
+ ready = _ready.Task;
+ connecting = _connecting;
+ }
+ }
+
+ if (closed is not null)
+ {
+ // The connection closed and its shutdown event has not arrived yet.
+ OnConnectionLost(closed, GetCloseReason(closed));
+ continue;
+ }
+
+ // A start that fails ends its routine without a connection. The wait then starts the next one.
+ await Task.WhenAny(ready!, connecting!).WaitAsync(cancellationToken);
+ if (ready!.IsCompleted)
+ {
+ var connection = await ready;
+ if (connection.IsOpen)
+ {
+ return connection;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Connects, retrying with exponential backoff until elapsed. A failure that
+ /// repeats on every attempt, such as refused credentials, fails at once.
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ Task? connecting = null;
+ Task? ready = null;
+ IConnection? closed = null;
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+ if (_connection is { } current)
+ {
+ if (current.IsOpen)
+ {
+ return;
+ }
+
+ closed = current;
+ }
+ else if (_connecting.IsCompleted)
+ {
+ var timeout = ConnectTimeout;
+ _unavailableSince = TimeProvider.GetTimestamp();
+ _connecting = connecting = Task.Run(() => ConnectWithRetryAsync(timeout, cancellationToken));
+ }
+ else
+ {
+ ready = _ready.Task;
+ }
+ }
+
+ if (closed is not null)
+ {
+ OnConnectionLost(closed, GetCloseReason(closed));
+ continue;
+ }
+
+ if (connecting is not null)
+ {
+ try
+ {
+ await connecting;
+ return;
+ }
+ catch (OperationCanceledException)
+ when (_disposeCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ throw ThrowHelper.ConnectionManagerDisposed();
+ }
+ }
+
+ // A recovery already runs in the background. The start waits for it as long as it would have
+ // kept trying itself.
+ try
+ {
+ await ready!.WaitAsync(ConnectTimeout, TimeProvider, cancellationToken);
+ return;
+ }
+ catch (TimeoutException ex)
+ {
+ throw ThrowHelper.ConnectionTimedOut(ConnectTimeout, ex);
+ }
+ }
+ }
+
+ private async Task ConnectWithRetryAsync(TimeSpan? deadline, CancellationToken cancellationToken)
+ {
+ using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
+ var loopToken = loopCts.Token;
+ var started = TimeProvider.GetTimestamp();
+
+ for (var attempt = 1; ; attempt++)
+ {
+ loopToken.ThrowIfCancellationRequested();
+
+ // The last attempt before the deadline ends with the deadline.
+ var attemptTimeout = AttemptTimeout;
+ if (deadline is { } limit && limit > TimeSpan.Zero)
+ {
+ var remaining = limit - TimeProvider.GetElapsedTime(started);
+ if (remaining < attemptTimeout)
+ {
+ attemptTimeout = remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
+ }
+ }
+
+ using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(loopToken);
+ attemptCts.CancelAfter(attemptTimeout);
+
+ try
+ {
+ using (BeginLogScope())
+ {
+ _logger.ConnectionAttemptStarted(ConnectionName, attempt);
+ }
+
+ var connection = await ConnectOnceAsync(attemptCts.Token);
+ if (!TryHandOut(connection, attempt))
+ {
+ // The manager was disposed while the attempt ran. Disposal cancelled the routine's token
+ // before it set the flag, so this ends the routine.
+ await CloseConnectionAsync(connection);
+ loopToken.ThrowIfCancellationRequested();
+ }
+
+ return;
+ }
+ catch (Exception ex) when (!loopToken.IsCancellationRequested)
+ {
+ // Nobody cancelled the routine, so a cancellation is a timeout: of the attempt, or of the
+ // client, which reports a request the broker did not answer in time as a cancellation.
+ var timedOut = ex is OperationCanceledException;
+ var failure = !timedOut ? ex
+ : attemptCts.IsCancellationRequested ? ThrowHelper.ConnectionAttemptTimedOut(attemptTimeout, ex)
+ : ThrowHelper.ConnectionRequestTimedOut(ex);
+ var delay = ExponentialBackoff.CalculateDelay(attempt, s_initialRetryDelay, s_maxRetryDelay);
+ var elapsed = TimeProvider.GetElapsedTime(Volatile.Read(ref _unavailableSince));
+
+ if (deadline is { } connectTimeout
+ && (RabbitMQFailure.IsPermanent(failure)
+ || TimeProvider.GetElapsedTime(started) + delay > connectTimeout))
+ {
+ using (BeginLogScope())
+ {
+ _logger.ConnectionFailed(failure, ConnectionName, attempt, elapsed);
+ }
+
+ if (timedOut)
+ {
+ throw ThrowHelper.ConnectionTimedOut(connectTimeout, ex);
+ }
+
+ throw;
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionAttemptFailed(
+ failure,
+ RabbitMQFailure.GetLogLevel(failure),
+ ConnectionName,
+ attempt,
+ elapsed,
+ delay);
+ }
+
+ await Task.Delay(delay, TimeProvider, loopToken);
+ }
+ }
+ }
+
+ private async Task ConnectOnceAsync(CancellationToken cancellationToken)
+ {
+ var connection = await _connectionFactory(cancellationToken);
+ try
+ {
+ connection.ConnectionShutdownAsync += (_, reason) =>
+ {
+ OnConnectionLost(connection, reason);
+ return Task.CompletedTask;
+ };
+ connection.CallbackExceptionAsync += OnCallbackExceptionAsync;
+ connection.ConnectionBlockedAsync += (_, e) =>
+ {
+ OnConnectionBlocked(connection, e.Reason);
+ return Task.CompletedTask;
+ };
+ connection.ConnectionUnblockedAsync += (_, _) =>
+ {
+ OnConnectionUnblocked(connection);
+ return Task.CompletedTask;
+ };
+
+ // The client does not end a request with the token once the request is sent.
+ await _setUpConnectionAsync(connection, cancellationToken).WaitOrAbandonAsync(cancellationToken);
+
+ if (!connection.IsOpen)
+ {
+ throw ThrowHelper.ConnectionClosedDuringSetup(connection.CloseReason);
+ }
+
+ return connection;
+ }
+ catch
+ {
+ await CloseConnectionAsync(connection);
+ throw;
+ }
+ }
+
+ private bool TryHandOut(IConnection connection, int attempt)
+ {
+ lock (_sync)
+ {
+ if (_isDisposed)
+ {
+ return false;
+ }
+
+ Volatile.Write(ref _blockedSince, 0);
+ _unblocked.TrySetResult();
+ Volatile.Write(ref _connection, connection);
+ _ready.TrySetResult(connection);
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionReady(
+ ConnectionName,
+ attempt,
+ TimeProvider.GetElapsedTime(Volatile.Read(ref _unavailableSince)));
+ }
+
+ // A connection that closed before it was handed out raised its shutdown event to nobody.
+ if (!connection.IsOpen)
+ {
+ OnConnectionLost(connection, GetCloseReason(connection));
+ }
+
+ return true;
+ }
+
+ private void OnConnectionLost(IConnection connection, ShutdownEventArgs? reason)
+ {
+ if (!TryBeginRecovery(connection))
+ {
+ return;
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionLost(
+ ConnectionName,
+ reason?.ReplyCode ?? 0,
+ reason?.ReplyText,
+ reason?.Initiator ?? ShutdownInitiator.Library,
+ reason?.Exception?.Message);
+ }
+ }
+
+ ///
+ /// Starts replacing the ready connection in the background. Returns false when
+ /// is not the ready connection, so only the first report for a connection
+ /// has an effect.
+ ///
+ private bool TryBeginRecovery(IConnection connection)
+ {
+ lock (_sync)
+ {
+ if (_isDisposed || !ReferenceEquals(_connection, connection))
+ {
+ return false;
+ }
+
+ // Callers that arrive from here on wait for the next connection.
+ _ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Volatile.Write(ref _connection, null);
+ Volatile.Write(ref _unavailableSince, TimeProvider.GetTimestamp());
+ Volatile.Write(ref _blockedSince, 0);
+ _unblocked.TrySetResult();
+
+ // The client raises the shutdown event on its reader loop, which must not wait for the cleanup.
+ _connecting = Task.Run(() => RecoverAsync(connection));
+ return true;
+ }
+ }
+
+ private async Task RecoverAsync(IConnection lostConnection)
+ {
+ // The lost connection is closed first, so the broker has released what belongs to it, such as an
+ // exclusive queue. Disposing it also stops the client's own automatic recovery.
+ await CloseConnectionAsync(lostConnection);
+ await ConnectWithRetryAsync(deadline: null, CancellationToken.None);
+ }
+
+ private Task OnCallbackExceptionAsync(object? sender, CallbackExceptionEventArgs e)
+ {
+ using (BeginLogScope())
+ {
+ _logger.ConnectionCallbackFailed(e.Exception, ConnectionName, Print(e.Detail));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void OnConnectionBlocked(IConnection connection, string reason)
+ {
+ lock (_sync)
+ {
+ if (!ReferenceEquals(_connection, connection))
+ {
+ return;
+ }
+
+ if (_blockedSince == 0)
+ {
+ Volatile.Write(ref _blockedSince, TimeProvider.GetTimestamp());
+ Volatile.Write(ref _unblocked, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
+ }
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionBlocked(ConnectionName, reason);
+ }
+ }
+
+ private void OnConnectionUnblocked(IConnection connection)
+ {
+ long blockedSince;
+ lock (_sync)
+ {
+ if (!ReferenceEquals(_connection, connection))
+ {
+ return;
+ }
+
+ blockedSince = _blockedSince;
+ Volatile.Write(ref _blockedSince, 0);
+ _unblocked.TrySetResult();
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionUnblocked(
+ ConnectionName,
+ blockedSince == 0 ? TimeSpan.Zero : TimeProvider.GetElapsedTime(blockedSince));
+ }
+ }
+
+ // A disposed connection of the client throws instead of giving its close reason.
+ private static ShutdownEventArgs? GetCloseReason(IConnection connection)
+ {
+ try
+ {
+ return connection.CloseReason;
+ }
+ catch (ObjectDisposedException)
+ {
+ return null;
+ }
+ }
+
+ private static TaskCompletionSource CreateCompletedSource()
+ {
+ var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ source.SetResult();
+ return source;
+ }
+
+ ///
+ /// Opens a channel on a connection. The open does not end with a caller's token, because the client keeps
+ /// the channel number of a cancelled open until the connection closes. When the broker does not answer
+ /// within , the connection is closed, which starts a recovery.
+ ///
+ ///
+ /// The broker did not answer because it blocked the connection. The connection stays, and the channel
+ /// number of the open is lost until the connection is replaced.
+ ///
+ public async Task OpenChannelAsync(
+ IConnection connection,
+ CreateChannelOptions options,
+ TimeSpan timeout)
+ {
+ using var openCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token);
+ openCts.CancelAfter(timeout);
+
+ try
+ {
+ // The client does not end the request with the token once the request is sent.
+ return await connection.CreateChannelAsync(options, openCts.Token).WaitOrAbandonAsync(openCts.Token);
+ }
+ catch (Exception) when (_disposeCts.IsCancellationRequested)
+ {
+ throw ThrowHelper.ConnectionManagerDisposed();
+ }
+ catch (OperationCanceledException ex)
+ {
+ // The timeout elapsed, or the client's own timeout for a request did.
+ lock (_sync)
+ {
+ if (_blockedSince != 0 && ReferenceEquals(_connection, connection))
+ {
+ // The broker does not read from a blocked connection, so the silence says nothing about
+ // the connection. Replacing it would send every pending publish again.
+ throw ThrowHelper.ChannelOpenBlocked(ex);
+ }
+ }
+
+ // Closing the connection is the only way to give the channel number back, and it ends a request
+ // that is still in flight.
+ ReportUnresponsive(connection, "the broker did not answer the request to open a channel");
+ throw ThrowHelper.ChannelOpenTimedOut(ex);
+ }
+ }
+
+ ///
+ /// Replaces the ready connection because the broker stopped answering on it. The connection is closed in
+ /// the background before the next one is created.
+ ///
+ /// The connection the broker does not answer on.
+ /// Why the connection is replaced, for the log.
+ public void ReportUnresponsive(IConnection connection, string reason)
+ {
+ if (!TryBeginRecovery(connection))
+ {
+ return;
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionAborted(ConnectionName, reason);
+ }
+ }
+
+ ///
+ /// Closes and disposes a connection, waiting no longer than the close timeout for either step.
+ ///
+ private async Task CloseConnectionAsync(IConnection connection)
+ {
+ try
+ {
+ if (connection.IsOpen)
+ {
+ await connection.AbortAsync(s_closeTimeout);
+ }
+ }
+ catch (Exception ex)
+ {
+ LogCloseFailed(ex);
+ }
+
+ try
+ {
+ // Disposing a connection whose broker stopped answering can take as long as the client's own timeouts.
+ await connection.DisposeAsync().AsTask().WaitAsync(s_closeTimeout);
+ }
+ catch (Exception ex)
+ {
+ LogCloseFailed(ex);
+ }
+ }
+
+ private void LogCloseFailed(Exception exception)
+ {
+ using (BeginLogScope())
+ {
+ _logger.ConnectionCloseFailed(exception, ConnectionName);
+ }
+ }
+
+ ///
+ /// Closes and disposes a channel, ignoring failures of either step and waiting no longer than the close
+ /// timeout. A close that takes longer continues in the background.
+ ///
+ public static async Task CloseChannelAsync(IChannel channel)
+ {
+ var closing = CloseChannelCompletelyAsync(channel);
+ if (closing.IsCompleted)
+ {
+ return;
+ }
+
+ try
+ {
+ await closing.WaitAsync(s_closeTimeout);
+ }
+ catch (TimeoutException)
+ {
+ // The broker does not answer.
+ }
+ }
+
+ ///
+ /// Closes and disposes a channel, ignoring failures of either step. Completes when the client has given
+ /// the channel number back, which takes as long as the client's timeout for a request when the broker
+ /// does not answer.
+ ///
+ public static async Task CloseChannelCompletelyAsync(IChannel channel)
+ {
+ try
+ {
+ if (channel.IsOpen)
+ {
+ await channel.CloseAsync();
+ }
+ else
+ {
+ // A connection with automatic recovery forgets a channel the broker closed only when the
+ // channel is closed here as well. The cancelled token keeps the client from waiting for a reply.
+ await channel.CloseAsync(Constants.ReplySuccess, "Goodbye", abort: true, s_cancelled);
+ }
+ }
+ catch
+ {
+ // Ignore close errors.
+ }
+ finally
+ {
+ try
+ {
+ await channel.DisposeAsync();
+ }
+ catch
+ {
+ // Ignore disposal errors.
+ }
+ }
+ }
+
+ ///
+ /// Stops connecting and closes the connection. Pending and later calls of
+ /// fail with .
+ ///
+ public ValueTask DisposeAsync()
+ {
+ lock (_sync)
+ {
+ return new ValueTask(_disposeTask ??= DisposeCoreAsync());
+ }
+ }
+
+ private async Task DisposeCoreAsync()
+ {
+ // Yields first, so the caller's lock is not held while the cancellation callbacks run.
+ await Task.Yield();
+ await _disposeCts.CancelAsync();
+
+ IConnection? connection;
+ Task connecting;
+ lock (_sync)
+ {
+ _isDisposed = true;
+ connection = _connection;
+ _connection = null;
+ connecting = _connecting;
+
+ _unblocked.TrySetResult();
+ _ready.TrySetException(ThrowHelper.ConnectionManagerDisposed());
+
+ // Reading the exception marks it as observed when nobody waits for a connection.
+ _ = _ready.Task.Exception;
+ }
+
+ try
+ {
+ await connecting;
+ }
+ catch
+ {
+ // The connect routine ended with the cancellation above, or failed the start it belonged to.
+ }
+
+ if (connection is not null)
+ {
+ await CloseConnectionAsync(connection);
+ }
+
+ using (BeginLogScope())
+ {
+ _logger.ConnectionStopped(ConnectionName);
+ }
+ }
+
+ private static string Print(IDictionary? detail)
+ {
+ if (detail is null || detail.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var builder = new StringBuilder();
+ foreach (var item in detail)
+ {
+ if (builder.Length > 0)
+ {
+ builder.Append(", ");
+ }
+
+ builder.Append(item.Key).Append(": ").Append(item.Value);
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManagerBase.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManagerBase.cs
deleted file mode 100644
index 6c460a0d7eb..00000000000
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConnectionManagerBase.cs
+++ /dev/null
@@ -1,351 +0,0 @@
-using System.Text;
-using Microsoft.Extensions.Logging;
-using RabbitMQ.Client;
-using RabbitMQ.Client.Events;
-
-namespace Mocha.Transport.RabbitMQ;
-
-///
-/// Base class for RabbitMQ connection management with automatic reconnection,
-/// exponential backoff, and connection event handling.
-///
-public abstract class RabbitMQConnectionManagerBase : IAsyncDisposable
-{
- private const int InitialConnectionRetryDelaySeconds = 1;
- private const int MaxConnectionAttempts = 5;
- private static readonly TimeSpan s_maxBackoffDelay = TimeSpan.FromSeconds(60);
-
- private readonly Func> _connectionFactory;
- private readonly SemaphoreSlim _connectionLock = new(1, 1);
-
- private IConnection? _currentConnection;
- private bool _isDisposed;
-
- ///
- /// Creates a new connection manager base with the specified logger and connection factory.
- ///
- /// The logger for connection lifecycle events.
- /// A factory delegate that creates new RabbitMQ connections on demand.
- protected RabbitMQConnectionManagerBase(
- ILogger logger,
- Func> connectionFactory)
- {
- Logger = logger;
- _connectionFactory = connectionFactory;
- }
-
- ///
- /// Gets the logger used for connection lifecycle events.
- ///
- protected ILogger Logger { get; }
-
- ///
- /// Gets a value indicating whether the current connection is open and usable.
- ///
- public bool IsConnected => _currentConnection?.IsOpen ?? false;
-
- ///
- /// Gets the current underlying RabbitMQ connection, or null if no connection has been established.
- ///
- protected IConnection? CurrentConnection => _currentConnection;
-
- ///
- /// Gets a value indicating whether this manager has been disposed.
- ///
- protected bool IsDisposed => _isDisposed;
-
- ///
- /// Gets the current connection, creating one if necessary.
- ///
- public async ValueTask GetConnectionAsync(CancellationToken cancellationToken)
- {
- ObjectDisposedException.ThrowIf(_isDisposed, this);
-
- var connection = Volatile.Read(ref _currentConnection);
- if (connection is { IsOpen: true })
- {
- return connection;
- }
-
- await _connectionLock.WaitAsync(cancellationToken);
- try
- {
- // Double-check after acquiring lock
- connection = _currentConnection;
- if (connection is { IsOpen: true })
- {
- return connection;
- }
-
- await CreateConnectionWithRetryAsync(cancellationToken);
- return _currentConnection!;
- }
- finally
- {
- _connectionLock.Release();
- }
- }
-
- ///
- /// Ensures connection is established, creating it if necessary.
- ///
- public async Task EnsureConnectedAsync(CancellationToken cancellationToken = default)
- {
- await GetConnectionAsync(cancellationToken);
- }
-
- ///
- /// Called before a new connection is created.
- /// Override to perform cleanup (e.g., clearing channel pools).
- ///
- protected virtual Task OnBeforeConnectionCreatedAsync(CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- ///
- /// Called after a new connection is successfully created and setup actions have run.
- /// Override to perform post-connection setup (e.g., reconnecting consumers).
- ///
- protected virtual Task OnAfterConnectionCreatedAsync(IConnection connection, CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- ///
- /// Called when the connection is shut down (not by application).
- /// Override to perform cleanup.
- ///
- protected virtual Task OnConnectionLostAsync()
- {
- return Task.CompletedTask;
- }
-
- ///
- /// Called after automatic recovery succeeds.
- /// Override to perform post-recovery actions.
- ///
- protected virtual Task OnConnectionRecoveredAsync(CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- ///
- /// Creates connection using the factory delegate with exponential backoff retry.
- /// Must be called while holding _connectionLock.
- ///
- private async Task CreateConnectionWithRetryAsync(CancellationToken cancellationToken)
- {
- var attempt = 0;
- var baseDelay = TimeSpan.FromSeconds(InitialConnectionRetryDelaySeconds);
-
- Logger.CreatingConnectionUsingFactoryDelegate();
-
- while (attempt < MaxConnectionAttempts)
- {
- try
- {
- // Clean up existing connection if any
- await DisposeConnectionInternalAsync();
-
- // Allow derived classes to perform cleanup
- await OnBeforeConnectionCreatedAsync(cancellationToken);
-
- // Create new connection
- var connection = await _connectionFactory(cancellationToken);
-
- // Wire up connection events
- WireConnectionEvents(connection);
-
- _currentConnection = connection;
-
- Logger.SuccessfullyCreatedConnection(connection.ClientProvidedName ?? "Unknown");
-
- // Run setup actions (declare exchanges, queues, etc.)
- await OnConnectionEstablished(connection, cancellationToken);
-
- // Allow derived classes to perform post-connection setup
- await OnAfterConnectionCreatedAsync(connection, cancellationToken);
-
- return;
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- attempt++;
-
- if (attempt >= MaxConnectionAttempts)
- {
- Logger.FailedToCreateConnectionAfterAttempts(ex, MaxConnectionAttempts);
- throw;
- }
-
- var delay = CalculateBackoffDelay(baseDelay, attempt);
- Logger.FailedToCreateConnectionRetrying(ex, attempt, MaxConnectionAttempts, delay);
-
- await Task.Delay(delay, cancellationToken);
- }
- }
- }
-
- private void WireConnectionEvents(IConnection connection)
- {
- connection.CallbackExceptionAsync += OnCallbackExceptionAsync;
- connection.ConnectionShutdownAsync += OnConnectionShutdownAsync;
- connection.RecoverySucceededAsync += OnRecoverySucceededAsync;
- connection.ConnectionRecoveryErrorAsync += OnConnectionRecoveryErrorAsync;
- connection.ConnectionBlockedAsync += OnConnectionBlockedAsync;
- connection.ConnectionUnblockedAsync += OnConnectionUnblockedAsync;
- }
-
- private void UnwireConnectionEvents(IConnection connection)
- {
- connection.CallbackExceptionAsync -= OnCallbackExceptionAsync;
- connection.ConnectionShutdownAsync -= OnConnectionShutdownAsync;
- connection.RecoverySucceededAsync -= OnRecoverySucceededAsync;
- connection.ConnectionRecoveryErrorAsync -= OnConnectionRecoveryErrorAsync;
- connection.ConnectionBlockedAsync -= OnConnectionBlockedAsync;
- connection.ConnectionUnblockedAsync -= OnConnectionUnblockedAsync;
- }
-
- protected virtual Task OnConnectionEstablished(IConnection connection, CancellationToken cancellationToken)
- => Task.CompletedTask;
-
- private Task OnCallbackExceptionAsync(object? sender, CallbackExceptionEventArgs e)
- {
- Logger.ExceptionInConnectionCallback(e.Exception, e.Detail.Print());
-
- return Task.CompletedTask;
- }
-
- private async Task OnConnectionShutdownAsync(object? sender, ShutdownEventArgs e)
- {
- if (e.Initiator == ShutdownInitiator.Application)
- {
- Logger.ConnectionClosedByApplication();
- return;
- }
-
- Logger.ConnectionShutdownDetected(e.Initiator, e.ReplyText);
-
- await OnConnectionLostAsync();
- }
-
- private async Task OnRecoverySucceededAsync(object? sender, AsyncEventArgs e)
- {
- Logger.ConnectionRecoverySucceeded();
-
- try
- {
- if (_currentConnection is not null)
- {
- await OnConnectionEstablished(_currentConnection, CancellationToken.None);
- }
-
- await OnConnectionRecoveredAsync(CancellationToken.None);
-
- Logger.SuccessfullyRecoveredConnectionTopologyAndConsumers();
- }
- catch (Exception ex)
- {
- Logger.ErrorDuringPostRecoveryOperations(ex);
- }
- }
-
- private Task OnConnectionRecoveryErrorAsync(object? sender, ConnectionRecoveryErrorEventArgs e)
- {
- Logger.ConnectionRecoveryFailedWillRetry(e.Exception, e.Exception?.Message);
- return Task.CompletedTask;
- }
-
- private Task OnConnectionBlockedAsync(object? sender, ConnectionBlockedEventArgs e)
- {
- Logger.ConnectionBlocked(e.Reason);
- return Task.CompletedTask;
- }
-
- private Task OnConnectionUnblockedAsync(object? sender, AsyncEventArgs e)
- {
- Logger.ConnectionUnblocked();
- return Task.CompletedTask;
- }
-
- private static TimeSpan CalculateBackoffDelay(TimeSpan baseDelay, int attempt)
- {
- var delay = baseDelay * Math.Pow(2, attempt - 1);
- return delay > s_maxBackoffDelay ? s_maxBackoffDelay : delay;
- }
-
- private async Task DisposeConnectionInternalAsync()
- {
- if (_currentConnection is null)
- {
- return;
- }
-
- try
- {
- UnwireConnectionEvents(_currentConnection);
-
- if (_currentConnection.IsOpen)
- {
- await _currentConnection.CloseAsync();
- }
-
- await _currentConnection.DisposeAsync();
- }
- catch (Exception ex)
- {
- Logger.ErrorDisposingConnection(ex);
- }
- finally
- {
- _currentConnection = null;
- }
- }
-
- ///
- /// Override to perform cleanup before base disposal.
- ///
- protected virtual ValueTask DisposeAsyncCore()
- {
- return ValueTask.CompletedTask;
- }
-
- ///
- /// Disposes the connection manager, calling for derived cleanup then closing the connection.
- ///
- public async ValueTask DisposeAsync()
- {
- if (_isDisposed)
- {
- return;
- }
-
- _isDisposed = true;
-
- // Allow derived classes to clean up first
- await DisposeAsyncCore();
-
- // Dispose connection
- await DisposeConnectionInternalAsync();
-
- _connectionLock.Dispose();
- }
-}
-
-file static class Extensions
-{
- public static string Print(this IDictionary? obj)
- {
- var sb = new StringBuilder();
- foreach (var item in obj ?? Enumerable.Empty>())
- {
- sb.AppendLine($"{item.Key}: {item.Value}");
- }
- return sb.ToString();
- }
-}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumer.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumer.cs
new file mode 100644
index 00000000000..96a8232762b
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumer.cs
@@ -0,0 +1,794 @@
+using System.Buffers;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+using Mocha.Threading;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+
+namespace Mocha.Transport.RabbitMQ;
+
+///
+/// Consumes one queue. A single task attaches the consumer to a channel, waits until the channel or the
+/// broker-side consumer is lost, and attaches again, until the consumer is stopped.
+///
+internal sealed class RabbitMQConsumer : IAsyncDisposable
+{
+ private static readonly CancellationToken s_noDrain = new(canceled: true);
+ private static readonly TimeSpan s_initialRetryDelay = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan s_maxRetryDelay = TimeSpan.FromSeconds(30);
+
+ private static readonly CreateChannelOptions s_channelOptions = new(
+ publisherConfirmationsEnabled: false,
+ publisherConfirmationTrackingEnabled: false,
+ consumerDispatchConcurrency: 1);
+
+ private readonly RabbitMQConsumerManager _manager;
+ private readonly Func? _prepareAsync;
+ private readonly Func _messageHandler;
+
+ // Ends the lifecycle task.
+ private readonly CancellationTokenSource _stopRequested = new();
+
+ // Ends the wait for running handlers during a stop.
+ private readonly CancellationTokenSource _drainDeadline = new();
+
+ // One queue for the lifetime of the consumer. Every delivery names the attachment it arrived on.
+ private readonly Channel _deliveries =
+ System.Threading.Channels.Channel.CreateUnbounded();
+
+ // One permit per concurrent handler. A handler holds its permit until it returns, also after its
+ // channel was lost.
+ private readonly SemaphoreSlim _slots;
+
+ private readonly TaskCompletionSource _firstAttempt =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly object _stopSync = new();
+ private readonly Task _lifecycle;
+ private readonly Task _workers;
+
+ // Written by the lifecycle task, and by the stop after the lifecycle task ended.
+ private volatile Attachment? _attachment;
+ private volatile ConsumerState _state;
+
+ // Completed and replaced on every change of the state.
+ private TaskCompletionSource _stateChanged = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private Task? _stopTask;
+ private bool _wasAttached;
+
+ public RabbitMQConsumer(
+ RabbitMQConsumerManager manager,
+ string queueName,
+ Func? prepareAsync,
+ Func messageHandler,
+ ushort maxPrefetch,
+ ushort maxConcurrency)
+ {
+ _manager = manager;
+ _prepareAsync = prepareAsync;
+ _messageHandler = messageHandler;
+ QueueName = queueName;
+ MaxPrefetch = maxPrefetch;
+ MaxConcurrency = maxConcurrency;
+ _slots = new SemaphoreSlim(maxConcurrency, maxConcurrency);
+
+ var workers = new Task[maxConcurrency];
+ for (var i = 0; i < workers.Length; i++)
+ {
+ workers[i] = Task.Run(WorkAsync);
+ }
+
+ _workers = Task.WhenAll(workers);
+ _lifecycle = Task.Run(RunAsync);
+ }
+
+ ///
+ /// Gets the name of the queue the consumer consumes.
+ ///
+ public string QueueName { get; }
+
+ ///
+ /// Gets the largest number of unacknowledged messages the broker delivers to the consumer.
+ ///
+ public ushort MaxPrefetch { get; }
+
+ ///
+ /// Gets the largest number of messages that are handled at the same time.
+ ///
+ public ushort MaxConcurrency { get; }
+
+ ///
+ /// Gets the consumer tag the broker assigned, or null while the consumer is not attached.
+ ///
+ public string? ConsumerTag => _attachment?.Tag;
+
+ ///
+ /// Gets the channel the consumer receives deliveries on, or null while it is not attached.
+ ///
+ public IChannel? Channel => _attachment?.Channel;
+
+ ///
+ /// Gets whether the consumer is attached to an open channel and the broker delivers to it.
+ ///
+ public bool IsAttached => HealthFailureReason is null;
+
+ ///
+ /// Gets the number of handlers that have not returned, including cancelled handlers of a lost channel.
+ ///
+ public int RunningHandlers => Math.Max(0, MaxConcurrency - _slots.CurrentCount);
+
+ ///
+ /// Gets the reason the consumer does not consume, or null when it is attached.
+ ///
+ public string? HealthFailureReason
+ => _state switch
+ {
+ ConsumerState.Attached when _attachment is { IsLost: false, Channel.IsOpen: true } => null,
+ ConsumerState.WaitingForHandlers => "The consumer waits for a handler of a lost channel to return.",
+ ConsumerState.Stopped => "The consumer is stopped.",
+ _ => "The consumer is not attached."
+ };
+
+ ///
+ /// Gets a task that completes after the first attempt to attach: with null when the consumer
+ /// attached or no connection was ready, and with the failure when the attempt failed.
+ ///
+ public Task FirstAttempt => _firstAttempt.Task;
+
+ ///
+ /// Waits until the consumer is attached. Fails when the consumer is stopped.
+ ///
+ public ValueTask WaitUntilAttachedAsync(CancellationToken cancellationToken)
+ {
+ return IsAttached ? ValueTask.CompletedTask : WaitAsync(cancellationToken);
+
+ async ValueTask WaitAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ // Read before the state, so a change after the check below completes this task.
+ var changed = Volatile.Read(ref _stateChanged).Task;
+ if (IsAttached)
+ {
+ return;
+ }
+
+ if (_state == ConsumerState.Stopped)
+ {
+ throw ThrowHelper.ConsumerStopped(QueueName);
+ }
+
+ await changed.WaitAsync(cancellationToken);
+ }
+ }
+ }
+
+ private void SetState(ConsumerState state)
+ {
+ _state = state;
+ Interlocked
+ .Exchange(ref _stateChanged, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously))
+ .TrySetResult();
+ }
+
+ private async Task RunAsync()
+ {
+ var stop = _stopRequested.Token;
+ var connections = _manager.Connection;
+ var timeProvider = connections.TimeProvider;
+ var logger = _manager.Logger;
+ var detachedSince = timeProvider.GetTimestamp();
+ var attempt = 0;
+
+ try
+ {
+ while (true)
+ {
+ SetState(ConsumerState.WaitingForConnection);
+ if (!connections.IsConnected)
+ {
+ // A registration does not wait for an outage to end.
+ _firstAttempt.TrySetResult(null);
+ }
+
+ IConnection connection;
+ Attachment attachment;
+ try
+ {
+ connection = await connections.GetConnectionAsync(stop);
+ SetState(ConsumerState.Attaching);
+ attachment = await AttachAsync(connection, stop);
+ }
+ catch (Exception ex) when (!stop.IsCancellationRequested)
+ {
+ attempt++;
+ var delay = ExponentialBackoff.CalculateDelay(attempt, s_initialRetryDelay, s_maxRetryDelay);
+
+ // A first attempt the broker refuses fails the registration, which reports it.
+ if (!_firstAttempt.TrySetResult(ex) || !RabbitMQFailure.IsPermanent(ex))
+ {
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerAttachFailed(
+ ex,
+ RabbitMQFailure.GetLogLevel(ex),
+ QueueName,
+ connections.ConnectionName,
+ attempt,
+ timeProvider.GetElapsedTime(detachedSince),
+ delay);
+ }
+ }
+
+ await Task.Delay(delay, timeProvider, stop);
+ continue;
+ }
+
+ _attachment = attachment;
+ _wasAttached = true;
+ SetState(ConsumerState.Attached);
+ _firstAttempt.TrySetResult(null);
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerAttached(
+ attachment.Tag,
+ QueueName,
+ connections.ConnectionName,
+ timeProvider.GetElapsedTime(detachedSince),
+ MaxPrefetch,
+ MaxConcurrency);
+ }
+
+ // Completes on a channel shutdown and on a cancel by the broker, also when either came first.
+ var reason = await attachment.Lost.WaitAsync(stop);
+
+ detachedSince = timeProvider.GetTimestamp();
+ _attachment = null;
+ SetState(ConsumerState.WaitingForConnection);
+ var cancelledHandlers = RunningHandlers;
+ attachment.CancelHandlers();
+ LogLoss(reason, cancelledHandlers, isConnectionLost: !connection.IsOpen);
+ await RabbitMQConnectionManager.CloseChannelAsync(attachment.Channel);
+
+ // An attachment that did not last counts as a failed attempt, so a consumer that the broker
+ // keeps cancelling backs off.
+ attempt = timeProvider.GetElapsedTime(attachment.AttachedAt, detachedSince) < s_initialRetryDelay
+ ? attempt + 1
+ : 0;
+ if (attempt > 0)
+ {
+ var delay = ExponentialBackoff.CalculateDelay(attempt, s_initialRetryDelay, s_maxRetryDelay);
+ await Task.Delay(delay, timeProvider, stop);
+ }
+ }
+ }
+ catch (Exception) when (stop.IsCancellationRequested)
+ {
+ // The stop takes over. A registration that still waits for the first attempt ends with it.
+ _firstAttempt.TrySetResult(null);
+ }
+ catch (Exception ex)
+ {
+ _firstAttempt.TrySetResult(ex);
+ SetState(ConsumerState.Stopped);
+ if (!connections.IsDisposed)
+ {
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerFailed(ex, QueueName, connections.ConnectionName);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Opens a channel, prepares it, waits for a free handler slot and starts consuming.
+ ///
+ private async Task AttachAsync(IConnection connection, CancellationToken stop)
+ {
+ var connections = _manager.Connection;
+ var timeout = _manager.AttachTimeout;
+
+ // The broker does not answer on a connection it blocked.
+ await connections.WaitWhileBlockedAsync(stop);
+
+ var opening = connections.OpenChannelAsync(connection, s_channelOptions, timeout);
+ IChannel channel;
+ try
+ {
+ channel = await opening.WaitAsync(stop);
+ }
+ catch (OperationCanceledException) when (stop.IsCancellationRequested)
+ {
+ // The consumer was stopped. The channel is closed once it is open.
+ _ = CloseWhenOpenedAsync(opening);
+ throw;
+ }
+
+ var attachment = new Attachment(this, channel);
+ using var attachCts = CancellationTokenSource.CreateLinkedTokenSource(stop);
+ var attachToken = attachCts.Token;
+ try
+ {
+ channel.ChannelShutdownAsync += attachment.OnChannelShutdownAsync;
+
+ attachCts.CancelAfter(timeout);
+ if (_prepareAsync is not null)
+ {
+ await _prepareAsync(connection, channel, attachToken).WaitOrAbandonAsync(attachToken);
+ }
+
+ // The queue and its bindings exist from here on, also while the consumer waits for a handler.
+ attachCts.CancelAfter(Timeout.InfiniteTimeSpan);
+ await WaitForFreeSlotAsync(stop);
+
+ attachCts.CancelAfter(timeout);
+ await channel
+ .BasicQosAsync(prefetchSize: 0, prefetchCount: MaxPrefetch, global: false, attachToken)
+ .WaitOrAbandonAsync(attachToken);
+
+ attachment.AttachedAt = connections.TimeProvider.GetTimestamp();
+ attachment.Tag = await channel
+ .BasicConsumeAsync(QueueName, autoAck: false, consumer: attachment, attachToken)
+ .WaitOrAbandonAsync(attachToken);
+
+ return attachment;
+ }
+ catch (Exception ex)
+ {
+ // A request may still be in flight, so the channel is closed without waiting for it.
+ attachment.StopAccepting();
+ _ = RabbitMQConnectionManager.CloseChannelAsync(channel);
+
+ if (ex is not OperationCanceledException || stop.IsCancellationRequested)
+ {
+ throw;
+ }
+
+ // Without the timeout the client's own timeout for a request ended the attempt.
+ throw attachCts.IsCancellationRequested
+ ? ThrowHelper.ConsumerAttachTimedOut(QueueName, timeout)
+ : ThrowHelper.ConsumerAttachRequestTimedOut(QueueName, ex);
+ }
+ }
+
+ private static async Task CloseWhenOpenedAsync(Task opening)
+ {
+ try
+ {
+ await RabbitMQConnectionManager.CloseChannelAsync(await opening);
+ }
+ catch
+ {
+ // The channel could not be opened.
+ }
+ }
+
+ ///
+ /// Waits until at least one handler slot is free. Handlers of a lost channel keep their slot until they
+ /// return, and a new attachment needs one free slot.
+ ///
+ private async ValueTask WaitForFreeSlotAsync(CancellationToken stop)
+ {
+ if (_slots.CurrentCount > 0)
+ {
+ return;
+ }
+
+ SetState(ConsumerState.WaitingForHandlers);
+
+ // The handlers were cancelled when their channel was lost. Only a handler that ignores the
+ // cancellation is still running after the grace period.
+ var gracePeriod = _manager.BlockedGracePeriod;
+ if (!await _slots.WaitAsync(gracePeriod, stop))
+ {
+ var connections = _manager.Connection;
+ var blockedSince = connections.TimeProvider.GetTimestamp();
+ using (connections.BeginLogScope())
+ {
+ _manager.Logger.ConsumerBlockedByHandlers(
+ QueueName,
+ connections.ConnectionName,
+ MaxConcurrency,
+ gracePeriod);
+ }
+
+ await _slots.WaitAsync(stop);
+
+ using (connections.BeginLogScope())
+ {
+ _manager.Logger.ConsumerUnblocked(
+ QueueName,
+ connections.ConnectionName,
+ connections.TimeProvider.GetElapsedTime(blockedSince) + gracePeriod);
+ }
+ }
+
+ _slots.Release();
+ SetState(ConsumerState.Attaching);
+ }
+
+ private void LogLoss(ShutdownEventArgs? reason, int cancelledHandlers, bool isConnectionLost)
+ {
+ var connections = _manager.Connection;
+ using (connections.BeginLogScope())
+ {
+ if (reason is null)
+ {
+ _manager.Logger.ConsumerCancelled(QueueName, connections.ConnectionName, cancelledHandlers);
+ return;
+ }
+
+ // The loss of the connection has its own line. This one adds what happened to running handlers.
+ var level = isConnectionLost && cancelledHandlers == 0 ? LogLevel.Debug : LogLevel.Warning;
+ _manager.Logger.ConsumerChannelLost(
+ level,
+ QueueName,
+ connections.ConnectionName,
+ reason.ReplyCode,
+ reason.ReplyText,
+ reason.Initiator,
+ cancelledHandlers);
+ }
+ }
+
+ private async Task WorkAsync()
+ {
+ var reader = _deliveries.Reader;
+ while (await reader.WaitToReadAsync())
+ {
+ while (reader.TryRead(out var delivery))
+ {
+ try
+ {
+ // What a lost or stopping attachment left in the queue is skipped. The broker delivers
+ // it again once the channel is closed.
+ var attachment = delivery.Attachment;
+ if (!attachment.IsAccepting)
+ {
+ continue;
+ }
+
+ await _slots.WaitAsync();
+ try
+ {
+ await _messageHandler(attachment.Channel, delivery.EventArgs, attachment.HandlerToken);
+ }
+ catch (OperationCanceledException) when (attachment.HandlerToken.IsCancellationRequested)
+ {
+ // The channel was lost or the stop stopped waiting.
+ }
+ catch (Exception ex)
+ {
+ LogDeliveryFailed(ex, delivery.EventArgs);
+ }
+ finally
+ {
+ _slots.Release();
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(delivery.Buffer);
+ }
+ }
+ }
+ }
+
+ private void LogDeliveryFailed(Exception exception, BasicDeliverEventArgs delivery)
+ {
+ var connections = _manager.Connection;
+ using (connections.BeginLogScope())
+ {
+ _manager.Logger.DeliveryFailed(
+ exception,
+ delivery.BasicProperties.MessageId,
+ QueueName,
+ connections.ConnectionName,
+ delivery.ConsumerTag,
+ delivery.DeliveryTag,
+ delivery.BasicProperties.CorrelationId);
+ }
+ }
+
+ ///
+ /// Stops consuming and waits for running handlers until is cancelled, then
+ /// cancels the handlers that still run and closes the channel. Closing the channel does not depend on
+ /// the token, and the broker delivers unacknowledged messages again.
+ ///
+ public Task StopAsync(CancellationToken drainToken)
+ {
+ Task stopTask;
+ lock (_stopSync)
+ {
+ stopTask = _stopTask ??= StopCoreAsync();
+ }
+
+ return stopTask.IsCompleted ? stopTask : WaitForStopAsync(stopTask, drainToken);
+ }
+
+ private async Task WaitForStopAsync(Task stopTask, CancellationToken drainToken)
+ {
+ // The token of any caller ends the wait for running handlers.
+ await using var registration = drainToken.UnsafeRegister(
+ static state => ((CancellationTokenSource)state!).Cancel(),
+ _drainDeadline);
+
+ await stopTask;
+ }
+
+ private async Task StopCoreAsync()
+ {
+ // Leaves the caller's lock.
+ await Task.Yield();
+
+ var connections = _manager.Connection;
+ var logger = _manager.Logger;
+ var started = connections.TimeProvider.GetTimestamp();
+ var drainToken = _drainDeadline.Token;
+
+ await _stopRequested.CancelAsync();
+ await _lifecycle;
+
+ // From here on this method owns the attachment.
+ var attachment = _attachment;
+ _attachment = null;
+
+ if (RunningHandlers > 0)
+ {
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerDrainStarted(QueueName, connections.ConnectionName, RunningHandlers);
+ }
+ }
+
+ if (attachment is not null)
+ {
+ if (attachment is { IsLost: false, Channel.IsOpen: true })
+ {
+ try
+ {
+ // The broker stops delivering. Handlers that run settle their messages on the open channel.
+ await attachment.Channel
+ .BasicCancelAsync(attachment.Tag, noWait: false, drainToken)
+ .WaitOrAbandonAsync(drainToken);
+ }
+ catch
+ {
+ // The channel is closed below.
+ }
+
+ attachment.StopAccepting();
+ }
+ else
+ {
+ // The channel is gone, so its handlers cannot settle their messages any more.
+ attachment.CancelHandlers();
+ }
+ }
+
+ // The workers finish the handlers they started, skip what is left in the queue, and end.
+ _deliveries.Writer.TryComplete();
+ try
+ {
+ await _workers.WaitAsync(drainToken);
+ }
+ catch (OperationCanceledException)
+ {
+ var runningHandlers = RunningHandlers;
+ if (runningHandlers > 0)
+ {
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerDrainTimedOut(
+ QueueName,
+ connections.ConnectionName,
+ runningHandlers,
+ connections.TimeProvider.GetElapsedTime(started));
+ }
+ }
+
+ attachment?.CancelHandlers();
+ }
+
+ if (attachment is not null)
+ {
+ await RabbitMQConnectionManager.CloseChannelAsync(attachment.Channel);
+ }
+
+ SetState(ConsumerState.Stopped);
+ _manager.Remove(this);
+
+ if (_wasAttached)
+ {
+ using (connections.BeginLogScope())
+ {
+ logger.ConsumerStopped(
+ QueueName,
+ connections.ConnectionName,
+ connections.TimeProvider.GetElapsedTime(started),
+ RunningHandlers);
+ }
+ }
+ }
+
+ ///
+ /// Stops consuming, cancels running handlers without waiting for them, and closes the channel.
+ ///
+ public async ValueTask DisposeAsync() => await StopAsync(s_noDrain);
+
+ private readonly record struct Delivery(Attachment Attachment, BasicDeliverEventArgs EventArgs, byte[] Buffer);
+
+ ///
+ /// The broker-side consumer on one channel. A lost attachment is never used again.
+ ///
+ private sealed class Attachment : AsyncDefaultBasicConsumer
+ {
+ // Never disposed: it has no timer and is not linked, so it holds nothing that needs disposing.
+ private readonly CancellationTokenSource _handlers = new();
+ private readonly TaskCompletionSource _lost =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly RabbitMQConsumer _owner;
+ private volatile bool _isAccepting = true;
+
+ public Attachment(RabbitMQConsumer owner, IChannel channel)
+ : base(channel)
+ {
+ _owner = owner;
+ HandlerToken = _handlers.Token;
+ }
+
+ ///
+ /// Gets or sets the consumer tag the broker assigned.
+ ///
+ public string Tag { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the timestamp at which consuming was requested.
+ ///
+ public long AttachedAt { get; set; }
+
+ ///
+ /// Gets a task that completes when the channel shut down, with the reason, or when the broker
+ /// cancelled the consumer, with null.
+ ///
+ public Task Lost => _lost.Task;
+
+ ///
+ /// Gets whether the channel shut down or the broker cancelled the consumer.
+ ///
+ public bool IsLost => _lost.Task.IsCompleted;
+
+ ///
+ /// Gets the token that is cancelled for the handlers of this attachment.
+ ///
+ public CancellationToken HandlerToken { get; }
+
+ ///
+ /// Gets whether handlers may still start for deliveries of this attachment.
+ ///
+ public bool IsAccepting => _isAccepting;
+
+ public override Task HandleBasicDeliverAsync(
+ string consumerTag,
+ ulong deliveryTag,
+ bool redelivered,
+ string exchange,
+ string routingKey,
+ IReadOnlyBasicProperties properties,
+ ReadOnlyMemory body,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_isAccepting)
+ {
+ // The broker delivers the message again once the channel is closed.
+ return Task.CompletedTask;
+ }
+
+ // The client returns the body's buffer to its pool once this callback returns.
+ var buffer = ArrayPool.Shared.Rent(body.Length);
+ body.CopyTo(buffer);
+
+ var eventArgs = new BasicDeliverEventArgs(
+ consumerTag,
+ deliveryTag,
+ redelivered,
+ exchange,
+ routingKey,
+ properties,
+ buffer.AsMemory(0, body.Length),
+ HandlerToken);
+
+ if (!_owner._deliveries.Writer.TryWrite(new Delivery(this, eventArgs, buffer)))
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public override async Task HandleBasicCancelAsync(
+ string consumerTag,
+ CancellationToken cancellationToken = default)
+ {
+ await base.HandleBasicCancelAsync(consumerTag, cancellationToken);
+ OnLost(reason: null);
+ }
+
+ public Task OnChannelShutdownAsync(object? _, ShutdownEventArgs reason)
+ {
+ OnLost(reason);
+ return Task.CompletedTask;
+ }
+
+ private void OnLost(ShutdownEventArgs? reason)
+ {
+ _isAccepting = false;
+ _lost.TrySetResult(reason);
+ }
+
+ ///
+ /// Stops starting handlers for deliveries of this attachment.
+ ///
+ public void StopAccepting() => _isAccepting = false;
+
+ ///
+ /// Stops starting handlers and cancels the handlers that run.
+ ///
+ public void CancelHandlers()
+ {
+ _isAccepting = false;
+ _ = CancelHandlersAsync();
+ }
+
+ private async Task CancelHandlersAsync()
+ {
+ try
+ {
+ // The callbacks of handlers run on the thread pool, not on the caller.
+ await _handlers.CancelAsync();
+ }
+ catch (Exception ex)
+ {
+ var connections = _owner._manager.Connection;
+ using (connections.BeginLogScope())
+ {
+ _owner._manager.Logger.HandlerCancellationFailed(
+ ex,
+ _owner.QueueName,
+ connections.ConnectionName);
+ }
+ }
+ }
+ }
+}
+
+///
+/// Identifies what a consumer is doing.
+///
+internal enum ConsumerState
+{
+ ///
+ /// The consumer waits for a ready connection.
+ ///
+ WaitingForConnection,
+
+ ///
+ /// The consumer opens a channel, declares its queue and starts consuming.
+ ///
+ Attaching,
+
+ ///
+ /// The consumer declared its queue and waits until a handler of a lost channel returns.
+ ///
+ WaitingForHandlers,
+
+ ///
+ /// The broker delivers messages to the consumer.
+ ///
+ Attached,
+
+ ///
+ /// The consumer was stopped.
+ ///
+ Stopped
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.Logging.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.Logging.cs
new file mode 100644
index 00000000000..caa0da21623
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.Logging.cs
@@ -0,0 +1,182 @@
+using Microsoft.Extensions.Logging;
+using RabbitMQ.Client;
+
+namespace Mocha.Transport.RabbitMQ;
+
+internal static partial class Logs
+{
+ [LoggerMessage(
+ LogLevel.Information,
+ "Consumer {ConsumerTag} is attached to queue {Queue} on {ConnectionName} after {Elapsed} "
+ + "(prefetch {MaxPrefetch}, concurrency {MaxConcurrency})")]
+ public static partial void ConsumerAttached(
+ this ILogger logger,
+ string consumerTag,
+ string queue,
+ string connectionName,
+ TimeSpan elapsed,
+ ushort maxPrefetch,
+ ushort maxConcurrency);
+
+ [LoggerMessage(
+ Message = "The consumer for queue {Queue} on {ConnectionName} is detached for {Elapsed}: attempt {Attempt} "
+ + "failed. Retrying in {Delay}")]
+ public static partial void ConsumerAttachFailed(
+ this ILogger logger,
+ Exception exception,
+ LogLevel level,
+ string queue,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed,
+ TimeSpan delay);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "The broker refused the consumer for queue {Queue} on {ConnectionName}. Its endpoint does not start")]
+ public static partial void ConsumerRegistrationFailed(
+ this ILogger logger,
+ Exception exception,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(
+ Message = "The consumer for queue {Queue} on {ConnectionName} lost its channel: {ReplyCode} {ReplyText} "
+ + "(initiator: {Initiator}). {CancelledHandlers} running handlers were cancelled, and the broker "
+ + "delivers their messages again")]
+ public static partial void ConsumerChannelLost(
+ this ILogger logger,
+ LogLevel level,
+ string queue,
+ string connectionName,
+ ushort replyCode,
+ string replyText,
+ ShutdownInitiator initiator,
+ int cancelledHandlers);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "The broker cancelled the consumer for queue {Queue} on {ConnectionName}, which it does when the queue "
+ + "is deleted. {CancelledHandlers} running handlers were cancelled. Attaching again")]
+ public static partial void ConsumerCancelled(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ int cancelledHandlers);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "The consumer for queue {Queue} on {ConnectionName} cannot attach: all {MaxConcurrency} handlers of a "
+ + "lost channel are still running {GracePeriod} after they were cancelled. The endpoint consumes "
+ + "again when one of them returns")]
+ public static partial void ConsumerBlockedByHandlers(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ ushort maxConcurrency,
+ TimeSpan gracePeriod);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "A handler of the consumer for queue {Queue} on {ConnectionName} returned after {Elapsed}. "
+ + "The consumer attaches again")]
+ public static partial void ConsumerUnblocked(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "Stopping the consumer for queue {Queue} on {ConnectionName}: waiting for {RunningHandlers} running "
+ + "handlers")]
+ public static partial void ConsumerDrainStarted(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ int runningHandlers);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "The consumer for queue {Queue} on {ConnectionName} stopped waiting for its handlers after {Elapsed}. "
+ + "{RunningHandlers} handlers were cancelled, and the broker returns their unacknowledged messages "
+ + "to the queue")]
+ public static partial void ConsumerDrainTimedOut(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ int runningHandlers,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "Stopped the consumer for queue {Queue} on {ConnectionName} after {Elapsed}. {RunningHandlers} "
+ + "handlers are still running")]
+ public static partial void ConsumerStopped(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ TimeSpan elapsed,
+ int runningHandlers);
+
+ [LoggerMessage(
+ LogLevel.Critical,
+ "The consumer for queue {Queue} on {ConnectionName} failed unexpectedly and no longer consumes")]
+ public static partial void ConsumerFailed(
+ this ILogger logger,
+ Exception exception,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(LogLevel.Error, "Stopping the consumer for queue {Queue} on {ConnectionName} failed")]
+ public static partial void ConsumerStopFailed(
+ this ILogger logger,
+ Exception exception,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "Handling message {MessageId} from queue {Queue} on {ConnectionName} failed (consumer {ConsumerTag}, "
+ + "delivery {DeliveryTag}, correlation {CorrelationId})")]
+ public static partial void DeliveryFailed(
+ this ILogger logger,
+ Exception exception,
+ string? messageId,
+ string queue,
+ string connectionName,
+ string consumerTag,
+ ulong deliveryTag,
+ string? correlationId);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "Cancelling the handlers of the consumer for queue {Queue} on {ConnectionName} failed")]
+ public static partial void HandlerCancellationFailed(
+ this ILogger logger,
+ Exception exception,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(LogLevel.Warning, "Deleting queue {Queue} on {ConnectionName} failed")]
+ public static partial void QueueDeleteFailed(
+ this ILogger logger,
+ Exception exception,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "Skipped the {Settlement} of message {MessageId} because its channel closed (consumer {ConsumerTag}, "
+ + "delivery {DeliveryTag}, exchange {Exchange}, routing key {RoutingKey}, redelivered: {Redelivered}). "
+ + "The broker delivers the message again")]
+ public static partial void SettlementSkipped(
+ this ILogger logger,
+ string settlement,
+ string? messageId,
+ string consumerTag,
+ ulong deliveryTag,
+ string exchange,
+ string routingKey,
+ bool redelivered);
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.cs
index f99ee4edc6f..0f6fdfd56f8 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQConsumerManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.Runtime.ExceptionServices;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
@@ -6,520 +7,259 @@
namespace Mocha.Transport.RabbitMQ;
///
-/// Manages RabbitMQ connections and consumers with automatic reconnection.
+/// Owns the RabbitMQ connection that consumes messages, and the consumers on it.
///
-public sealed class RabbitMQConsumerManager : RabbitMQConnectionManagerBase
+internal sealed class RabbitMQConsumerManager : IAsyncDisposable
{
- private ImmutableArray _registeredConsumers = [];
+ private static readonly CreateChannelOptions s_probeChannelOptions = new(
+ publisherConfirmationsEnabled: false,
+ publisherConfirmationTrackingEnabled: false);
- private CancellationTokenSource? _monitoringCts;
- private Task? _monitoringTask;
+ private readonly RabbitMQConnectionManager _connection;
+ private readonly object _sync = new();
+ private ImmutableArray _consumers = [];
+ private Task? _disposeTask;
+ private volatile bool _isDisposed;
///
- /// Creates a new consumer manager that will use the specified connection factory to establish RabbitMQ connections.
+ /// Creates a consumer manager.
///
- /// The logger for connection and consumer lifecycle events.
- /// A factory delegate that creates new RabbitMQ connections on demand.
- public RabbitMQConsumerManager(
- ILogger logger,
- Func> connectionFactory) : base(logger, connectionFactory) { }
+ /// The logger for consumer events.
+ /// The manager of the connection that consumes messages.
+ public RabbitMQConsumerManager(ILogger logger, RabbitMQConnectionManager connection)
+ {
+ Logger = logger;
+ _connection = connection;
+ }
///
- /// Registers a new consumer for the specified queue, creating a dedicated channel with the given prefetch count.
+ /// Gets the logger for consumer events.
///
- ///
- /// If a connection is already established, the consumer is connected immediately.
- /// On reconnection, all registered consumers are automatically re-attached.
- /// Disposing the returned handle unregisters the consumer and closes its channel.
- ///
- /// The name of the RabbitMQ queue to consume from.
- /// The callback invoked for each delivered message, receiving the channel, delivery event args, and a cancellation token.
- /// The maximum number of unacknowledged messages the broker will deliver to this consumer.
- /// The maximum number of messages dispatched concurrently to the consumer callback. When greater than 1, multiple messages are processed in parallel.
- /// A token to cancel the registration operation.
- /// An handle that, when disposed, unregisters the consumer and releases its channel.
- public async Task RegisterConsumerAsync(
- string queueName,
- Func messageHandler,
- ushort prefetchCount,
- ushort consumerDispatchConcurrency,
- CancellationToken cancellationToken)
- {
- var registration = new RegisteredConsumer
- {
- Manager = this,
- QueueName = queueName,
- MessageHandler = messageHandler,
- PrefetchCount = prefetchCount,
- ConsumerDispatchConcurrency = consumerDispatchConcurrency
- };
+ public ILogger Logger { get; }
- AddConsumer(registration);
+ ///
+ /// Gets the manager of the connection that consumes messages.
+ ///
+ public RabbitMQConnectionManager Connection => _connection;
- if (IsConnected)
- {
- await registration.ConnectAsync(CurrentConnection!, cancellationToken);
- }
+ ///
+ /// Gets or sets how long one attempt to attach a consumer may take before it is abandoned and retried.
+ ///
+ public TimeSpan AttachTimeout { get; set; } = TimeSpan.FromSeconds(60);
- Logger.RegisteredConsumerForQueue(queueName);
+ ///
+ /// Gets or sets how long a consumer waits for a handler of a lost channel to return before it reports
+ /// that it is blocked.
+ ///
+ public TimeSpan BlockedGracePeriod { get; set; } = TimeSpan.FromSeconds(30);
- return registration;
- }
+ ///
+ /// Gets whether a ready connection exists.
+ ///
+ public bool IsConnected => _connection.IsConnected;
///
- /// Called after a new RabbitMQ connection is established, either on first connect or after a
- /// connection loss that required creating an entirely new connection.
+ /// Gets whether the connection is ready and every consumer is attached.
///
- ///
- /// This method re-attaches every previously registered consumer to the new connection by
- /// creating fresh channels and restarting consumption. It also starts a background health
- /// monitor that polls every 30 seconds and triggers reconnection when the connection drops.
- /// Because consumers are automatically re-attached, callers of
- /// do not need to handle reconnection themselves.
- ///
- /// The newly created .
- /// A token to cancel the reconnection of consumers.
- /// A task that completes when all consumers have been re-attached and health monitoring has started.
- protected override async Task OnAfterConnectionCreatedAsync(
- IConnection connection,
- CancellationToken cancellationToken)
+ public bool IsHealthy => HealthFailureReason is null;
+
+ ///
+ /// Gets the reason the connection or a consumer is unhealthy, or null when all are healthy.
+ ///
+ public string? HealthFailureReason
{
- // Reconnect existing consumers
- await ReconnectConsumersAsync(connection, cancellationToken);
+ get
+ {
+ if (!_connection.IsConnected)
+ {
+ return "The consumer connection is unavailable.";
+ }
- // Start health monitoring if not already running
- StartHealthMonitoring();
+ foreach (var consumer in _consumers)
+ {
+ if (consumer.HealthFailureReason is { } reason)
+ {
+ return $"Queue '{consumer.QueueName}': {reason}";
+ }
+ }
+
+ return null;
+ }
}
///
- /// Called when the RabbitMQ client library's built-in automatic recovery succeeds and the
- /// existing connection object is restored rather than replaced.
+ /// Connects, as describes.
///
- ///
- /// Even though the client library recovers the connection, channels and consumers may be in
- /// an inconsistent state. This method iterates all registered consumers and reconnects each
- /// one with a fresh channel, ensuring that message delivery resumes correctly after recovery.
- ///
- /// A token to cancel the consumer reconnection.
- /// A task that completes when all consumers have been re-attached to the recovered connection.
- protected override async Task OnConnectionRecoveredAsync(CancellationToken cancellationToken)
+ public Task StartAsync(CancellationToken cancellationToken) => _connection.StartAsync(cancellationToken);
+
+ ///
+ /// Makes one round trip on a new connection, so a connection the broker refuses after the handshake
+ /// does not count as ready.
+ ///
+ public static async Task SetUpConnectionAsync(IConnection connection, CancellationToken cancellationToken)
{
- if (CurrentConnection is not null)
- {
- await ReconnectConsumersAsync(CurrentConnection, cancellationToken);
- }
+ await using var channel = await connection.CreateChannelAsync(s_probeChannelOptions, cancellationToken);
+ await channel.CloseAsync(cancellationToken);
}
- private async Task ReconnectConsumersAsync(IConnection connection, CancellationToken cancellationToken)
+ ///
+ /// Registers a consumer for a queue and waits for its first attempt to attach. The consumer attaches
+ /// again by itself whenever its channel or its connection is lost, until it is stopped.
+ ///
+ /// The name of the queue to consume.
+ ///
+ /// Runs on every new channel of the consumer before it consumes, for example to declare the queue and
+ /// its bindings, or null to consume at once.
+ ///
+ /// Handles one delivered message.
+ /// The largest number of unacknowledged messages the broker delivers.
+ /// The largest number of messages that are handled at the same time.
+ /// Ends the wait for the first attempt and stops the consumer.
+ ///
+ /// The consumer. It is not attached when no connection was ready or the first attempt failed for a
+ /// reason that a later attempt can overcome.
+ ///
+ /// The broker refused the first attempt. The consumer is stopped.
+ public async Task RegisterConsumerAsync(
+ string queueName,
+ Func? prepareAsync,
+ Func messageHandler,
+ ushort maxPrefetch,
+ ushort maxConcurrency,
+ CancellationToken cancellationToken)
{
- var consumers = _registeredConsumers;
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+ cancellationToken.ThrowIfCancellationRequested();
+ ArgumentOutOfRangeException.ThrowIfZero(maxConcurrency);
- if (consumers.IsEmpty)
+ var consumer = new RabbitMQConsumer(this, queueName, prepareAsync, messageHandler, maxPrefetch, maxConcurrency);
+ ImmutableInterlocked.Update(ref _consumers, static (consumers, added) => consumers.Add(added), consumer);
+
+ if (_isDisposed)
{
- return;
+ await consumer.DisposeAsync();
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
}
- Logger.ReconnectConsumersAsync(consumers.Length);
+ Exception? failure;
+ try
+ {
+ failure = await consumer.FirstAttempt.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ await consumer.DisposeAsync();
+ throw;
+ }
+
+ // The disposal of the manager stopped the consumer while it attached.
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
- foreach (var consumer in consumers)
+ if (failure is not null && RabbitMQFailure.IsPermanent(failure))
{
- try
+ using (_connection.BeginLogScope())
{
- await consumer.ConnectAsync(connection, cancellationToken);
- Logger.ReconnectConsumerForQueue(consumer.QueueName);
- }
- catch (Exception ex)
- {
- Logger.FailedToReconnectConsumerForQueue(ex, consumer.QueueName);
+ Logger.ConsumerRegistrationFailed(failure, queueName, _connection.ConnectionName);
}
+
+ // A consumer the broker refuses does not keep attaching in the background.
+ await consumer.DisposeAsync();
+ ExceptionDispatchInfo.Throw(failure);
}
+
+ return consumer;
}
- private void StartHealthMonitoring()
+ ///
+ /// Deletes a queue on the ready connection. Does nothing when there is no ready connection, and logs
+ /// a failure instead of throwing it.
+ ///
+ public async Task DeleteQueueAsync(string queueName, CancellationToken cancellationToken)
{
- if (_monitoringCts is { IsCancellationRequested: false })
+ if (_connection.Connection is not { } connection || cancellationToken.IsCancellationRequested)
{
return;
}
- _monitoringCts = new CancellationTokenSource();
- _monitoringTask = MonitorConnectionHealthAsync(_monitoringCts.Token);
- }
-
- private async Task MonitorConnectionHealthAsync(CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested)
+ // A cancelled open would cost the connection a channel number, so the open runs to its end.
+ var opening = _connection.OpenChannelAsync(connection, s_probeChannelOptions, AttachTimeout);
+ try
{
+ var channel = await opening.WaitOrAbandonAsync(cancellationToken);
try
{
- await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
-
- if (!IsConnected)
- {
- Logger.ConnectionHealthCheckFailedAttemptingReconnection();
-
- try
- {
- await EnsureConnectedAsync(cancellationToken);
- }
- catch (Exception ex)
- {
- Logger.ReconnectionAttemptFailed(ex);
- }
- }
- else
- {
- Logger.ConnectionHealthCheckOk();
- }
+ await channel
+ .QueueDeleteAsync(queueName, cancellationToken: cancellationToken)
+ .WaitOrAbandonAsync(cancellationToken);
}
- catch (OperationCanceledException)
+ finally
{
- break;
+ _ = RabbitMQConnectionManager.CloseChannelAsync(channel);
}
- catch (Exception ex)
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller cancelled the cleanup. A channel that still opens is closed.
+ _ = CloseWhenOpenedAsync(opening);
+ }
+ catch (Exception ex)
+ {
+ using (_connection.BeginLogScope())
{
- Logger.ErrorDuringConnectionHealthCheck(ex);
+ Logger.QueueDeleteFailed(ex, queueName, _connection.ConnectionName);
}
}
}
- private void AddConsumer(RegisteredConsumer consumer)
+ private static async Task CloseWhenOpenedAsync(Task opening)
{
- ImmutableInterlocked.Update(ref _registeredConsumers, consumers => consumers.Add(consumer));
- }
-
- private void RemoveConsumer(RegisteredConsumer consumer)
- {
- ImmutableInterlocked.Update(ref _registeredConsumers, consumers => consumers.Remove(consumer));
- }
-
- protected override async ValueTask DisposeAsyncCore()
- {
- // Stop monitoring
- if (_monitoringCts is not null)
+ try
{
- await _monitoringCts.CancelAsync();
- _monitoringCts.Dispose();
- _monitoringCts = null;
+ await RabbitMQConnectionManager.CloseChannelAsync(await opening);
}
-
- if (_monitoringTask is not null)
+ catch
{
- try
- {
- await _monitoringTask;
- }
- catch
- {
- // Ignore
- }
+ // The channel could not be opened.
}
+ }
- // Dispose all consumers
- var consumers = ImmutableInterlocked.InterlockedExchange(ref _registeredConsumers, []);
+ internal void Remove(RabbitMQConsumer consumer)
+ => ImmutableInterlocked.Update(ref _consumers, static (consumers, removed) => consumers.Remove(removed), consumer);
- foreach (var consumer in consumers)
+ ///
+ /// Stops every consumer without waiting for running handlers, and closes the connection.
+ ///
+ public ValueTask DisposeAsync()
+ {
+ lock (_sync)
{
- try
- {
- await consumer.DisposeAsync();
- }
- catch (Exception ex)
- {
- Logger.ErrorDisposingConsumer(ex, consumer.QueueName);
- }
+ return new ValueTask(_disposeTask ??= DisposeCoreAsync());
}
}
- ///
- /// Represents a consumer registration that maintains a dedicated channel to a specific queue,
- /// supporting reconnection and graceful disposal.
- ///
- internal sealed class RegisteredConsumer : IAsyncDisposable
+ private async Task DisposeCoreAsync()
{
- ///
- /// Gets the owning consumer manager responsible for connection lifecycle.
- ///
- public required RabbitMQConsumerManager Manager { get; init; }
-
- ///
- /// Gets the name of the RabbitMQ queue this consumer is bound to.
- ///
- public required string QueueName { get; init; }
-
- ///
- /// Gets the callback invoked for each message delivered from the queue.
- ///
- public required Func<
- IChannel,
- BasicDeliverEventArgs,
- CancellationToken,
- ValueTask
- > MessageHandler
- { get; init; }
-
- ///
- /// Gets the maximum number of unacknowledged messages the broker delivers to this consumer.
- ///
- public required ushort PrefetchCount { get; init; }
-
- ///
- /// Gets the maximum number of messages dispatched concurrently to the consumer callback.
- ///
- public required ushort ConsumerDispatchConcurrency { get; init; }
-
- ///
- /// Gets or sets the server-assigned consumer tag identifying this consumer on the channel.
- ///
- public string? ConsumerTag { get; set; }
+ // Leaves the caller's lock.
+ await Task.Yield();
+ _isDisposed = true;
- ///
- /// Gets or sets the dedicated channel used by this consumer for message delivery.
- ///
- public IChannel? Channel { get; set; }
-
- private readonly CancellationTokenSource _consumerCts = new();
+ // The consumers stop side by side, so a broker that does not answer delays the disposal only once.
+ await Task.WhenAll(_consumers.Select(StopConsumerAsync));
+ await _connection.DisposeAsync();
+ }
- ///
- /// Establishes a new channel on the given connection, configures QoS prefetch, and starts consuming messages from the queue.
- ///
- ///
- /// Any previously active channel is disconnected before a new one is created.
- /// On failure, the partially created channel is disposed and the consumer state is reset.
- ///
- /// The RabbitMQ connection to create a channel on.
- /// A token to cancel the connect operation.
- public async Task ConnectAsync(IConnection connection, CancellationToken cancellationToken)
+ private async Task StopConsumerAsync(RabbitMQConsumer consumer)
+ {
+ try
{
- await DisconnectAsync(cancellationToken);
-
- IChannel? channel = null;
- try
- {
- var channelOptions = new CreateChannelOptions(
- publisherConfirmationsEnabled: false,
- publisherConfirmationTrackingEnabled: false,
- consumerDispatchConcurrency: ConsumerDispatchConcurrency);
- channel = await connection.CreateChannelAsync(channelOptions, cancellationToken);
- var consumer = new AsyncEventingBasicConsumer(channel);
- consumer.ReceivedAsync += async (_, eventArgs) =>
- await MessageHandler(channel, eventArgs, _consumerCts.Token);
-
- await channel.BasicQosAsync(
- prefetchSize: 0,
- prefetchCount: PrefetchCount, //100
- global: false,
- cancellationToken: cancellationToken);
-
- var tag = await channel.BasicConsumeAsync(
- queue: QueueName,
- autoAck: false,
- consumer: consumer,
- cancellationToken: cancellationToken);
-
- ConsumerTag = tag;
- Channel = channel;
- }
- catch
- {
- if (channel is not null)
- {
- await channel.DisposeAsync();
- }
-
- ConsumerTag = null;
- Channel = null;
- throw;
- }
+ await consumer.DisposeAsync();
}
-
- private async Task DisconnectAsync(CancellationToken cancellationToken)
+ catch (Exception ex)
{
- if (ConsumerTag is not null && Channel is { IsOpen: true })
+ using (_connection.BeginLogScope())
{
- try
- {
- await Channel.BasicCancelAsync(ConsumerTag, cancellationToken: cancellationToken);
- }
- catch
- {
- // Ignore
- }
- }
-
- ConsumerTag = null;
-
- if (Channel is not null)
- {
- try
- {
- if (Channel.IsOpen)
- {
- await Channel.CloseAsync(cancellationToken: cancellationToken);
- }
-
- await Channel.DisposeAsync();
- }
- catch
- {
- // Ignore
- }
-
- Channel = null;
+ Logger.ConsumerStopFailed(ex, consumer.QueueName, _connection.ConnectionName);
}
}
-
- ///
- /// Disconnects the consumer from its channel, unregisters it from the manager, and releases all resources.
- ///
- public async ValueTask DisposeAsync()
- {
- Manager.RemoveConsumer(this);
- await _consumerCts.CancelAsync();
- await DisconnectAsync(CancellationToken.None);
- _consumerCts.Dispose();
- }
}
}
-
-internal static partial class Logs
-{
- [LoggerMessage(LogLevel.Information, "Creating RabbitMQ connection using factory delegate")]
- public static partial void CreatingConnectionUsingFactoryDelegate(this ILogger logger);
-
- [LoggerMessage(LogLevel.Information, "Successfully created connection: {ConnectionName}")]
- public static partial void SuccessfullyCreatedConnection(this ILogger logger, string? connectionName);
-
- [LoggerMessage(LogLevel.Error, "Failed to create connection after {Attempts} attempts")]
- public static partial void FailedToCreateConnectionAfterAttempts(this ILogger logger, Exception ex, int attempts);
-
- [LoggerMessage(
- LogLevel.Warning,
- "Failed to create connection (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}")]
- public static partial void FailedToCreateConnectionRetrying(
- this ILogger logger,
- Exception ex,
- int attempt,
- int maxAttempts,
- TimeSpan delay);
-
- [LoggerMessage(LogLevel.Error, "Error processing message from queue {Queue}")]
- public static partial void ErrorProcessingMessageFromQueue(this ILogger logger, Exception ex, string queue);
-
- [LoggerMessage(LogLevel.Information, "Registered consumer for queue {Queue}")]
- public static partial void RegisteredConsumerForQueue(this ILogger logger, string queue);
-
- [LoggerMessage(LogLevel.Warning, "Cannot provision topology - connection not available")]
- public static partial void CannotProvisionTopologyConnectionNotAvailable(this ILogger logger);
-
- [LoggerMessage(LogLevel.Information, "Provisioning RabbitMQ topology")]
- public static partial void ProvisioningRabbitMQTopology(this ILogger logger);
-
- [LoggerMessage(LogLevel.Debug, "Declared exchange: {Exchange}")]
- public static partial void DeclaredExchange(this ILogger logger, string exchange);
-
- [LoggerMessage(LogLevel.Debug, "Declared queue: {Queue}")]
- public static partial void DeclaredQueue(this ILogger logger, string queue);
-
- [LoggerMessage(LogLevel.Debug, "Created binding: {Queue} -> {Exchange}/{RoutingKey}")]
- public static partial void CreatedBinding(this ILogger logger, string queue, string exchange, string routingKey);
-
- [LoggerMessage(LogLevel.Information, "Successfully provisioned topology")]
- public static partial void SuccessfullyProvisionedTopology(this ILogger logger);
-
- [LoggerMessage(LogLevel.Error, "Failed to provision topology")]
- public static partial void FailedToProvisionTopology(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Information, "Reconnecting {Count} registered consumers")]
- public static partial void ReconnectConsumersAsync(this ILogger logger, int count);
-
- [LoggerMessage(LogLevel.Warning, "Cannot recreate consumer for queue {Queue} - no factory or handler")]
- public static partial void CannotRecreateConsumerNoFactoryOrHandler(this ILogger logger, string queue);
-
- [LoggerMessage(LogLevel.Information, "Reconnected consumer for queue {Queue}")]
- public static partial void ReconnectConsumerForQueue(this ILogger logger, string queue);
-
- [LoggerMessage(LogLevel.Error, "Failed to reconnect consumer for queue {Queue}")]
- public static partial void FailedToReconnectConsumerForQueue(this ILogger logger, Exception ex, string queue);
-
- [LoggerMessage(LogLevel.Information, "Connection closed by application")]
- public static partial void ConnectionClosedByApplication(this ILogger logger);
-
- [LoggerMessage(LogLevel.Warning, "Connection shutdown detected. Initiator: {Initiator}, Reason: {Reason}")]
- public static partial void ConnectionShutdownDetected(
- this ILogger logger,
- ShutdownInitiator initiator,
- string reason);
-
- [LoggerMessage(LogLevel.Error, "Failed to reconnect after shutdown")]
- public static partial void FailedToReconnectAfterShutdown(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Information, "Connection recovery succeeded")]
- public static partial void ConnectionRecoverySucceeded(this ILogger logger);
-
- [LoggerMessage(LogLevel.Information, "Successfully recovered connection, topology, and consumers")]
- public static partial void SuccessfullyRecoveredConnectionTopologyAndConsumers(this ILogger logger);
-
- [LoggerMessage(LogLevel.Error, "Error during post-recovery operations")]
- public static partial void ErrorDuringPostRecoveryOperations(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Information, "Connection recovery started")]
- public static partial void ConnectionRecoveryStarted(this ILogger logger);
-
- [LoggerMessage(LogLevel.Warning, "Connection blocked: {Reason}")]
- public static partial void ConnectionBlocked(this ILogger logger, string reason);
-
- [LoggerMessage(LogLevel.Information, "Connection unblocked")]
- public static partial void ConnectionUnblocked(this ILogger logger);
-
- [LoggerMessage(LogLevel.Warning, "Connection health check failed - attempting reconnection")]
- public static partial void ConnectionHealthCheckFailedAttemptingReconnection(this ILogger logger);
-
- [LoggerMessage(LogLevel.Error, "Reconnection attempt failed")]
- public static partial void ReconnectionAttemptFailed(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Trace, "Connection health check: OK")]
- public static partial void ConnectionHealthCheckOk(this ILogger logger);
-
- [LoggerMessage(LogLevel.Error, "Error during connection health check")]
- public static partial void ErrorDuringConnectionHealthCheck(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Error, "Error disposing connection")]
- public static partial void ErrorDisposingConnection(this ILogger logger, Exception ex);
-
- [LoggerMessage(LogLevel.Information, "DelegateConnectionManager disposed")]
- public static partial void DelegateConnectionManagerDisposed(this ILogger logger);
-
- [LoggerMessage(LogLevel.Error, "Exception in connection callback. Detail: {Detail}")]
- public static partial void ExceptionInConnectionCallback(this ILogger logger, Exception ex, string detail);
-
- [LoggerMessage(LogLevel.Error, "Connection recovery failed. Will retry. Error: {ErrorMessage}")]
- public static partial void ConnectionRecoveryFailedWillRetry(
- this ILogger logger,
- Exception? exception,
- string? errorMessage);
-
- [LoggerMessage(LogLevel.Information, "Consumer tag changed after recovery. Old: {OldTag}, New: {NewTag}")]
- public static partial void ConsumerTagChangedAfterRecovery(this ILogger logger, string oldTag, string newTag);
-
- [LoggerMessage(LogLevel.Debug, "Updated consumer tag for queue {Queue}: {OldTag} -> {NewTag}")]
- public static partial void UpdatedConsumerTagForQueue(
- this ILogger logger,
- string queue,
- string oldTag,
- string newTag);
-
- [LoggerMessage(
- LogLevel.Warning,
- "Server-named queue changed after recovery. Old: {OldName}, New: {NewName}. This may affect consumers expecting the old queue name.")]
- public static partial void ServerNamedQueueChangedAfterRecovery(
- this ILogger logger,
- string oldName,
- string newName);
-
- [LoggerMessage(LogLevel.Information, "Recovering consumer {ConsumerTag}")]
- public static partial void RecoveringConsumer(this ILogger logger, string consumerTag);
-
- [LoggerMessage(LogLevel.Error, "Error disposing consumer {ConsumerTag}")]
- public static partial void ErrorDisposingConsumer(this ILogger logger, Exception ex, string consumerTag);
-}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Logging.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Logging.cs
new file mode 100644
index 00000000000..4c03579806d
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Logging.cs
@@ -0,0 +1,103 @@
+using Microsoft.Extensions.Logging;
+
+namespace Mocha.Transport.RabbitMQ;
+
+internal static partial class Logs
+{
+ [LoggerMessage(
+ LogLevel.Debug,
+ "The broker confirmed message {MessageId} for endpoint {Endpoint} on {ConnectionName} after {Elapsed} "
+ + "(attempt {Attempt})")]
+ public static partial void PublishConfirmed(
+ this ILogger logger,
+ string? messageId,
+ string endpoint,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "The broker confirmed message {MessageId} for endpoint {Endpoint} on {ConnectionName} only after "
+ + "{Elapsed} (attempt {Attempt}), which is longer than {Threshold}")]
+ public static partial void PublishConfirmedSlowly(
+ this ILogger logger,
+ string? messageId,
+ string endpoint,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed,
+ TimeSpan threshold);
+
+ [LoggerMessage(
+ LogLevel.Error,
+ "Publishing message {MessageId} to endpoint {Endpoint} on {ConnectionName} failed after {Elapsed} "
+ + "(attempt {Attempt})")]
+ public static partial void PublishFailed(
+ this ILogger logger,
+ Exception exception,
+ string? messageId,
+ string endpoint,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Debug,
+ "Publishing message {MessageId} to endpoint {Endpoint} on {ConnectionName} was cancelled by the caller "
+ + "after {Elapsed} (attempt {Attempt})")]
+ public static partial void PublishCancelled(
+ this ILogger logger,
+ string? messageId,
+ string endpoint,
+ string connectionName,
+ int attempt,
+ TimeSpan elapsed);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "Publishing message {MessageId} to endpoint {Endpoint} on {ConnectionName} failed (attempt {Attempt}) "
+ + "and is attempted again in {Delay}. The broker delivers the message twice if it already has it. "
+ + "Reason: {Reason}")]
+ public static partial void PublishRetryScheduled(
+ this ILogger logger,
+ string? messageId,
+ string endpoint,
+ string connectionName,
+ int attempt,
+ TimeSpan delay,
+ string reason);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "{Count} publishes on RabbitMQ connection {ConnectionName} have waited for the broker for at least "
+ + "{Interval}. The connection is {ConnectionStatus}")]
+ public static partial void PublishesStalled(
+ this ILogger logger,
+ string connectionName,
+ long count,
+ TimeSpan interval,
+ string connectionStatus);
+
+ [LoggerMessage(LogLevel.Debug, "Opened publisher channel {ChannelNumber} on {ConnectionName}")]
+ public static partial void PublisherChannelOpened(this ILogger logger, string connectionName, int channelNumber);
+
+ [LoggerMessage(
+ LogLevel.Warning,
+ "The broker returned message {MessageId} because queue {Queue} does not exist on {ConnectionName}. "
+ + "Declaring the queue and its bindings again")]
+ public static partial void MessageReturned(
+ this ILogger logger,
+ string? messageId,
+ string queue,
+ string connectionName);
+
+ [LoggerMessage(
+ LogLevel.Information,
+ "Declared queue {Queue} and its bindings again on {ConnectionName}. Publishing message {MessageId} again")]
+ public static partial void QueueRedeclared(
+ this ILogger logger,
+ string queue,
+ string connectionName,
+ string? messageId);
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Publishing.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Publishing.cs
new file mode 100644
index 00000000000..e8824f10ca2
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.Publishing.cs
@@ -0,0 +1,275 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using Mocha.Middlewares;
+using RabbitMQ.Client;
+
+namespace Mocha.Transport.RabbitMQ;
+
+internal sealed partial class RabbitMQDispatcher
+{
+ private static readonly TimeSpan s_slowPublishThreshold = TimeSpan.FromSeconds(10);
+
+ private readonly Stack _deadlines = new();
+ private ITimer? _stalledPublishCheck;
+ private TimeSpan _stalledPublishInterval = TimeSpan.FromSeconds(30);
+ private long _publishesStarted;
+ private long _publishesFinished;
+ private long _startedAtLastCheck;
+ private long _finishedAtLastCheck;
+
+ ///
+ /// Gets or sets how often the dispatcher checks for publishes that wait for the broker. A publish that
+ /// waits for a whole interval while no other publish finishes is reported.
+ ///
+ public TimeSpan StalledPublishInterval
+ {
+ get => _stalledPublishInterval;
+ set
+ {
+ lock (_sync)
+ {
+ _stalledPublishInterval = value;
+ _stalledPublishCheck?.Change(value, value);
+ }
+ }
+ }
+
+ ///
+ /// Counts a publish as in flight and returns the deadline its operations observe, or null when
+ /// the publish timeout is infinite and the operations observe the caller's token.
+ ///
+ public PublishDeadline? StartPublish(CancellationToken callerToken)
+ {
+ Interlocked.Increment(ref _publishesStarted);
+
+ var timeout = PublishTimeout;
+ if (timeout == Timeout.InfiniteTimeSpan)
+ {
+ return null;
+ }
+
+ PublishDeadline? deadline;
+ lock (_deadlines)
+ {
+ _deadlines.TryPop(out deadline);
+ }
+
+ deadline ??= new PublishDeadline();
+ deadline.Start(timeout, callerToken);
+ return deadline;
+ }
+
+ ///
+ /// Counts a publish as finished and takes its deadline back.
+ ///
+ public void FinishPublish(PublishDeadline? deadline)
+ {
+ Interlocked.Increment(ref _publishesFinished);
+
+ if (deadline is null)
+ {
+ return;
+ }
+
+ if (!deadline.TryReset())
+ {
+ deadline.Dispose();
+ return;
+ }
+
+ lock (_deadlines)
+ {
+ if (_deadlines.Count < MaxPublisherChannels)
+ {
+ _deadlines.Push(deadline);
+ return;
+ }
+ }
+
+ deadline.Dispose();
+ }
+
+ ///
+ /// Gets whether a failed publish is attempted again: the failure is transient, the publish was not
+ /// cancelled, and the dispatcher is not disposed.
+ ///
+ public bool CanRetry(Exception exception, CancellationToken cancellationToken)
+ => !cancellationToken.IsCancellationRequested
+ && !IsDisposed
+ && RabbitMQFailure.Classify(exception) == RabbitMQFailureKind.Transient;
+
+ private void StartStalledPublishCheck()
+ {
+ lock (_sync)
+ {
+ if (_isDisposed || _stalledPublishCheck is not null)
+ {
+ return;
+ }
+
+ var interval = _stalledPublishInterval;
+ _stalledPublishCheck = _connection.TimeProvider.CreateTimer(
+ static state => ((RabbitMQDispatcher)state!).CheckForStalledPublishes(),
+ this,
+ interval,
+ interval);
+ }
+ }
+
+ private void StopStalledPublishCheck()
+ {
+ ITimer? timer;
+ lock (_sync)
+ {
+ timer = _stalledPublishCheck;
+ _stalledPublishCheck = null;
+ }
+
+ timer?.Dispose();
+ }
+
+ private void CheckForStalledPublishes()
+ {
+ // Read in this order, the number in flight is never negative.
+ var finished = Volatile.Read(ref _publishesFinished);
+ var started = Volatile.Read(ref _publishesStarted);
+ var stalled = _startedAtLastCheck - _finishedAtLastCheck;
+
+ // No publish finished since the last check, so every publish that was in flight then still is.
+ if (stalled > 0 && finished == _finishedAtLastCheck && _logger.IsEnabled(LogLevel.Warning))
+ {
+ var status = !_connection.IsConnected ? "unavailable"
+ : _connection.IsBlocked ? "blocked by the broker"
+ : "ready";
+
+ using (_connection.BeginLogScope())
+ {
+ _logger.PublishesStalled(_connection.ConnectionName, stalled, StalledPublishInterval, status);
+ }
+ }
+
+ _startedAtLastCheck = started;
+ _finishedAtLastCheck = finished;
+ }
+
+ public void LogPublishConfirmed(IDispatchContext context, int attempt, long started)
+ {
+ var elapsed = Stopwatch.GetElapsedTime(started);
+ var isSlow = elapsed >= s_slowPublishThreshold;
+ if (!_logger.IsEnabled(isSlow ? LogLevel.Warning : LogLevel.Debug))
+ {
+ return;
+ }
+
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ var messageId = context.Envelope?.MessageId;
+ var endpoint = context.Endpoint.Name;
+ if (isSlow)
+ {
+ _logger.PublishConfirmedSlowly(
+ messageId,
+ endpoint,
+ _connection.ConnectionName,
+ attempt,
+ elapsed,
+ s_slowPublishThreshold);
+ }
+ else
+ {
+ _logger.PublishConfirmed(messageId, endpoint, _connection.ConnectionName, attempt, elapsed);
+ }
+ }
+ }
+
+ public void LogPublishFailed(IDispatchContext context, Exception exception, int attempt, long started)
+ {
+ if (!_logger.IsEnabled(LogLevel.Error))
+ {
+ return;
+ }
+
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ _logger.PublishFailed(
+ exception,
+ context.Envelope?.MessageId,
+ context.Endpoint.Name,
+ _connection.ConnectionName,
+ attempt,
+ Stopwatch.GetElapsedTime(started));
+ }
+ }
+
+ public void LogPublishCancelled(IDispatchContext context, int attempt, long started)
+ {
+ if (!_logger.IsEnabled(LogLevel.Debug))
+ {
+ return;
+ }
+
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ _logger.PublishCancelled(
+ context.Envelope?.MessageId,
+ context.Endpoint.Name,
+ _connection.ConnectionName,
+ attempt,
+ Stopwatch.GetElapsedTime(started));
+ }
+ }
+
+ public void LogPublishRetry(IDispatchContext context, Exception exception, int attempt, TimeSpan delay)
+ {
+ if (!_logger.IsEnabled(LogLevel.Information))
+ {
+ return;
+ }
+
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ _logger.PublishRetryScheduled(
+ context.Envelope?.MessageId,
+ context.Endpoint.Name,
+ _connection.ConnectionName,
+ attempt,
+ delay,
+ $"{exception.GetType().Name}: {exception.Message}");
+ }
+ }
+
+ public void LogMessageReturned(IDispatchContext context, string queueName)
+ {
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ _logger.MessageReturned(context.Envelope?.MessageId, queueName, _connection.ConnectionName);
+ }
+ }
+
+ public void LogQueueRedeclared(IDispatchContext context, string queueName)
+ {
+ using (_connection.BeginLogScope())
+ using (_logger.BeginMessageScope(context))
+ {
+ _logger.QueueRedeclared(queueName, _connection.ConnectionName, context.Envelope?.MessageId);
+ }
+ }
+
+ private void LogPublisherChannelOpened(IChannel channel)
+ {
+ if (!_logger.IsEnabled(LogLevel.Debug))
+ {
+ return;
+ }
+
+ using (_connection.BeginLogScope())
+ {
+ _logger.PublisherChannelOpened(_connection.ConnectionName, channel.ChannelNumber);
+ }
+ }
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.cs
index c0a943f774f..4e769779cc1 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQDispatcher.cs
@@ -1,159 +1,407 @@
-using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
namespace Mocha.Transport.RabbitMQ;
///
-/// Manages RabbitMQ connections and provides channel pooling for publishing messages.
+/// Owns the RabbitMQ connection that publishes messages, and leases its channels to one publish at a time.
///
-public sealed class RabbitMQDispatcher(
- ILogger logger,
- Func> connectionFactory,
- Func onConnectionEstablished)
- : RabbitMQConnectionManagerBase(logger, connectionFactory)
+internal sealed partial class RabbitMQDispatcher : IAsyncDisposable
{
- private const int MaxPooledChannels = 10;
+ ///
+ /// The largest number of publisher channels per connection, which is also the largest number of
+ /// publishes in flight.
+ ///
+ internal const int MaxPublisherChannels = 100;
- private readonly ConcurrentQueue _channelPool = new();
- private int _pooledChannelCount;
+ private static readonly CreateChannelOptions s_publishChannelOptions = new(
+ publisherConfirmationsEnabled: true,
+ publisherConfirmationTrackingEnabled: true,
+ outstandingPublisherConfirmationsRateLimiter: null);
+
+ private readonly ILogger _logger;
+ private readonly RabbitMQConnectionManager _connection;
+ private readonly object _sync = new();
+ private PublisherChannelPool? _pool;
+ private bool _isDisposed;
///
- /// Rents a channel from the pool or creates a new one.
- /// The caller is responsible for returning the channel via ReturnChannelAsync.
+ /// Creates a dispatcher.
///
- public async ValueTask RentChannelAsync(CancellationToken cancellationToken = default)
+ /// The logger for publish events.
+ /// The manager of the connection that publishes messages.
+ public RabbitMQDispatcher(ILogger logger, RabbitMQConnectionManager connection)
{
- ObjectDisposedException.ThrowIf(IsDisposed, this);
+ _logger = logger;
+ _connection = connection;
+ }
- // Try to get a channel from the pool
- while (_channelPool.TryDequeue(out var pooledChannel))
- {
- Interlocked.Decrement(ref _pooledChannelCount);
+ ///
+ /// Gets the manager of the connection that publishes messages.
+ ///
+ public RabbitMQConnectionManager Connection => _connection;
+
+ ///
+ /// Gets whether a ready connection exists.
+ ///
+ public bool IsConnected => _connection.IsConnected;
+
+ ///
+ /// Gets whether the dispatcher was disposed, after which no publish can succeed.
+ ///
+ public bool IsDisposed => Volatile.Read(ref _isDisposed);
+
+ ///
+ /// Gets or sets how long opening a publisher channel may take before its connection is closed and replaced.
+ ///
+ public TimeSpan ChannelOpenTimeout { get; set; } = TimeSpan.FromSeconds(60);
- if (pooledChannel.IsOpen)
+ ///
+ /// Gets or sets how long a publish waits for the broker's confirmation, including the wait for a
+ /// connection and all retries. waits until the caller cancels.
+ ///
+ public TimeSpan PublishTimeout { get; set; } = TimeSpan.FromMinutes(2);
+
+ ///
+ /// Connects and starts the stalled publish check.
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await _connection.StartAsync(cancellationToken);
+ StartStalledPublishCheck();
+ }
+
+ ///
+ /// Leases a channel of the ready connection to one publish, waiting while there is no connection or all
+ /// channels are leased. The caller gives the lease back with .
+ ///
+ public async ValueTask RentChannelAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ var connection = await _connection.GetConnectionAsync(cancellationToken);
+ if (GetPool(connection) is not { } pool)
{
- Logger.RentedChannelFromPool();
+ // The connection was replaced in the meantime.
+ continue;
+ }
+
+ await pool.AcquireAsync(cancellationToken);
- return pooledChannel;
+ if (pool.IsRetired)
+ {
+ // The connection was replaced while this publish waited. The other waiters of the pool follow.
+ pool.Release(channel: null, reusable: false);
+ continue;
}
- // Channel is closed, dispose it
- await DisposeChannelSafelyAsync(pooledChannel);
+ var ownsPermit = true;
+ try
+ {
+ while (true)
+ {
+ while (pool.TryTakeIdle(out var idle))
+ {
+ if (idle.IsOpen)
+ {
+ return new PublisherLease(pool, idle);
+ }
+
+ _ = RabbitMQConnectionManager.CloseChannelAsync(idle);
+ }
+
+ if (!_connection.IsBlocked)
+ {
+ break;
+ }
+
+ // The broker does not read from a blocked connection, so it would not answer a request to
+ // open a channel. The publish waits here and takes a channel that became idle meanwhile.
+ await _connection.WaitWhileBlockedAsync(cancellationToken);
+ if (pool.IsRetired || !ReferenceEquals(_connection.Connection, connection))
+ {
+ break;
+ }
+ }
+
+ if (pool.IsRetired || !ReferenceEquals(_connection.Connection, connection))
+ {
+ // The connection was lost while this publish waited.
+ ownsPermit = false;
+ pool.Release(channel: null, reusable: false);
+ continue;
+ }
+
+ var opening = _connection.OpenChannelAsync(connection, s_publishChannelOptions, ChannelOpenTimeout);
+ try
+ {
+ var channel = await opening.WaitAsync(cancellationToken);
+ LogPublisherChannelOpened(channel);
+ return new PublisherLease(pool, channel);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller gave up. The channel keeps its permit until it is open and goes to the pool.
+ ownsPermit = false;
+ _ = ReturnWhenOpenedAsync(pool, opening);
+ throw;
+ }
+ catch (ChannelOpenBlockedException)
+ {
+ ownsPermit = false;
+ OnChannelNumberLost(pool);
+ throw;
+ }
+ }
+ catch when (ownsPermit)
+ {
+ pool.Release(channel: null, reusable: false);
+ throw;
+ }
}
+ }
- // No valid pooled channel, create a new one
- var connection = await GetConnectionAsync(cancellationToken);
- var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken);
+ ///
+ /// Gives a lease back. The channel is leased again only when is true,
+ /// which requires that the broker answered the last publish on it. Otherwise the channel is closed.
+ ///
+ public void Return(PublisherLease lease, bool reusable) => lease.Pool.Release(lease.Channel, reusable);
- Logger.CreatedNewChannel();
+ private async Task ReturnWhenOpenedAsync(PublisherChannelPool pool, Task opening)
+ {
+ IChannel? channel = null;
+ try
+ {
+ channel = await opening;
+ }
+ catch (ChannelOpenBlockedException)
+ {
+ OnChannelNumberLost(pool);
+ return;
+ }
+ catch
+ {
+ // The channel could not be opened.
+ }
- return channel;
+ pool.Release(channel, reusable: channel is not null);
}
///
- /// Returns a channel to the pool for reuse.
- /// If the channel is closed or the pool is full, the channel is disposed.
+ /// Keeps the permit of a channel number the client lost to a request the blocked connection did not
+ /// answer, so the connection never has more channels than permits. A connection that lost half of its
+ /// channels this way is replaced.
///
- public async ValueTask ReturnChannelAsync(IChannel channel)
+ private void OnChannelNumberLost(PublisherChannelPool pool)
{
- if (IsDisposed || !channel.IsOpen)
+ if (pool.LoseChannelNumber())
{
- await DisposeChannelSafelyAsync(channel);
-
- return;
+ _connection.ReportUnresponsive(
+ pool.Connection,
+ "half of its publisher channels were lost to requests the broker did not answer while it blocked the connection");
}
+ }
- // Check if pool has room
- if (Interlocked.Increment(ref _pooledChannelCount) <= MaxPooledChannels)
+ private PublisherChannelPool? GetPool(IConnection connection)
+ {
+ var pool = Volatile.Read(ref _pool);
+ if (pool is not null && ReferenceEquals(pool.Connection, connection))
{
- _channelPool.Enqueue(channel);
-
- Logger.ReturnedChannelToPool(_pooledChannelCount);
+ return pool;
}
- else
+
+ lock (_sync)
{
- // Pool is full, dispose the channel
- Interlocked.Decrement(ref _pooledChannelCount);
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
- await DisposeChannelSafelyAsync(channel);
+ pool = _pool;
+ if (pool is not null && ReferenceEquals(pool.Connection, connection))
+ {
+ return pool;
+ }
- Logger.ChannelPoolFullDisposedChannel();
+ // Only the ready connection gets a pool, so a caller that still holds a lost connection asks again.
+ if (!ReferenceEquals(_connection.Connection, connection))
+ {
+ return null;
+ }
+
+ // The pool of a lost connection retired itself when the connection shut down.
+ pool?.Retire();
+ pool = new PublisherChannelPool(connection, MaxPublisherChannels);
+ Volatile.Write(ref _pool, pool);
+ return pool;
}
}
- protected override async Task OnConnectionEstablished(IConnection connection, CancellationToken cancellationToken)
+ ///
+ /// Stops the stalled publish check, closes the publisher channels and closes the connection.
+ ///
+ public async ValueTask DisposeAsync()
{
- await onConnectionEstablished(connection, cancellationToken);
- }
+ PublisherChannelPool? pool;
+ lock (_sync)
+ {
+ _isDisposed = true;
+ pool = _pool;
+ _pool = null;
+ }
- protected override async Task OnBeforeConnectionCreatedAsync(CancellationToken cancellationToken)
- {
- await ClearChannelPoolAsync();
+ StopStalledPublishCheck();
+ pool?.Retire();
+ await _connection.DisposeAsync();
}
+}
- protected override async Task OnConnectionLostAsync()
- {
- await ClearChannelPoolAsync();
- }
+///
+/// A publisher channel that is leased to one publish.
+///
+/// The pool the channel belongs to.
+/// The leased channel, which has publisher confirms enabled.
+internal readonly record struct PublisherLease(PublisherChannelPool Pool, IChannel Channel)
+{
+ ///
+ /// Gets the connection the channel belongs to.
+ ///
+ public IConnection Connection => Pool.Connection;
+}
+
+///
+/// The publisher channels of one connection. A permit bounds the channels and the publishes in flight, and
+/// channels the broker answered on last wait on a stack for the next publish.
+///
+internal sealed class PublisherChannelPool
+{
+ // Larger than any number of waiters, and far from an overflow of the permit count.
+ private const int AllWaiters = 1 << 30;
- protected override async Task OnConnectionRecoveredAsync(CancellationToken cancellationToken)
+ private readonly SemaphoreSlim _permits;
+ private readonly Stack _idle;
+ private readonly int _capacity;
+ private int _lostChannelNumbers;
+ private bool _isRetired;
+
+ public PublisherChannelPool(IConnection connection, int maxChannels)
{
- await ClearChannelPoolAsync();
+ Connection = connection;
+
+ // A channel maximum of zero means that the broker sets no limit.
+ _capacity = connection.ChannelMax == 0 ? maxChannels : Math.Min(maxChannels, connection.ChannelMax);
+ _permits = new SemaphoreSlim(_capacity);
+ _idle = new Stack(_capacity);
+
+ connection.ConnectionShutdownAsync += (_, _) =>
+ {
+ Retire();
+ return Task.CompletedTask;
+ };
+
+ // A connection that closed before the subscription raised its shutdown event to nobody.
+ if (!connection.IsOpen)
+ {
+ Retire();
+ }
}
- private async Task ClearChannelPoolAsync()
+ ///
+ /// Gets the connection the channels belong to.
+ ///
+ public IConnection Connection { get; }
+
+ ///
+ /// Gets whether the connection was lost or the dispatcher was disposed, after which the pool leases
+ /// no channel.
+ ///
+ public bool IsRetired => Volatile.Read(ref _isRetired);
+
+ ///
+ /// Takes a permit, waiting while all are taken. The wait also ends when the pool is retired.
+ ///
+ public ValueTask AcquireAsync(CancellationToken cancellationToken)
+ => _permits.Wait(0, CancellationToken.None)
+ ? ValueTask.CompletedTask
+ : new ValueTask(_permits.WaitAsync(cancellationToken));
+
+ ///
+ /// Takes the channel that was returned last, if there is one.
+ ///
+ public bool TryTakeIdle(out IChannel channel)
{
- while (_channelPool.TryDequeue(out var channel))
+ lock (_idle)
{
- Interlocked.Decrement(ref _pooledChannelCount);
- await DisposeChannelSafelyAsync(channel);
+ return _idle.TryPop(out channel!);
}
-
- Logger.ClearedChannelPool();
}
- private static async ValueTask DisposeChannelSafelyAsync(IChannel channel)
+ ///
+ /// Gives a permit back, and keeps its channel for the next publish when it is reusable and still open.
+ /// A channel that is not kept is closed first, so the connection never has more channels than permits.
+ ///
+ public void Release(IChannel? channel, bool reusable)
{
- try
+ if (channel is not null)
{
- if (channel.IsOpen)
+ var isKept = false;
+ if (reusable && channel.IsOpen)
{
- await channel.CloseAsync();
+ lock (_idle)
+ {
+ if (!_isRetired)
+ {
+ _idle.Push(channel);
+ isKept = true;
+ }
+ }
}
- await channel.DisposeAsync();
- }
- catch
- {
- // Ignore disposal errors
+ if (!isKept)
+ {
+ _ = CloseAndReleaseAsync(channel);
+ return;
+ }
}
+
+ _permits.Release();
}
- protected override async ValueTask DisposeAsyncCore()
+ private async Task CloseAndReleaseAsync(IChannel channel)
{
- await ClearChannelPoolAsync();
+ // The permit follows the channel number, which the client gives back when the close has completed.
+ await RabbitMQConnectionManager.CloseChannelCompletelyAsync(channel);
+ _permits.Release();
}
-}
-
-// Add these log messages to your Logs class
-internal static partial class Logs
-{
- [LoggerMessage(LogLevel.Debug, "Rented channel from pool")]
- public static partial void RentedChannelFromPool(this ILogger logger);
- [LoggerMessage(LogLevel.Debug, "Created new channel")]
- public static partial void CreatedNewChannel(this ILogger logger);
+ ///
+ /// Keeps a permit for good, because the client lost its channel number. Returns true when half of
+ /// the permits are gone.
+ ///
+ public bool LoseChannelNumber() => Interlocked.Increment(ref _lostChannelNumbers) * 2 >= _capacity;
- [LoggerMessage(LogLevel.Debug, "Returned channel to pool. Pool size: {PoolSize}")]
- public static partial void ReturnedChannelToPool(this ILogger logger, int poolSize);
+ ///
+ /// Closes the idle channels and ends every wait for a permit. Channels that are returned later are
+ /// closed as well.
+ ///
+ public void Retire()
+ {
+ IChannel[] idle;
+ lock (_idle)
+ {
+ if (_isRetired)
+ {
+ return;
+ }
- [LoggerMessage(LogLevel.Debug, "Channel pool full, disposed channel")]
- public static partial void ChannelPoolFullDisposedChannel(this ILogger logger);
+ _isRetired = true;
+ idle = _idle.ToArray();
+ _idle.Clear();
+ }
- [LoggerMessage(LogLevel.Debug, "Cleared channel pool")]
- public static partial void ClearedChannelPool(this ILogger logger);
+ // Whoever takes a permit of a retired pool gives it back and asks for the pool of the next connection.
+ _permits.Release(AllWaiters);
- [LoggerMessage(LogLevel.Information, "RabbitMQ Dispatcher disposed")]
- public static partial void DispatcherDisposed(this ILogger logger);
+ foreach (var channel in idle)
+ {
+ _ = RabbitMQConnectionManager.CloseChannelAsync(channel);
+ }
+ }
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQFailure.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQFailure.cs
new file mode 100644
index 00000000000..bc3d9ff062a
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Connection/RabbitMQFailure.cs
@@ -0,0 +1,88 @@
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Exceptions;
+
+namespace Mocha.Transport.RabbitMQ;
+
+///
+/// Identifies whether a failed RabbitMQ operation can succeed on a later attempt.
+///
+internal enum RabbitMQFailureKind
+{
+ ///
+ /// The connection or the broker is unavailable, and a later attempt can succeed.
+ ///
+ Transient,
+
+ ///
+ /// The broker or the configuration refuses the operation, and it fails the same way on every attempt.
+ ///
+ Permanent
+}
+
+///
+/// Classifies failures of RabbitMQ operations for connection startup, recovery logging and publish retries.
+///
+internal static class RabbitMQFailure
+{
+ ///
+ /// Gets whether a later attempt of the failed operation can succeed. Callers handle their own
+ /// cancellation before they ask.
+ ///
+ public static RabbitMQFailureKind Classify(Exception exception)
+ {
+ for (var current = exception; current is not null; current = current.InnerException)
+ {
+ switch (current)
+ {
+ // One entry per broker node. A node that is only unreachable keeps the failure transient.
+ case AggregateException { InnerExceptions.Count: > 0 } nodes:
+ foreach (var node in nodes.InnerExceptions)
+ {
+ if (Classify(node) == RabbitMQFailureKind.Transient)
+ {
+ return RabbitMQFailureKind.Transient;
+ }
+ }
+
+ return RabbitMQFailureKind.Permanent;
+
+ case AuthenticationFailureException or AuthenticationException or PublishException:
+ return RabbitMQFailureKind.Permanent;
+
+ // The broker closed the channel or the connection and said why.
+ case OperationInterruptedException { ShutdownReason: { Initiator: ShutdownInitiator.Peer } reason }:
+ return reason.ReplyCode is Constants.ConnectionForced or Constants.InternalError
+ ? RabbitMQFailureKind.Transient
+ : RabbitMQFailureKind.Permanent;
+ }
+ }
+
+ return exception
+ is IOException
+ or SocketException
+ or TimeoutException
+ or OperationCanceledException
+ or ObjectDisposedException
+ or ChannelClosedException
+ or OperationInterruptedException
+ or ConnectFailureException
+ ? RabbitMQFailureKind.Transient
+ : RabbitMQFailureKind.Permanent;
+ }
+
+ ///
+ /// Gets whether the failed operation fails the same way on every attempt.
+ ///
+ public static bool IsPermanent(Exception exception) => Classify(exception) == RabbitMQFailureKind.Permanent;
+
+ ///
+ /// Gets the level a failed attempt that is retried is logged at: for a
+ /// transient failure, and for a permanent one.
+ ///
+ public static LogLevel GetLogLevel(Exception exception)
+ => IsPermanent(exception) ? LogLevel.Error : LogLevel.Warning;
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQMessagingTransportDescriptor.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQMessagingTransportDescriptor.cs
index 1a48a670abe..de10ecb0930 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQMessagingTransportDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQMessagingTransportDescriptor.cs
@@ -27,6 +27,26 @@ public interface IRabbitMQMessagingTransportDescriptor
IRabbitMQMessagingTransportDescriptor ConnectionProvider(
Func connectionFactory);
+ ///
+ /// Sets how long the transport keeps retrying while the broker cannot be reached before its start fails.
+ /// The default is two minutes, and fails the start after the first failed attempt.
+ ///
+ /// How long to keep retrying, between zero and 4,294,967,294 milliseconds.
+ /// The descriptor for method chaining.
+ IRabbitMQMessagingTransportDescriptor ConnectTimeout(TimeSpan timeout);
+
+ ///
+ /// Sets how long a publish waits for the broker's confirmation, including the wait for a connection and
+ /// every retry, before it fails with . The default is two minutes,
+ /// and waits until the caller cancels.
+ ///
+ ///
+ /// How long to wait, more than zero and at most 4,294,967,294 milliseconds, or
+ /// .
+ ///
+ /// The descriptor for method chaining.
+ IRabbitMQMessagingTransportDescriptor PublishTimeout(TimeSpan timeout);
+
///
/// Configures bus-level defaults that are applied to all auto-provisioned queues and exchanges.
///
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQQueueDescriptor.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQQueueDescriptor.cs
index 3a7076b5b87..855bb4d93fe 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQQueueDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQQueueDescriptor.cs
@@ -16,7 +16,8 @@ public interface IRabbitMQQueueDescriptor
IRabbitMQQueueDescriptor Durable(bool durable = true);
///
- /// Configures the backing queue as a quorum queue for high availability and data safety.
+ /// Configures a quorum queue.
+ /// Delivery limits are determined by RabbitMQ unless explicitly configured.
///
/// The descriptor for method chaining.
IRabbitMQQueueDescriptor Quorum();
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQMessagingTransportDescriptor.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQMessagingTransportDescriptor.cs
index 4cb99dcd694..4e4def48685 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQMessagingTransportDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQMessagingTransportDescriptor.cs
@@ -149,6 +149,31 @@ public IRabbitMQMessagingTransportDescriptor ConnectionProvider(
return this;
}
+ ///
+ public IRabbitMQMessagingTransportDescriptor ConnectTimeout(TimeSpan timeout)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(timeout, TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+
+ Configuration.ConnectTimeout = timeout;
+
+ return this;
+ }
+
+ ///
+ public IRabbitMQMessagingTransportDescriptor PublishTimeout(TimeSpan timeout)
+ {
+ if (timeout != Timeout.InfiniteTimeSpan)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(timeout, TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+ }
+
+ Configuration.PublishTimeout = timeout;
+
+ return this;
+ }
+
///
public IRabbitMQMessagingTransportDescriptor ConfigureDefaults(Action configure)
{
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQQueueDescriptor.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQQueueDescriptor.cs
index 262ff930e6e..111280eb939 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQQueueDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/RabbitMQQueueDescriptor.cs
@@ -156,7 +156,7 @@ public IRabbitMQQueueDescriptor UseReceive(
{
if (before is not null && after is not null)
{
- throw ThrowHelper.BeforeAndAfterConflict();
+ throw global::Mocha.ThrowHelper.BeforeAndAfterConflict();
}
if (before is null && after is null)
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Extensions/RequestTaskExtensions.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Extensions/RequestTaskExtensions.cs
new file mode 100644
index 00000000000..4141f23720e
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Extensions/RequestTaskExtensions.cs
@@ -0,0 +1,58 @@
+namespace Mocha.Transport.RabbitMQ;
+
+internal static class RequestTaskExtensions
+{
+ ///
+ /// Waits for a request to the broker, and stops waiting when is
+ /// cancelled. RabbitMQ.Client does not end a request with a token once the request is sent, so the
+ /// abandoned request stays in flight, and its channel must not be used again.
+ ///
+ public static Task WaitOrAbandonAsync(this Task request, CancellationToken cancellationToken)
+ {
+ return request.IsCompleted || !cancellationToken.CanBeCanceled
+ ? request
+ : WaitAsync(request, cancellationToken);
+
+ static async Task WaitAsync(Task request, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await request.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ ObserveFailure(request);
+ throw;
+ }
+ }
+ }
+
+ ///
+ public static Task WaitOrAbandonAsync(this Task request, CancellationToken cancellationToken)
+ {
+ return request.IsCompleted || !cancellationToken.CanBeCanceled
+ ? request
+ : WaitAsync(request, cancellationToken);
+
+ static async Task WaitAsync(Task request, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await request.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ ObserveFailure(request);
+ throw;
+ }
+ }
+ }
+
+ // A failure of the abandoned request is read, so it does not surface as an unobserved task exception.
+ private static void ObserveFailure(Task request)
+ => request.ContinueWith(
+ static task => _ = task.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Middlewares/Receive/RabbitMQAcknowledgementMiddleware.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Middlewares/Receive/RabbitMQAcknowledgementMiddleware.cs
index 6072c2be2ca..ac672fc6b57 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Middlewares/Receive/RabbitMQAcknowledgementMiddleware.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Middlewares/Receive/RabbitMQAcknowledgementMiddleware.cs
@@ -1,5 +1,10 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Mocha.Middlewares;
using Mocha.Transport.RabbitMQ.Features;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+using RabbitMQ.Client.Exceptions;
namespace Mocha.Transport.RabbitMQ.Middlewares;
@@ -7,7 +12,7 @@ namespace Mocha.Transport.RabbitMQ.Middlewares;
/// Receive middleware that sends a BasicAck on successful processing and a BasicNack (with requeue) on failure,
/// ensuring messages are properly acknowledged or returned to the broker.
///
-internal sealed class RabbitMQAcknowledgementMiddleware
+internal sealed class RabbitMQAcknowledgementMiddleware(ILogger logger)
{
///
/// Invokes the next middleware in the pipeline and acknowledges or negatively acknowledges the message based on the outcome.
@@ -19,37 +24,81 @@ public async ValueTask InvokeAsync(IReceiveContext context, ReceiveDelegate next
var feature = context.Features.GetOrSet();
var channel = feature.Channel;
var eventArgs = feature.EventArgs;
-
try
{
await next(context);
-
- // Once next returns the handler's work is committed, so the ack is sent without the
- // shutdown token. A cancelled handler throws and is nacked with requeue instead.
- if (channel.IsOpen)
- {
- await channel.BasicAckAsync(eventArgs.DeliveryTag, false, CancellationToken.None);
- }
}
catch
{
- if (channel.IsOpen)
+ await TrySettleAsync(context, channel, eventArgs, ack: false);
+ throw;
+ }
+
+ // Once next returns the handler's work is committed, so the ack is sent without the shutdown token.
+ await TrySettleAsync(context, channel, eventArgs, ack: true);
+ }
+
+ private async ValueTask TrySettleAsync(
+ IReceiveContext context,
+ IChannel channel,
+ BasicDeliverEventArgs eventArgs,
+ bool ack)
+ {
+ if (channel.IsOpen)
+ {
+ try
{
- await channel.BasicNackAsync(eventArgs.DeliveryTag, false, true, CancellationToken.None);
- }
+ if (ack)
+ {
+ await channel.BasicAckAsync(eventArgs.DeliveryTag, false, CancellationToken.None);
+ }
+ else
+ {
+ await channel.BasicNackAsync(eventArgs.DeliveryTag, false, true, CancellationToken.None);
+ }
- throw;
+ return;
+ }
+ catch (Exception ex) when (ex is AlreadyClosedException or ObjectDisposedException)
+ {
+ // The channel closed after the check above.
+ }
}
+
+ LogSkippedSettlement(context, eventArgs, ack ? "ack" : "nack");
}
- private static readonly RabbitMQAcknowledgementMiddleware s_instance = new();
+ private void LogSkippedSettlement(IReceiveContext context, BasicDeliverEventArgs delivery, string settlement)
+ {
+ if (!logger.IsEnabled(LogLevel.Information))
+ {
+ return;
+ }
+
+ using ((context.Transport as RabbitMQMessagingTransport)?.ConsumerManager.Connection.BeginLogScope())
+ using (logger.BeginMessageScope(context))
+ {
+ logger.SettlementSkipped(
+ settlement,
+ delivery.BasicProperties.MessageId ?? context.MessageId,
+ delivery.ConsumerTag,
+ delivery.DeliveryTag,
+ delivery.Exchange,
+ delivery.RoutingKey,
+ delivery.Redelivered);
+ }
+ }
///
- /// Creates a that wraps the acknowledgement middleware singleton.
+ /// Creates middleware that acknowledges handled deliveries and requeues failed deliveries.
///
- /// A middleware configuration keyed as "RabbitMQAcknowledgement".
public static ReceiveMiddlewareConfiguration Create()
=> new(
- static (_, next) => ctx => s_instance.InvokeAsync(ctx, next),
+ static (context, next) =>
+ {
+ var middleware = new RabbitMQAcknowledgementMiddleware(
+ context.Services.GetRequiredService>());
+ return ctx => middleware.InvokeAsync(ctx, next);
+ },
"RabbitMQAcknowledgement");
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Mocha.Transport.RabbitMQ.csproj b/src/Mocha/src/Mocha.Transport.RabbitMQ/Mocha.Transport.RabbitMQ.csproj
index 27fddde27c1..61d1f295bfb 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Mocha.Transport.RabbitMQ.csproj
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Mocha.Transport.RabbitMQ.csproj
@@ -9,7 +9,12 @@
-
+
+
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs
index 6918ff6f0c1..14ddc0ecce9 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs
@@ -1,17 +1,25 @@
+using System.Diagnostics;
using Mocha.Middlewares;
+using Mocha.Threading;
using RabbitMQ.Client;
+using RabbitMQ.Client.Exceptions;
using static System.StringSplitOptions;
namespace Mocha.Transport.RabbitMQ;
///
/// RabbitMQ dispatch endpoint that publishes outbound messages to a target queue or exchange
-/// using pooled channels from the transport's dispatcher.
+/// and waits for the broker to confirm each message.
///
/// The owning RabbitMQ transport instance.
public sealed class RabbitMQDispatchEndpoint(RabbitMQMessagingTransport transport)
: DispatchEndpoint(transport)
{
+ private static readonly TimeSpan s_initialRetryDelay = TimeSpan.FromMilliseconds(250);
+ private static readonly TimeSpan s_maxRetryDelay = TimeSpan.FromSeconds(5);
+
+ private volatile bool _isProvisioned;
+
///
/// Gets the target queue for this endpoint, or null if the endpoint targets an exchange.
///
@@ -24,27 +32,183 @@ public sealed class RabbitMQDispatchEndpoint(RabbitMQMessagingTransport transpor
protected override async ValueTask DispatchAsync(IDispatchContext context)
{
- if (context.Envelope is not { } envelope)
+ var envelope = context.Envelope ?? throw ThrowHelper.EnvelopeRequired();
+ var dispatcher = transport.Dispatcher;
+ var callerToken = context.CancellationToken;
+ var started = Stopwatch.GetTimestamp();
+ var attempt = 1;
+ Exception? lastFailure = null;
+
+ var deadline = dispatcher.StartPublish(callerToken);
+ var cancellationToken = deadline?.Token ?? callerToken;
+
+ try
+ {
+ while (true)
+ {
+ try
+ {
+ await PublishOnceAsync(context, envelope, cancellationToken);
+ break;
+ }
+ catch (Exception ex) when (dispatcher.CanRetry(ex, cancellationToken))
+ {
+ // The message may have reached the broker, so the next attempt can deliver it twice.
+ lastFailure = ex;
+ var delay = ExponentialBackoff.CalculateDelay(attempt, s_initialRetryDelay, s_maxRetryDelay);
+ dispatcher.LogPublishRetry(context, ex, attempt, delay);
+ attempt++;
+
+ // The next attempt waits for a connection by itself. The delay keeps a publish that fails
+ // on a ready connection from spinning.
+ await Task.Delay(delay, cancellationToken);
+ }
+ }
+
+ dispatcher.LogPublishConfirmed(context, attempt, started);
+ }
+ catch (Exception ex)
+ {
+ if (callerToken.IsCancellationRequested)
+ {
+ dispatcher.LogPublishCancelled(context, attempt, started);
+ if (ex is OperationCanceledException)
+ {
+ throw ThrowHelper.PublishCancelled(ex, callerToken);
+ }
+
+ throw;
+ }
+
+ if (deadline is { IsCancellationRequested: true } && !dispatcher.IsDisposed)
+ {
+ var timedOut = ThrowHelper.PublishTimedOut(dispatcher.PublishTimeout, lastFailure ?? ex);
+ dispatcher.LogPublishFailed(context, timedOut, attempt, started);
+ throw timedOut;
+ }
+
+ dispatcher.LogPublishFailed(context, ex, attempt, started);
+ throw;
+ }
+ finally
{
- throw new InvalidOperationException("Envelope is not set");
+ dispatcher.FinishPublish(deadline);
}
+ }
+ ///
+ /// Publishes the message on a leased channel and waits for the broker's answer. The channel goes back
+ /// to the pool only when the broker answered: with a confirmation, a rejection or a return.
+ ///
+ private async ValueTask PublishOnceAsync(
+ IDispatchContext context,
+ MessageEnvelope envelope,
+ CancellationToken cancellationToken)
+ {
var dispatcher = transport.Dispatcher;
- var cancellationToken = context.CancellationToken;
var timeProvider = context.Services.GetTimeProvider();
- var channel = await dispatcher.RentChannelAsync(cancellationToken);
+
+ // A request is published once its reply queue exists and is consumed, so the reply cannot be dropped.
+ if (transport.ReplyReceiveEndpoint is RabbitMQReceiveEndpoint replyEndpoint
+ && envelope.ResponseAddress == replyEndpoint.Source.Address.ToString())
+ {
+ await replyEndpoint.WaitUntilAttachedAsync(cancellationToken);
+ }
+
+ var lease = await dispatcher.RentChannelAsync(cancellationToken);
+ var isChannelReusable = false;
try
{
- await EnsureProvisionedAsync(channel, cancellationToken);
- await DispatchAsync(channel, envelope, timeProvider, cancellationToken);
+ await EnsureProvisionedAsync(lease, cancellationToken);
+
+ PublishException returned;
+ try
+ {
+ await PublishAsync(lease.Channel, envelope, timeProvider, cancellationToken);
+ isChannelReusable = true;
+ return;
+ }
+ catch (PublishException ex)
+ {
+ isChannelReusable = true;
+ if (!ex.IsReturn || Queue is null)
+ {
+ throw;
+ }
+
+ returned = ex;
+ }
+
+ // The queue of the endpoint does not exist.
+ if (Queue.Exclusive || !transport.Topology.ShouldProvision(Queue))
+ {
+ throw ThrowHelper.MessageReturned(Queue.Name, returned);
+ }
+
+ isChannelReusable = false;
+ dispatcher.LogMessageReturned(context, Queue.Name);
+ await ProvisionQueueAsync(lease, Queue, cancellationToken);
+ dispatcher.LogQueueRedeclared(context, Queue.Name);
+
+ try
+ {
+ await PublishAsync(lease.Channel, envelope, timeProvider, cancellationToken);
+ isChannelReusable = true;
+ }
+ catch (PublishException ex)
+ {
+ isChannelReusable = true;
+ if (ex.IsReturn)
+ {
+ throw ThrowHelper.MessageReturned(Queue.Name, ex);
+ }
+
+ throw;
+ }
}
finally
{
- await dispatcher.ReturnChannelAsync(channel);
+ dispatcher.Return(lease, isChannelReusable);
}
}
- private async ValueTask DispatchAsync(
+ private async ValueTask EnsureProvisionedAsync(PublisherLease lease, CancellationToken cancellationToken)
+ {
+ if (_isProvisioned)
+ {
+ return;
+ }
+
+ var topology = transport.Topology;
+
+ // An exclusive queue belongs to the connection that declares it, which must be the consumer's.
+ if (Queue is { Exclusive: false } queue && topology.ShouldProvision(queue))
+ {
+ await ProvisionQueueAsync(lease, queue, cancellationToken);
+ }
+
+ if (Exchange is { } exchange && topology.ShouldProvision(exchange))
+ {
+ await exchange
+ .ProvisionAsync(lease.Channel, lease.Connection, cancellationToken)
+ .WaitOrAbandonAsync(cancellationToken);
+ }
+
+ _isProvisioned = true;
+ }
+
+ ///
+ /// Declares a queue, its bindings and the exchanges they start at.
+ ///
+ private Task ProvisionQueueAsync(PublisherLease lease, RabbitMQQueue queue, CancellationToken cancellationToken)
+ {
+ var provisioner = new RabbitMQTopologyProvisioner(transport.Topology, lease.Connection);
+ return provisioner
+ .ProvisionQueueAsync(queue, lease.Channel, cancellationToken)
+ .WaitOrAbandonAsync(cancellationToken);
+ }
+
+ private ValueTask PublishAsync(
IChannel channel,
MessageEnvelope envelope,
TimeProvider timeProvider,
@@ -125,31 +289,17 @@ private async ValueTask DispatchAsync(
var properties = RabbitMQMessageEnvelopeFormatter.Format(envelope, timeProvider);
- await channel.BasicPublishAsync(exchangeName, routingKey, false, properties, envelope.Body, cancellationToken);
- }
-
- private bool _isProvisioned;
+ // A message sent to a missing queue is confirmed and dropped unless the broker is asked to return it.
+ // A reply whose requester is gone has no queue, and is dropped.
+ var mandatory = Queue is not null && Kind != DispatchEndpointKind.Reply;
- private async ValueTask EnsureProvisionedAsync(IChannel channel, CancellationToken cancellationToken)
- {
- if (_isProvisioned)
- {
- return;
- }
-
- var autoProvision = ((RabbitMQMessagingTopology)transport.Topology).AutoProvision;
-
- if (Queue is not null && (Queue.AutoProvision ?? autoProvision))
- {
- await Queue.ProvisionAsync(channel, cancellationToken);
- }
-
- if (Exchange is not null && (Exchange.AutoProvision ?? autoProvision))
- {
- await Exchange.ProvisionAsync(channel, cancellationToken);
- }
-
- _isProvisioned = true;
+ return channel.BasicPublishAsync(
+ exchangeName,
+ routingKey,
+ mandatory,
+ properties,
+ envelope.Body,
+ cancellationToken);
}
protected override void OnInitialize(
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs
index 8834858d668..dfcabfc55b7 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs
@@ -25,27 +25,35 @@ public RabbitMQMessagingTransport(Action
private RabbitMQMessagingTopology _topology = null!;
///
- public override MessagingTopology Topology => _topology;
+ public override RabbitMQMessagingTopology Topology => _topology;
///
- /// Gets the consumer manager responsible for registering and maintaining queue consumers with automatic reconnection.
+ /// Gets the owner of the connection that consumes messages, and of the consumers on it.
///
- public RabbitMQConsumerManager ConsumerManager { get; private set; } = null!;
+ internal RabbitMQConsumerManager ConsumerManager { get; private set; } = null!;
///
- /// Gets the dispatcher that provides pooled channels for publishing messages to RabbitMQ.
+ /// Gets the owner of the connection that publishes messages, and of its channels.
///
- public RabbitMQDispatcher Dispatcher { get; private set; } = null!;
+ internal RabbitMQDispatcher Dispatcher { get; private set; } = null!;
///
/// Gets the connection provider used to create RabbitMQ connections.
///
public IRabbitMQConnectionProvider Connection { get; private set; } = null!;
+ ///
+ /// Gets the reason the transport is unhealthy, or null when it is started, both broker connections
+ /// are ready, and every receive endpoint is healthy.
+ ///
+ public override string? HealthFailureReason
+ => !IsStarted ? "The transport is not started."
+ : !Dispatcher.IsConnected ? "The dispatcher connection is unavailable."
+ : ConsumerManager.HealthFailureReason ?? base.HealthFailureReason;
+
///
/// Resolves the RabbitMQ connection provider, builds the transport topology URI, and creates
- /// the and instances used for the
- /// lifetime of this transport.
+ /// the owners of the two connections used for the lifetime of this transport.
///
///
/// Called once during the messaging host initialization phase, after the base transport has
@@ -92,52 +100,74 @@ protected override void OnAfterInitialized(IMessagingSetupContext context)
_topology.AddBinding(binding);
}
- Dispatcher = CreateDispatcher(context);
- ConsumerManager = CreateConsumerManager(context);
+ // Reconnect delays and timeouts follow the system clock, also when the application replaces the clock.
+ var timeProvider = TimeProvider.System;
+
+ Dispatcher = new RabbitMQDispatcher(
+ context.Services.GetRequiredService>(),
+ CreateConnectionManager(
+ context,
+ context.Services.GetRequiredService>(),
+ timeProvider,
+ "dispatcher",
+ ProvisionTopologyAsync));
+
+ ConsumerManager = new RabbitMQConsumerManager(
+ context.Services.GetRequiredService>(),
+ CreateConnectionManager(
+ context,
+ context.Services.GetRequiredService>(),
+ timeProvider,
+ "consumer",
+ RabbitMQConsumerManager.SetUpConnectionAsync));
+
+ if (configuration.ConnectTimeout is { } connectTimeout)
+ {
+ Dispatcher.Connection.ConnectTimeout = connectTimeout;
+ ConsumerManager.Connection.ConnectTimeout = connectTimeout;
+ }
+
+ if (configuration.PublishTimeout is { } publishTimeout)
+ {
+ Dispatcher.PublishTimeout = publishTimeout;
+ }
}
- private RabbitMQConsumerManager CreateConsumerManager(IMessagingSetupContext context)
+ private async Task ProvisionTopologyAsync(IConnection connection, CancellationToken cancellationToken)
{
- var logger = context.Services.GetRequiredService>();
-
- return new RabbitMQConsumerManager(logger, Connection.CreateAsync);
+ await using var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken);
+ await new RabbitMQTopologyProvisioner(_topology, connection).ProvisionAsync(channel, cancellationToken);
}
- private RabbitMQDispatcher CreateDispatcher(IMessagingSetupContext context)
+ private RabbitMQConnectionManager CreateConnectionManager(
+ IMessagingSetupContext context,
+ ILogger logger,
+ TimeProvider timeProvider,
+ string role,
+ Func setUpConnectionAsync)
{
- var logger = context.Services.GetRequiredService>();
+ var serviceName = context.Host.ServiceName ?? context.Host.ProcessName;
+ var connectionName = $"{serviceName} {Name} {role}";
- return new RabbitMQDispatcher(logger, Connection.CreateAsync, ProvisionTopologyAsync);
+ Func> connectionFactory =
+ Connection is ConnectionFactoryRabbitMQConnectionProvider provider
+ ? cancellationToken => provider.CreateAsync(connectionName, cancellationToken)
+ : Connection.CreateAsync;
- async Task ProvisionTopologyAsync(IConnection connection, CancellationToken ct)
+ return new RabbitMQConnectionManager(logger, connectionName, timeProvider, connectionFactory, setUpConnectionAsync)
{
- await using var channel = await connection.CreateChannelAsync(cancellationToken: ct);
- var autoProvision = _topology.AutoProvision;
-
- foreach (var exchange in _topology.Exchanges)
- {
- if (exchange.AutoProvision ?? autoProvision)
- {
- await exchange.ProvisionAsync(channel, ct);
- }
- }
-
- foreach (var queue in _topology.Queues)
- {
- if (queue.AutoProvision ?? autoProvision)
- {
- await queue.ProvisionAsync(channel, ct);
- }
- }
-
- foreach (var binding in _topology.Bindings)
+ LogContext = new Dictionary
{
- if (binding.AutoProvision ?? autoProvision)
- {
- await binding.ProvisionAsync(channel, ct);
- }
+ ["ServiceName"] = serviceName,
+ ["InstanceId"] = context.Host.InstanceId,
+ ["Transport"] = Name,
+ ["ConnectionName"] = connectionName,
+ ["ConnectionRole"] = role,
+ ["BrokerHost"] = Connection.Host,
+ ["BrokerPort"] = Connection.Port,
+ ["VirtualHost"] = Connection.VirtualHost
}
- }
+ };
}
///
@@ -149,7 +179,6 @@ public override TransportDescription Describe()
var entities = new List();
var links = new List();
- var autoProvision = _topology.AutoProvision;
foreach (var exchange in _topology.Exchanges)
{
@@ -165,7 +194,7 @@ public override TransportDescription Describe()
["type"] = exchange.Type,
["durable"] = exchange.Durable,
["autoDelete"] = exchange.AutoDelete,
- ["autoProvision"] = exchange.AutoProvision ?? autoProvision,
+ ["autoProvision"] = _topology.ShouldProvision(exchange),
["origin"] = exchange.Origin
}));
}
@@ -184,7 +213,7 @@ public override TransportDescription Describe()
["durable"] = queue.Durable,
["exclusive"] = queue.Exclusive,
["autoDelete"] = queue.AutoDelete,
- ["autoProvision"] = queue.AutoProvision ?? autoProvision,
+ ["autoProvision"] = _topology.ShouldProvision(queue),
["origin"] = queue.Origin
}));
}
@@ -210,7 +239,7 @@ public override TransportDescription Describe()
new Dictionary
{
["routingKey"] = string.IsNullOrEmpty(binding.RoutingKey) ? null : binding.RoutingKey,
- ["autoProvision"] = binding.AutoProvision ?? autoProvision,
+ ["autoProvision"] = _topology.ShouldProvision(binding),
["origin"] = binding.Origin
}));
}
@@ -310,14 +339,12 @@ public override bool TryGetDispatchEndpoint(Uri address, [NotNullWhen(true)] out
}
///
- /// Ensures that both the consumer and dispatcher RabbitMQ connections are established before
- /// the transport's endpoints begin processing messages.
+ /// Connects the consumer and dispatcher RabbitMQ connections before the transport's endpoints start.
///
///
- /// Both and connect concurrently via
- /// Task.WhenAll. If either connection fails, the start-up will fail and the host will
- /// not begin consuming or dispatching messages. This is the last opportunity to guarantee
- /// network connectivity before the messaging pipeline is active.
+ /// While the broker cannot be reached, both connections keep retrying for up to the connect timeout, two minutes
+ /// by default, before the start fails. Refused credentials, refused virtual host access, and topology the broker
+ /// rejects fail the start at once.
///
/// The configuration context for the current startup phase.
/// A token to cancel the connection establishment.
@@ -325,10 +352,9 @@ protected override async ValueTask OnBeforeStartAsync(
IMessagingConfigurationContext context,
CancellationToken cancellationToken)
{
- // TODO we probably should make this resilient!
await Task.WhenAll(
- ConsumerManager.EnsureConnectedAsync(cancellationToken),
- Dispatcher.EnsureConnectedAsync(cancellationToken));
+ ConsumerManager.StartAsync(cancellationToken),
+ Dispatcher.StartAsync(cancellationToken));
}
///
@@ -353,7 +379,7 @@ protected override MessagingTransportConfiguration CreateConfiguration(IMessagin
///
/// Creates a new bound to this transport, which will
- /// consume messages from a RabbitMQ queue via the .
+ /// consume messages from a RabbitMQ queue on the transport's consumer connection.
///
/// A new, uninitialized for this transport.
protected override ReceiveEndpoint CreateReceiveEndpoint()
@@ -363,7 +389,7 @@ protected override ReceiveEndpoint CreateReceiveEndpoint()
///
/// Creates a new bound to this transport, which will
- /// publish messages to RabbitMQ exchanges or queues via the .
+ /// publish messages to RabbitMQ exchanges or queues on the transport's dispatcher connection.
///
/// A new, uninitialized for this transport.
protected override DispatchEndpoint CreateDispatchEndpoint()
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQReceiveEndpoint.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQReceiveEndpoint.cs
index 18afec90c3a..c950a3c0968 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQReceiveEndpoint.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQReceiveEndpoint.cs
@@ -19,6 +19,11 @@ public sealed class RabbitMQReceiveEndpoint(RabbitMQMessagingTransport transport
///
public RabbitMQQueue Queue { get; private set; } = null!;
+ ///
+ /// Gets whether the endpoint's consumer is attached and the broker delivers to it.
+ ///
+ internal bool IsHealthy => _consumer is { IsAttached: true };
+
protected override void OnInitialize(
IMessagingConfigurationContext context,
RabbitMQReceiveEndpointConfiguration configuration)
@@ -45,7 +50,7 @@ protected override void OnComplete(
throw new InvalidOperationException("Queue name is required");
}
- var topology = (RabbitMQMessagingTopology)Transport.Topology;
+ var topology = transport.Topology;
Queue =
topology.Queues.FirstOrDefault(q => q.Name == configuration.QueueName)
@@ -54,23 +59,35 @@ protected override void OnComplete(
Source = Queue;
}
- private IAsyncDisposable? _consumer;
+ private RabbitMQConsumer? _consumer;
- protected override async ValueTask OnStartAsync(
- IMessagingRuntimeContext context,
- CancellationToken cancellationToken)
+ ///
+ /// Waits until the endpoint's consumer is attached. Fails when the endpoint is not started.
+ ///
+ internal ValueTask WaitUntilAttachedAsync(CancellationToken cancellationToken)
{
- if (Transport is not RabbitMQMessagingTransport rabbitMQMessagingTransport)
+ if (_consumer is not { } consumer)
{
- throw new InvalidOperationException("Transport is not a RabbitMQMessagingTransport");
+ throw ThrowHelper.ReplyEndpointNotStarted();
}
- _consumer = await rabbitMQMessagingTransport.ConsumerManager.RegisterConsumerAsync(
+ return consumer.WaitUntilAttachedAsync(cancellationToken);
+ }
+
+ protected override async ValueTask OnStartAsync(
+ IMessagingRuntimeContext context,
+ CancellationToken cancellationToken)
+ {
+ _consumer = await transport.ConsumerManager.RegisterConsumerAsync(
Queue.Name,
+ PrepareChannelAsync,
ProcessMessageAsync,
_maxPrefetch,
_consumerDispatchConcurrency,
cancellationToken);
+
+ Task PrepareChannelAsync(IConnection connection, IChannel channel, CancellationToken ct)
+ => new RabbitMQTopologyProvisioner(transport.Topology, connection).ProvisionQueueAsync(Queue, channel, ct);
}
private async ValueTask ProcessMessageAsync(
@@ -102,9 +119,18 @@ protected override async ValueTask OnStopAsync(
{
if (_consumer is not null)
{
- await _consumer.DisposeAsync();
+ await _consumer.StopAsync(cancellationToken);
}
_consumer = null;
+
+ // A generated temporary reply queue is deleted on graceful stop when auto-provisioning is enabled.
+ if (Kind == ReceiveEndpointKind.Reply
+ && Configuration.IsTemporary
+ && Queue.Origin is not TopologyOrigin.Declared
+ && transport.Topology.ShouldProvision(Queue))
+ {
+ await transport.ConsumerManager.DeleteQueueAsync(Queue.Name, cancellationToken);
+ }
}
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/ThrowHelper.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/ThrowHelper.cs
index 05076b43d18..0273d2ebcfa 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/ThrowHelper.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/ThrowHelper.cs
@@ -1,7 +1,42 @@
+using RabbitMQ.Client.Events;
+using RabbitMQ.Client.Exceptions;
+
namespace Mocha.Transport.RabbitMQ;
internal static class ThrowHelper
{
+ public static InvalidOperationException EnvelopeRequired()
+ => new("Envelope is not set");
+
+ public static TimeoutException ConnectionAttemptTimedOut(TimeSpan timeout, Exception innerException)
+ => new($"The connection attempt did not complete within {timeout}.", innerException);
+
+ public static TimeoutException ConnectionRequestTimedOut(Exception innerException)
+ => new("The broker did not answer a request of the connection attempt in time.", innerException);
+
+ public static ChannelOpenBlockedException ChannelOpenBlocked(Exception innerException)
+ => new(
+ "The broker did not answer the request to open a channel, because it blocked the connection.",
+ innerException);
+
+ public static TimeoutException ChannelOpenTimedOut(Exception innerException)
+ => new("The broker did not answer the request to open a channel.", innerException);
+
+ public static ObjectDisposedException ConnectionManagerDisposed()
+ => new(typeof(RabbitMQConnectionManager).FullName);
+
+ public static OperationCanceledException PublishCancelled(Exception innerException, CancellationToken callerToken)
+ => new(innerException.Message, innerException, callerToken);
+
+ public static TransportUnavailableException PublishTimedOut(TimeSpan timeout, Exception? innerException)
+ => new(
+ $"The broker did not confirm the message within the publish timeout of {timeout}. "
+ + "The broker may or may not have received the message.",
+ innerException);
+
+ public static InvalidOperationException ReplyEndpointNotStarted()
+ => new("The RabbitMQ reply endpoint is not started.");
+
public static Exception BeforeAndAfterConflict()
=> Mocha.ThrowHelper.BeforeAndAfterConflict();
@@ -10,4 +45,26 @@ public static Exception TemporaryEndpointQueueConflict(string queueName, string?
$"Queue '{queueName}' is explicitly declared without auto-delete, which conflicts with "
+ $"receive endpoint '{endpointName}' being marked Temporary(). Declare the queue with "
+ "AutoDelete(), or remove Temporary() from the endpoint.");
+
+ public static OperationInterruptedException ConnectionClosedDuringSetup(ShutdownEventArgs? reason)
+ => reason is null ? new OperationInterruptedException() : new AlreadyClosedException(reason);
+
+ public static TimeoutException ConsumerAttachTimedOut(string queueName, TimeSpan timeout)
+ => new($"Attaching the consumer for queue '{queueName}' did not complete within {timeout}.");
+
+ public static TimeoutException ConsumerAttachRequestTimedOut(string queueName, Exception innerException)
+ => new(
+ $"The broker did not answer a request while the consumer for queue '{queueName}' attached.",
+ innerException);
+
+ public static InvalidOperationException ConsumerStopped(string queueName)
+ => new($"The consumer for queue '{queueName}' is stopped.");
+
+ public static TimeoutException ConnectionTimedOut(TimeSpan timeout, Exception innerException)
+ => new($"Connecting to RabbitMQ did not complete within {timeout}.", innerException);
+
+ public static InvalidOperationException MessageReturned(string queueName, Exception innerException)
+ => new(
+ $"The broker returned the message because no queue named '{queueName}' exists.",
+ innerException);
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Configurations/RabbitMQQueueConfiguration.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Configurations/RabbitMQQueueConfiguration.cs
index b18b37d6d2f..3ca5c2fbacb 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Configurations/RabbitMQQueueConfiguration.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Configurations/RabbitMQQueueConfiguration.cs
@@ -43,4 +43,9 @@ public sealed class RabbitMQQueueConfiguration : TopologyConfiguration
public bool? AutoProvision { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this is a generated temporary reply queue.
+ ///
+ internal bool IsTemporaryReplyQueue { get; set; }
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Extensions/RabbitMQQueueTopologyDescriptorExtensions.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Extensions/RabbitMQQueueTopologyDescriptorExtensions.cs
index b5e5bb640f5..29a659d9a83 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Extensions/RabbitMQQueueTopologyDescriptorExtensions.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/Extensions/RabbitMQQueueTopologyDescriptorExtensions.cs
@@ -145,11 +145,11 @@ public static IRabbitMQQueueTopologyDescriptor QuorumInitialGroupSize(this IRabb
}
///
- /// Sets the maximum delivery limit for messages in the queue.
+ /// Sets the redelivery limit for a quorum queue.
/// Messages that exceed this limit will be dead-lettered or discarded.
///
/// The queue descriptor.
- /// The maximum number of delivery attempts.
+ /// The broker's redelivery limit, or -1 for unlimited redeliveries.
/// The descriptor for method chaining.
public static IRabbitMQQueueTopologyDescriptor MaxDeliveryLimit(this IRabbitMQQueueTopologyDescriptor descriptor, int limit)
{
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/IRabbitMQResource.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/IRabbitMQResource.cs
index 5a1f06fa48c..6d0ab1faca1 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/IRabbitMQResource.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/IRabbitMQResource.cs
@@ -11,6 +11,7 @@ public interface IRabbitMQResource
/// Declares this resource on the broker using the specified channel.
///
/// The RabbitMQ channel to use for declaring the resource.
+ /// The connection that owns the channel.
/// A token to cancel the provisioning operation.
- Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken);
+ Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken);
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQBinding.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQBinding.cs
index 661b0861e0a..12c3af10d05 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQBinding.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQBinding.cs
@@ -53,8 +53,9 @@ private protected static string BuildQuery(string routingKey)
/// Declares this binding on the broker using the specified channel.
///
/// The RabbitMQ channel to use for declaring the binding.
+ /// The connection that owns the channel.
/// A token to cancel the provisioning operation.
- public abstract Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken);
+ public abstract Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken);
}
///
@@ -94,7 +95,7 @@ internal void SetDestination(RabbitMQExchange destination)
}
///
- public override async Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken)
+ public override async Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken)
{
await channel.ExchangeBindAsync(
Destination.Name,
@@ -142,7 +143,7 @@ internal void SetDestination(RabbitMQQueue destination)
}
///
- public override async Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken)
+ public override async Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken)
{
await channel.QueueBindAsync(
Destination.Name,
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQExchange.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQExchange.cs
index a7cccc1ae89..bed5ff51fbf 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQExchange.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQExchange.cs
@@ -88,8 +88,9 @@ internal void AddBinding(RabbitMQBinding binding)
/// Declares this exchange on the broker using the specified channel.
///
/// The RabbitMQ channel to use for declaring the exchange.
+ /// The connection that owns the channel.
/// A token to cancel the provisioning operation.
- public async Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken)
+ public async Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken)
{
await channel.ExchangeDeclareAsync(
CachedName.Value,
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQMessagingTopology.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQMessagingTopology.cs
index 78a7fca2ca4..8b4d3096336 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQMessagingTopology.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQMessagingTopology.cs
@@ -32,6 +32,21 @@ public sealed class RabbitMQMessagingTopology(
///
public bool AutoProvision => autoProvision;
+ ///
+ /// Gets whether the exchange is auto-provisioned, by its own setting or else the topology default.
+ ///
+ internal bool ShouldProvision(RabbitMQExchange exchange) => exchange.AutoProvision ?? autoProvision;
+
+ ///
+ /// Gets whether the queue is auto-provisioned, by its own setting or else the topology default.
+ ///
+ internal bool ShouldProvision(RabbitMQQueue queue) => queue.AutoProvision ?? autoProvision;
+
+ ///
+ /// Gets whether the binding is auto-provisioned, by its own setting or else the topology default.
+ ///
+ internal bool ShouldProvision(RabbitMQBinding binding) => binding.AutoProvision ?? autoProvision;
+
///
/// Gets the list of exchanges registered in this topology.
///
@@ -149,7 +164,14 @@ private RabbitMQQueue CreateQueue(RabbitMQQueueConfiguration configuration)
{
configuration.Topology = this;
- defaults.Queue.ApplyTo(configuration);
+ if (configuration.IsTemporaryReplyQueue)
+ {
+ defaults.Queue.ApplyToReplyQueue(configuration);
+ }
+ else
+ {
+ defaults.Queue.ApplyTo(configuration);
+ }
var queue = new RabbitMQQueue();
queue.Initialize(configuration);
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQQueue.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQQueue.cs
index 23da6ab16a9..4eef7bc3324 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQQueue.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQQueue.cs
@@ -94,19 +94,45 @@ internal void MarkTemporary(TimeSpan expiry)
Arguments = Arguments.SetItem("x-expires", (int)expiry.TotalMilliseconds);
}
+ ///
+ /// Marks this queue as not auto-delete, and makes the broker delete it once it has been unused for
+ /// .
+ ///
+ /// How long the queue may stay without consumers before the broker deletes it.
+ internal void MarkExpiring(TimeSpan expiration)
+ {
+ AutoDelete = false;
+ Arguments = Arguments.SetItem("x-expires", (int)expiration.TotalMilliseconds);
+ }
+
///
/// Declares this queue on the broker using the specified channel.
///
/// The RabbitMQ channel to use for declaring the queue.
+ /// The connection that owns the channel.
/// A token to cancel the provisioning operation.
- public async Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken)
+ public async Task ProvisionAsync(IChannel channel, IConnection connection, CancellationToken cancellationToken)
{
+ var arguments = Arguments;
+ var isLegacyBroker = connection.ServerProperties is { } properties
+ && properties.TryGetValue("version", out var version)
+ && version switch
+ {
+ byte[] bytes => bytes.AsSpan().StartsWith("3."u8),
+ string text => text.StartsWith("3.", StringComparison.Ordinal),
+ _ => false
+ };
+ if (isLegacyBroker && arguments.TryGetValue("x-delivery-limit", out var limit) && limit is -1)
+ {
+ arguments = arguments.Remove("x-delivery-limit");
+ }
+
await channel.QueueDeclareAsync(
CachedName.Value,
Durable,
Exclusive,
AutoDelete,
- Arguments,
+ arguments,
cancellationToken: cancellationToken);
}
}
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs
index bdd103f152d..b21ec5533fa 100644
--- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs
@@ -8,8 +8,10 @@ namespace Mocha.Transport.RabbitMQ;
///
public sealed class RabbitMQRoutingStrategy : RoutingStrategy
{
+ private static readonly TimeSpan s_replyQueueExpiration = TimeSpan.FromMinutes(1);
+
private RabbitMQMessagingTopology _topology =>
- field ??= (RabbitMQMessagingTopology)Transport.Topology;
+ field ??= Transport.Topology;
///
public override DispatchEndpointConfiguration? CreateEndpointConfiguration(
@@ -213,16 +215,24 @@ public override void DiscoverTopology(
EnsureNoTemporaryQueueConflict(rabbitConfiguration);
}
+ // Generated temporary reply queues expire without consumers and inherit quorum replication settings.
+ var isReplyQueue = rabbitConfiguration.IsTemporary && rabbitEndpoint.Kind == ReceiveEndpointKind.Reply;
+
var queue = _topology.GetOrAddQueue(
rabbitConfiguration.QueueName,
_ => new RabbitMQQueueConfiguration
{
- AutoDelete = rabbitConfiguration.IsTemporary,
+ AutoDelete = rabbitConfiguration.IsTemporary && !isReplyQueue,
AutoProvision = rabbitConfiguration.AutoProvision,
- Origin = TopologyOrigin.Endpoint
+ Origin = TopologyOrigin.Endpoint,
+ IsTemporaryReplyQueue = isReplyQueue
});
- if (rabbitConfiguration.IsTemporary && queue.Origin is not TopologyOrigin.Declared)
+ if (isReplyQueue && queue.Origin is not TopologyOrigin.Declared)
+ {
+ queue.MarkExpiring(rabbitConfiguration.TemporaryExpiry ?? s_replyQueueExpiration);
+ }
+ else if (rabbitConfiguration.IsTemporary && queue.Origin is not TopologyOrigin.Declared)
{
queue.MarkTemporary(rabbitConfiguration.TemporaryExpiry ??
RabbitMQReceiveEndpointConfiguration.TemporaryDefaults.Expiry);
diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQTopologyProvisioner.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQTopologyProvisioner.cs
new file mode 100644
index 00000000000..6082391d795
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQTopologyProvisioner.cs
@@ -0,0 +1,87 @@
+using RabbitMQ.Client;
+
+namespace Mocha.Transport.RabbitMQ;
+
+internal sealed class RabbitMQTopologyProvisioner(RabbitMQMessagingTopology topology, IConnection connection)
+{
+ public async Task ProvisionAsync(IChannel channel, CancellationToken cancellationToken)
+ {
+ foreach (var exchange in topology.Exchanges)
+ {
+ if (topology.ShouldProvision(exchange))
+ {
+ await exchange.ProvisionAsync(channel, connection, cancellationToken);
+ }
+ }
+
+ // An exclusive queue belongs to the connection that declares it, so only its consumer declares it and
+ // the bindings that target it.
+ foreach (var queue in topology.Queues)
+ {
+ if (!queue.Exclusive && topology.ShouldProvision(queue))
+ {
+ await queue.ProvisionAsync(channel, connection, cancellationToken);
+ }
+ }
+
+ foreach (var binding in topology.Bindings)
+ {
+ if (binding is not RabbitMQQueueBinding { Destination.Exclusive: true }
+ && topology.ShouldProvision(binding))
+ {
+ await binding.ProvisionAsync(channel, connection, cancellationToken);
+ }
+ }
+ }
+
+ public async Task ProvisionQueueAsync(
+ RabbitMQQueue queue,
+ IChannel channel,
+ CancellationToken cancellationToken)
+ {
+ if (topology.ShouldProvision(queue))
+ {
+ await queue.ProvisionAsync(channel, connection, cancellationToken);
+ }
+
+ var bindings = new List();
+ foreach (var binding in queue.Bindings)
+ {
+ if (topology.ShouldProvision(binding))
+ {
+ bindings.Add(binding);
+ }
+ }
+
+ // The list grows while it is read, which follows exchange-to-exchange bindings upstream of the queue.
+ var exchanges = new HashSet();
+ for (var i = 0; i < bindings.Count; i++)
+ {
+ var exchange = bindings[i].Source;
+ if (!exchanges.Add(exchange))
+ {
+ continue;
+ }
+
+ if (topology.ShouldProvision(exchange))
+ {
+ await exchange.ProvisionAsync(channel, connection, cancellationToken);
+ }
+
+ foreach (var binding in exchange.Bindings)
+ {
+ if (binding is RabbitMQExchangeBinding exchangeBinding
+ && exchangeBinding.Destination == exchange
+ && topology.ShouldProvision(binding))
+ {
+ bindings.Add(binding);
+ }
+ }
+ }
+
+ foreach (var binding in bindings)
+ {
+ await binding.ProvisionAsync(channel, connection, cancellationToken);
+ }
+ }
+}
diff --git a/src/Mocha/src/Mocha/Consumers/Implementations/ReplyConsumer.cs b/src/Mocha/src/Mocha/Consumers/Implementations/ReplyConsumer.cs
index 2af347d9b97..57ed5942157 100644
--- a/src/Mocha/src/Mocha/Consumers/Implementations/ReplyConsumer.cs
+++ b/src/Mocha/src/Mocha/Consumers/Implementations/ReplyConsumer.cs
@@ -66,6 +66,7 @@ protected override ValueTask ConsumeAsync(IConsumeContext context)
{
// Fault the waiting requester rather than leave it to time out.
responseManager.SetException(correlationId, ex);
+ using var _ = _logger.BeginMessageScope(context);
_logger.ReplyProcessingFailed(ex, correlationId, context.MessageId);
}
@@ -85,6 +86,7 @@ private void ReportUnmatchedReply(IConsumeContext context, string? correlationId
return;
}
+ using var _ = _logger.BeginMessageScope(context);
_logger.ReplyDiscarded(correlationId, context.MessageId);
}
}
diff --git a/src/Mocha/src/Mocha/DeferredResponseManager.cs b/src/Mocha/src/Mocha/DeferredResponseManager.cs
index e3fafebf3c9..d613e32f0bf 100644
--- a/src/Mocha/src/Mocha/DeferredResponseManager.cs
+++ b/src/Mocha/src/Mocha/DeferredResponseManager.cs
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
-using Mocha.Events;
using static System.Threading.Tasks.TaskCreationOptions;
namespace Mocha;
@@ -20,23 +19,56 @@ public sealed class DeferredResponseManager(TimeProvider timeProvider)
/// A that completes when the correlated response arrives or the timeout elapses.
public TaskCompletionSource