< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.Transports.RabbitMQ.RabbitMqResponseIngressSubscriber
Assembly: AsyncResponse.Transports.RabbitMQ
File(s): /_/src/Transports/AsyncResponse.Transports.RabbitMQ/RabbitMqSubscriberServices.cs
Line coverage
100%
Covered lines: 15
Uncovered lines: 0
Coverable lines: 15
Total lines: 253
Line coverage: 100%
Branch coverage
50%
Covered branches: 1
Total branches: 2
Branch coverage: 50%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
get_QueueName()100%11100%
get_SubscriberOptions()100%11100%
get_SubscriberRole()100%11100%
EnsureTopologyAsync(...)100%11100%
HandleMessageAsync(...)50%22100%

File(s)

/_/src/Transports/AsyncResponse.Transports.RabbitMQ/RabbitMqSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Text;
 5
 6namespace AsyncResponse.Transports.RabbitMQ;
 7
 8internal abstract class RabbitMqSubscriberService : BackgroundService
 9{
 10    private readonly IRabbitMqConnectionFactory _connectionFactory;
 11
 12    /// <summary>Runs the RabbitMqSubscriberService operation.</summary>
 13    protected RabbitMqSubscriberService(
 14        IOptions<RabbitMqAsyncResponseOptions> options,
 15        ILogger logger)
 16        : this(options, logger, new RabbitMqConnectionFactoryAdapter(options.Value))
 17    {
 18    }
 19
 20    /// <summary>Runs the RabbitMqSubscriberService operation.</summary>
 21    protected RabbitMqSubscriberService(
 22        IOptions<RabbitMqAsyncResponseOptions> options,
 23        ILogger logger,
 24        IRabbitMqConnectionFactory connectionFactory)
 25    {
 26        Options = options.Value;
 27        Logger = logger;
 28        _connectionFactory = connectionFactory;
 29    }
 30
 31    protected RabbitMqAsyncResponseOptions Options { get; }
 32    protected ILogger Logger { get; }
 33
 34    protected abstract string QueueName { get; }
 35    protected abstract RabbitMqSubscriberOptions SubscriberOptions { get; }
 36    protected abstract RabbitMqSubscriberRole SubscriberRole { get; }
 37    /// <summary>Ensures the required resource exists.</summary>
 38    protected abstract Task EnsureTopologyAsync(IRabbitMqChannel channel, CancellationToken cancellationToken);
 39    /// <summary>Handles the delivered message.</summary>
 40    protected abstract Task HandleMessageAsync(RabbitMqDelivery delivery, CancellationToken cancellationToken);
 41
 42    /// <summary>Runs this background operation until cancellation is requested.</summary>
 43    /// <summary>
 44    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 45    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 46    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 47    /// background-exception handling — or never, when a fast stop discards the queued work —
 48    /// instead of failing host startup synchronously.
 49    /// </summary>
 50    public override Task StartAsync(CancellationToken cancellationToken)
 51    {
 52        _ = QueueName; // Resolving the name enforces its Required check at startup too.
 53        RabbitMqMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 54        return base.StartAsync(cancellationToken);
 55    }
 56
 57    protected override Task ExecuteAsync(CancellationToken stoppingToken)
 58    {
 59        var queue = QueueName;
 60
 61        // basic.nack requeue does not increment the x-death header, so the resolved attempt for a plain
 62        // requeued delivery never exceeds 2. Warn once at startup instead of silently never enforcing the cap.
 63        if (SubscriberOptions.AckMode is RabbitMqAckMode.AckAfterHandlerCompletes
 64            && SubscriberOptions.MaxDeliveryAttempts > 2)
 65        {
 66            Logger.LogWarning(
 67                "RabbitMQ {OptionName} is {MaxDeliveryAttempts} for queue {Queue} ({Role}), but attempts beyond 2 cannot
 68                + "basic.nack requeue does not increment x-death, so the cap only takes effect once a TTL-retry dead-let
 69                + "re-delivers the message through a dead-letter exchange. Until then the effective cap is 2 — a failing
 70                + "rejected on its second delivery rather than requeued without limit.",
 71                nameof(RabbitMqSubscriberOptions.MaxDeliveryAttempts),
 72                SubscriberOptions.MaxDeliveryAttempts,
 73                queue,
 74                SubscriberRole);
 75        }
 76
 77        return SubscriberSupervisor.RunAsync(
 78            ct => RunSubscriberAsync(queue, ct),
 79            stoppingToken,
 80            // Covers failed startup and a mid-run consumer/channel termination alike. Jittered
 81            // backoff, not NetworkRecoveryInterval (which paces the CLIENT's automatic recovery
 82            // of an existing connection): a broker restart drops every consumer on every
 83            // replica at once, and a flat shared delay reconnects them all on the same tick.
 84            failures => AsyncResponseRetry.Backoff(failures, Options.SubscriberRetryBaseDelay, Options.SubscriberRetryMa
 85            (ex, retryDelay) => Logger.LogWarning(
 86                ex,
 87                "RabbitMQ subscriber failed for queue {Queue} ({Role}); retrying in {RetryDelay}.",
 88                queue,
 89                SubscriberRole,
 90                retryDelay));
 91    }
 92
 93    private async Task RunSubscriberAsync(string queue, CancellationToken stoppingToken)
 94    {
 95        await using var connection = await _connectionFactory.CreateConnectionAsync(stoppingToken).ConfigureAwait(false)
 96        // Publisher confirmations when a dead-letter exchange is configured: the already-ACKed
 97        // dead-letter copy is published on THIS channel, and without confirmation tracking an
 98        // unroutable (mandatory) return raises only an unobserved basic.return — the publish
 99        // "succeeded" and a successful burial was logged for a message the broker discarded.
 100        // With confirms the publish throws, and the existing catch logs the failure honestly.
 101        // Acks/nacks are unaffected by confirm mode, so channels that never publish pay nothing.
 102        await using var channel = await connection.CreateChannelAsync(
 103            publisherConfirmations: !string.IsNullOrWhiteSpace(Options.DeadLetterExchange),
 104            cancellationToken: stoppingToken).ConfigureAwait(false);
 105        await EnsureTopologyAsync(channel, stoppingToken).ConfigureAwait(false);
 106        await channel.BasicQosAsync(SubscriberOptions.PrefetchCount, stoppingToken).ConfigureAwait(false);
 107
 108        await using var dispatcher = RabbitMqMessageDispatcher.Create(
 109            HandleMessageAsync,
 110            Options,
 111            SubscriberOptions,
 112            Logger,
 113            queue,
 114            SubscriberRole);
 115
 116        Logger.LogInformation(
 117            "RabbitMQ subscriber started. Queue: {Queue}. Role: {Role}. AckMode: {AckMode}.",
 118            queue,
 119            SubscriberRole,
 120            SubscriberOptions.AckMode);
 121
 122        var consumer = await channel.BasicConsumeAsync(
 123            queue,
 124            delivery => dispatcher.HandleAsync(delivery, channel, stoppingToken),
 125            stoppingToken).ConfigureAwait(false);
 126
 127        // Park until host shutdown or consumer termination. The termination task is the only signal
 128        // that deliveries stopped (broker-side basic.cancel and channel-level closes raise no
 129        // exception here), so parking on the stopping token alone would keep a dead subscription
 130        // alive forever. A registration-fed TCS instead of an infinite Task.Delay: a faulted
 131        // iteration must not leak one timer + token registration per rebuild.
 132        var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 133        using (stoppingToken.Register(() => stopped.TrySetResult()))
 134        {
 135            var first = await Task.WhenAny(consumer.Terminated, stopped.Task).ConfigureAwait(false);
 136
 137            // A client-initiated cancel during shutdown also completes Terminated (cancel-ok raises
 138            // UnregisteredAsync), so termination is a failure only while the host is still running.
 139            // Throwing hands control to the ExecuteAsync retry loop, which disposes this
 140            // connection/channel (via await using) and rebuilds both plus the consumer after backoff.
 141            if (first == consumer.Terminated && !stoppingToken.IsCancellationRequested)
 142            {
 143                var reason = await consumer.Terminated.ConfigureAwait(false);
 144                throw new InvalidOperationException(
 145                    $"RabbitMQ consumer for queue '{queue}' ({SubscriberRole}) stopped receiving: {reason}.");
 146            }
 147        }
 148
 149        using var shutdown = new CancellationTokenSource(Options.ShutdownTimeout);
 150        await channel.BasicCancelAsync(consumer.ConsumerTag, shutdown.Token).ConfigureAwait(false);
 151
 152        // Drain the ACK-after-enqueue background queue BEFORE closing the channel and connection
 153        // (Kafka parity: "leaving the await-using scope drains ... before the consumer commits").
 154        // The drain's dead-letter publishes ride this consumer channel, so closing it first made
 155        // TryDeadLetterAlreadyAckedAsync find the channel closed on every graceful shutdown and
 156        // each already-ACKed failure during the drain lost its DLX record. DisposeAsync is
 157        // idempotent, so the `await using` unwind after the closes is a no-op.
 158        await dispatcher.DisposeAsync().ConfigureAwait(false);
 159
 160        // A fresh budget for the closes (ASB/SQS parity: arm the source right before the call it
 161        // bounds). The drain above can run up to BackgroundDrainTimeout, longer than the 5 s
 162        // ShutdownTimeout, so the token armed for BasicCancel was already cancelled by the time
 163        // it reached CloseAsync — which threw, skipped the connection close, and left both to
 164        // the unbounded await-using unwind on every early-ACK shutdown.
 165        using var closeBudget = new CancellationTokenSource(Options.ShutdownTimeout);
 166        await channel.CloseAsync(closeBudget.Token).ConfigureAwait(false);
 167        await connection.CloseAsync(Options.ShutdownTimeout, closeBudget.Token).ConfigureAwait(false);
 168    }
 169}
 170
 171internal sealed class RabbitMqWorkerSubscriber : RabbitMqSubscriberService
 172{
 173    private readonly IAsyncResponseIngress _ingress;
 174
 175    /// <summary>Runs the RabbitMqWorkerSubscriber operation.</summary>
 176    public RabbitMqWorkerSubscriber(
 177        IOptions<RabbitMqAsyncResponseOptions> options,
 178        IAsyncResponseIngress ingress,
 179        ILogger<RabbitMqWorkerSubscriber> logger)
 180        : base(options, logger)
 181    {
 182        _ingress = ingress;
 183    }
 184
 185    internal RabbitMqWorkerSubscriber(
 186        IOptions<RabbitMqAsyncResponseOptions> options,
 187        IAsyncResponseIngress ingress,
 188        ILogger<RabbitMqWorkerSubscriber> logger,
 189        IRabbitMqConnectionFactory connectionFactory)
 190        : base(options, logger, connectionFactory)
 191    {
 192        _ingress = ingress;
 193    }
 194
 195    protected override string QueueName
 196        => RabbitMqOptionsValidator.Required(Options.WorkerQueue, nameof(Options.WorkerQueue));
 197
 198    protected override RabbitMqSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 199    protected override RabbitMqSubscriberRole SubscriberRole => RabbitMqSubscriberRole.Worker;
 200
 201    /// <summary>Ensures the required resource exists.</summary>
 202    protected override Task EnsureTopologyAsync(IRabbitMqChannel channel, CancellationToken cancellationToken)
 203        => RabbitMqTopology.EnsureWorkerAsync(channel, Options, cancellationToken);
 204
 205    /// <summary>Handles the delivered message.</summary>
 206    protected override Task HandleMessageAsync(RabbitMqDelivery delivery, CancellationToken cancellationToken)
 207        => _ingress.HandleWorkerMessageAsync(Encoding.UTF8.GetString(delivery.Body.Span));
 208}
 209
 210internal sealed class RabbitMqResponseIngressSubscriber : RabbitMqSubscriberService
 211{
 212    private readonly IAsyncResponseIngress _ingress;
 213
 214    /// <summary>Runs the RabbitMqResponseIngressSubscriber operation.</summary>
 215    public RabbitMqResponseIngressSubscriber(
 216        IOptions<RabbitMqAsyncResponseOptions> options,
 217        IAsyncResponseIngress ingress,
 218        ILogger<RabbitMqResponseIngressSubscriber> logger)
 196219        : base(options, logger)
 220    {
 196221        _ingress = ingress;
 196222    }
 223
 224    internal RabbitMqResponseIngressSubscriber(
 225        IOptions<RabbitMqAsyncResponseOptions> options,
 226        IAsyncResponseIngress ingress,
 227        ILogger<RabbitMqResponseIngressSubscriber> logger,
 228        IRabbitMqConnectionFactory connectionFactory)
 4229        : base(options, logger, connectionFactory)
 230    {
 4231        _ingress = ingress;
 4232    }
 233
 234    protected override string QueueName
 390235        => RabbitMqOptionsValidator.Required(Options.ResponseQueue, nameof(Options.ResponseQueue));
 236
 1164237    protected override RabbitMqSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 589238    protected override RabbitMqSubscriberRole SubscriberRole => RabbitMqSubscriberRole.ResponseIngress;
 239
 240    /// <summary>Ensures the required resource exists.</summary>
 241    protected override Task EnsureTopologyAsync(IRabbitMqChannel channel, CancellationToken cancellationToken)
 194242        => RabbitMqTopology.EnsureResponseAsync(channel, Options, cancellationToken);
 243
 244    /// <summary>Handles the delivered message.</summary>
 245    protected override Task HandleMessageAsync(RabbitMqDelivery delivery, CancellationToken cancellationToken)
 246    {
 2247        var messageJson = Encoding.UTF8.GetString(delivery.Body.Span);
 2248        var correlationId = !_ingress.IsOverInboundBudget(messageJson)
 2249            ? RabbitMqCorrelationIdExtractor.Extract(delivery, messageJson, Options)
 2250            : null;
 2251        return _ingress.HandleResponseMessageAsync(messageJson, correlationId);
 252    }
 253}