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

Information
Class: AsyncResponse.Transports.RabbitMQ.RabbitMqMessageDispatcher
Assembly: AsyncResponse.Transports.RabbitMQ
File(s): /_/src/Transports/AsyncResponse.Transports.RabbitMQ/RabbitMqMessageDispatcher.cs
Line coverage
95%
Covered lines: 182
Uncovered lines: 8
Coverable lines: 190
Total lines: 1086
Line coverage: 95.7%
Branch coverage
84%
Covered branches: 83
Total branches: 98
Branch coverage: 84.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_TransportOptions()100%11100%
get_Logger()100%11100%
get_QueueName()100%11100%
get_MaxDeliveryAttempts()100%11100%
ResolveDeliveryAttempt(...)50%22100%
EffectiveDeliveryCap(...)100%44100%
ReadDeathCount(...)92.85%1414100%
BuildDeadLetterProperties(...)100%44100%
Create(...)100%22100%
ValidateOptions(...)91.66%494893.67%
AttachChannel(...)100%210%
DisposeAsync()100%11100%
.cctor()100%210%
Dispose()100%210%
ExecuteHandlerAsync()59.09%2222100%
NotifyBackgroundFailureAsync()100%22100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using RabbitMQ.Client;
 3using System.Collections;
 4using System.Diagnostics;
 5using System.Threading.Channels;
 6
 7namespace AsyncResponse.Transports.RabbitMQ;
 8
 9internal enum RabbitMqSubscriberRole
 10{
 11    Worker,
 12    ResponseIngress
 13}
 14
 15internal abstract class RabbitMqMessageDispatcher : IAsyncDisposable
 16{
 17    private readonly Func<RabbitMqDelivery, CancellationToken, Task> _handler;
 18    private readonly RabbitMqSubscriberOptions _subscriberOptions;
 19    private readonly string _queue;
 20    private readonly RabbitMqSubscriberRole _role;
 21
 22    /// <summary>Runs the RabbitMqMessageDispatcher operation.</summary>
 47223    protected RabbitMqMessageDispatcher(
 47224        Func<RabbitMqDelivery, CancellationToken, Task> handler,
 47225        RabbitMqAsyncResponseOptions transportOptions,
 47226        RabbitMqSubscriberOptions subscriberOptions,
 47227        ILogger logger,
 47228        string queue,
 47229        RabbitMqSubscriberRole role)
 30    {
 47231        _handler = handler;
 47232        TransportOptions = transportOptions;
 47233        _subscriberOptions = subscriberOptions;
 47234        Logger = logger;
 47235        _queue = queue;
 47236        _role = role;
 47237    }
 38
 5039    protected RabbitMqAsyncResponseOptions TransportOptions { get; }
 16940    protected ILogger Logger { get; }
 1341    protected string QueueName => _queue;
 42
 43    /// <summary>
 44    /// Maximum delivery attempts before a failing <see cref="RabbitMqAckMode.AckAfterHandlerCompletes"/> handler
 45    /// rejects without requeue. <c>0</c> means unlimited (requeue forever).
 46    /// </summary>
 60247    protected int MaxDeliveryAttempts => _subscriberOptions.MaxDeliveryAttempts;
 48
 49    /// <summary>
 50    /// Resolves the 1-based delivery attempt for a message from the broker's <c>x-death</c> count and the
 51    /// <c>redelivered</c> flag. A message seen for the first time is attempt 1.
 52    /// </summary>
 53    internal static int ResolveDeliveryAttempt(RabbitMqDelivery delivery)
 54    {
 7355        var priorAttempts = Math.Max(ReadDeathCount(delivery.BasicProperties), delivery.Redelivered ? 1L : 0L);
 56
 57        // Saturate BEFORE the +1. x-death is a message header, so any publisher can forge it: a count
 58        // of long.MaxValue wrapped to long.MinValue on the increment, slipped under the int.MaxValue
 59        // range check and cast to attempt 0 — a message below every cap forever, never parked.
 7360        return priorAttempts >= int.MaxValue ? int.MaxValue : (int)(priorAttempts + 1);
 61    }
 62
 63    /// <summary>
 64    /// The cap this delivery can actually be judged against. A plain <c>basic.nack</c> requeue does
 65    /// NOT increment <c>x-death</c>, so without a dead-letter hop the resolved attempt saturates at
 66    /// 2 — and a configured cap above 2 was therefore never reachable: <c>attempt &lt; cap</c> stayed
 67    /// true on every redelivery and the message requeued forever, exactly as if the cap were 0.
 68    /// That is strictly worse than the documented "behaves like 2", which is what this restores:
 69    /// once the broker has actually dead-lettered the message at least once (x-death present), its
 70    /// attempts are countable and the operator's full cap applies.
 71    /// </summary>
 72    internal int EffectiveDeliveryCap(RabbitMqDelivery delivery)
 5973        => MaxDeliveryAttempts > 2 && ReadDeathCount(delivery.BasicProperties) == 0
 5974            ? 2
 5975            : MaxDeliveryAttempts;
 76
 77    protected static long ReadDeathCount(IReadOnlyBasicProperties properties)
 78    {
 13479        if (properties.Headers is null
 13480            || !properties.Headers.TryGetValue("x-death", out var raw)
 13481            || raw is not IEnumerable entries)
 82        {
 7683            return 0;
 84        }
 85
 5886        long max = 0;
 23287        foreach (var entry in entries)
 88        {
 5889            if (entry is not IDictionary fields || !fields.Contains("count") || fields["count"] is not { } countValue)
 90                continue;
 91
 92            try
 93            {
 5694                max = Math.Max(max, Convert.ToInt64(countValue));
 5495            }
 296            catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
 97            {
 98                // Ignore malformed x-death entries; fall back to the redelivered flag.
 299            }
 100        }
 101
 58102        return max;
 103    }
 104
 105    /// <summary>Longest <c>AR-DeadLetter-Reason</c> header value, in UTF-16 code units.</summary>
 106    internal const int MaxDeadLetterReasonLength = 512;
 107
 108    /// <summary>
 109    /// Builds the properties for a dead-letter copy of <paramref name="delivery"/>: the original
 110    /// headers plus the <c>AR-DeadLetter-*</c> forensic headers.
 111    /// </summary>
 112    protected BasicProperties BuildDeadLetterProperties(RabbitMqDelivery delivery, Exception exception)
 113    {
 12114        var headers = new Dictionary<string, object?>(StringComparer.Ordinal);
 12115        if (delivery.BasicProperties.Headers is { } original)
 116        {
 24117            foreach (var header in original)
 6118                headers[header.Key] = header.Value;
 119        }
 120
 121        // Surrogate-aware cut (see PortableText.TruncateWellFormed): a fixed-index slice through a
 122        // non-BMP character left a lone high surrogate in a header the client encodes as UTF-8.
 12123        headers["AR-DeadLetter-Reason"] = PortableText.TruncateWellFormed(exception.Message, MaxDeadLetterReasonLength);
 12124        headers["AR-DeadLetter-Source-Queue"] = _queue;
 12125        headers["AR-DeadLetter-Role"] = _role.ToString();
 126
 12127        return new BasicProperties
 12128        {
 12129            Persistent = true,
 12130            ContentType = delivery.BasicProperties.ContentType,
 12131            MessageId = delivery.BasicProperties.MessageId,
 12132            CorrelationId = delivery.BasicProperties.CorrelationId,
 12133            Headers = headers
 12134        };
 135    }
 136
 137    /// <summary>Creates the configured dispatcher.</summary>
 138    public static RabbitMqMessageDispatcher Create(
 139        Func<RabbitMqDelivery, CancellationToken, Task> handler,
 140        RabbitMqAsyncResponseOptions transportOptions,
 141        RabbitMqSubscriberOptions subscriberOptions,
 142        ILogger logger,
 143        string queue,
 144        RabbitMqSubscriberRole role)
 145    {
 472146        ValidateOptions(transportOptions, subscriberOptions, role);
 147
 472148        return subscriberOptions.AckMode == RabbitMqAckMode.AckAfterHandlerCompletes
 472149            ? new AwaitingRabbitMqMessageDispatcher(
 472150                handler,
 472151                transportOptions,
 472152                subscriberOptions,
 472153                logger,
 472154                queue,
 472155                role)
 472156            : new QueuedRabbitMqMessageDispatcher(
 472157                handler,
 472158                transportOptions,
 472159                subscriberOptions,
 472160                logger,
 472161                queue,
 472162                role);
 163    }
 164
 165    /// <summary>Validates the supplied options.</summary>
 166    public static void ValidateOptions(
 167        RabbitMqAsyncResponseOptions transportOptions,
 168        RabbitMqSubscriberOptions subscriberOptions,
 169        RabbitMqSubscriberRole role)
 170    {
 932171        var optionPath = role is RabbitMqSubscriberRole.Worker
 932172            ? $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.WorkerSubscriber)}"
 932173            : $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.ResponseSubscriber)}";
 174
 932175        RabbitMqOptionsValidator.ValidateConnection(transportOptions);
 176
 177        // Both arm Task.Delay timers in the subscriber restart loop.
 928178        AsyncResponseChannelOptions.EnsureTimerBacked(transportOptions.SubscriberRetryBaseDelay, nameof(RabbitMqAsyncRes
 926179        AsyncResponseChannelOptions.EnsureTimerBacked(transportOptions.SubscriberRetryMaxDelay, nameof(RabbitMqAsyncResp
 924180        if (transportOptions.SubscriberRetryBaseDelay > transportOptions.SubscriberRetryMaxDelay)
 181        {
 2182            throw new InvalidOperationException(
 2183                $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.SubscriberRetryBaseDelay)}
 2184                $"{nameof(RabbitMqAsyncResponseOptions.SubscriberRetryMaxDelay)}.");
 185        }
 186
 922187        if (StringComparer.Ordinal.Equals(transportOptions.WorkerQueue, transportOptions.ResponseQueue))
 188        {
 2189            throw new InvalidOperationException(
 2190                $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.WorkerQueue)} and " +
 2191                $"{nameof(RabbitMqAsyncResponseOptions.ResponseQueue)} must be distinct so worker and response subscribe
 192        }
 193
 194        // The publish address is (exchange, routingKey), not the queue name: a direct exchange
 195        // fans one routing key out to EVERY queue bound with it, so two distinct queues sharing
 196        // one address both receive every publish — worker envelopes delivered to the response
 197        // queue complete real waiters through the response ingress.
 920198        if (StringComparer.Ordinal.Equals(transportOptions.WorkerExchange, transportOptions.ResponseExchange)
 920199            && StringComparer.Ordinal.Equals(transportOptions.WorkerRoutingKey, transportOptions.ResponseRoutingKey))
 200        {
 2201            throw new InvalidOperationException(
 2202                $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.WorkerExchange)}+" +
 2203                $"{nameof(RabbitMqAsyncResponseOptions.WorkerRoutingKey)} and " +
 2204                $"{nameof(RabbitMqAsyncResponseOptions.ResponseExchange)}+{nameof(RabbitMqAsyncResponseOptions.ResponseR
 2205                "must not form the same publish address: the exchange fans that routing key out to both queues, so worke
 2206                "jobs would also be delivered to the response queue (and responses to the worker queue).");
 207        }
 208
 209        // Parity with the Kafka/NATS validators: a dead-letter destination aimed at a live one
 210        // turns reject-without-requeue into a broker-rate loop. A rejected message re-enters the
 211        // dead-letter exchange under DeadLetterRoutingKey — or its ORIGINAL routing key when that
 212        // is blank, which by definition matches the binding of the queue that rejected it. It
 213        // loops (or crosses into the other role's queue) only when that (exchange, routing key)
 214        // pair is a live binding; a distinct DeadLetterRoutingKey on a shared exchange is a
 215        // legitimate topology and must keep starting.
 918216        if (!string.IsNullOrWhiteSpace(transportOptions.DeadLetterExchange))
 217        {
 46218            var deadLetterAddressIsLive = string.IsNullOrWhiteSpace(transportOptions.DeadLetterRoutingKey)
 46219                ? StringComparer.Ordinal.Equals(transportOptions.DeadLetterExchange, transportOptions.WorkerExchange)
 46220                    || StringComparer.Ordinal.Equals(transportOptions.DeadLetterExchange, transportOptions.ResponseExcha
 46221                : (StringComparer.Ordinal.Equals(transportOptions.DeadLetterExchange, transportOptions.WorkerExchange)
 46222                        && StringComparer.Ordinal.Equals(transportOptions.DeadLetterRoutingKey, transportOptions.WorkerR
 46223                    || (StringComparer.Ordinal.Equals(transportOptions.DeadLetterExchange, transportOptions.ResponseExch
 46224                        && StringComparer.Ordinal.Equals(transportOptions.DeadLetterRoutingKey, transportOptions.Respons
 46225            if (deadLetterAddressIsLive)
 226            {
 8227                throw new InvalidOperationException(
 8228                    $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.DeadLetterExchange)} "
 8229                    $"'{transportOptions.DeadLetterExchange}' plus {nameof(RabbitMqAsyncResponseOptions.DeadLetterRoutin
 8230                    $"'{transportOptions.DeadLetterRoutingKey ?? "(blank — the message's original routing key)"}' target
 8231                    $"binding ({nameof(RabbitMqAsyncResponseOptions.WorkerExchange)}/{nameof(RabbitMqAsyncResponseOption
 8232                    "dead-lettered messages would re-enter live routing and loop instead of parking. " +
 8233                    $"Set a {nameof(RabbitMqAsyncResponseOptions.DeadLetterRoutingKey)} that no live queue is bound with
 234            }
 235        }
 236
 910237        if (!string.IsNullOrWhiteSpace(transportOptions.DeadLetterQueue)
 910238            && (StringComparer.Ordinal.Equals(transportOptions.DeadLetterQueue, transportOptions.WorkerQueue)
 910239                || StringComparer.Ordinal.Equals(transportOptions.DeadLetterQueue, transportOptions.ResponseQueue)))
 240        {
 4241            throw new InvalidOperationException(
 4242                $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.DeadLetterQueue)} " +
 4243                $"'{transportOptions.DeadLetterQueue}' must not be a live queue " +
 4244                $"({nameof(RabbitMqAsyncResponseOptions.WorkerQueue)}/{nameof(RabbitMqAsyncResponseOptions.ResponseQueue
 4245                "dead-lettered messages would be consumed as live traffic.");
 246        }
 247
 248        // The park publish goes through the default exchange, whose routing key IS the queue name:
 249        // parked into a live queue, a capped message is redelivered straight back to the subscriber
 250        // that parked it — past its cap, so it is parked again, in a loop at broker rate.
 906251        if (!string.IsNullOrWhiteSpace(transportOptions.ParkQueue)
 906252            && (StringComparer.Ordinal.Equals(transportOptions.ParkQueue, transportOptions.WorkerQueue)
 906253                || StringComparer.Ordinal.Equals(transportOptions.ParkQueue, transportOptions.ResponseQueue)))
 254        {
 0255            throw new InvalidOperationException(
 0256                $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.ParkQueue)} " +
 0257                $"'{transportOptions.ParkQueue}' must not be a live queue " +
 0258                $"({nameof(RabbitMqAsyncResponseOptions.WorkerQueue)}/{nameof(RabbitMqAsyncResponseOptions.ResponseQueue
 0259                "parked messages would be consumed as live traffic.");
 260        }
 261
 906262        RabbitMqOptionsValidator.ValidateConsumerTimeout(transportOptions);
 263
 906264        if (subscriberOptions.PrefetchCount == 0)
 2265            throw new InvalidOperationException($"{optionPath}.{nameof(RabbitMqSubscriberOptions.PrefetchCount)} must be
 266
 904267        switch (subscriberOptions.AckMode)
 268        {
 269            case RabbitMqAckMode.AckAfterHandlerCompletes:
 834270                return;
 271
 272            case RabbitMqAckMode.AckAfterEnqueue:
 68273                if (subscriberOptions.BackgroundWorkerCount <= 0)
 274                {
 2275                    throw new InvalidOperationException(
 2276                        $"{optionPath}.{nameof(RabbitMqSubscriberOptions.BackgroundWorkerCount)} must be explicitly conf
 2277                        $"when {nameof(RabbitMqSubscriberOptions.AckMode)} is {nameof(RabbitMqAckMode.AckAfterEnqueue)}.
 278                }
 279
 66280                if (subscriberOptions.BackgroundQueueCapacity <= 0)
 281                {
 2282                    throw new InvalidOperationException(
 2283                        $"{optionPath}.{nameof(RabbitMqSubscriberOptions.BackgroundQueueCapacity)} must be explicitly co
 2284                        $"when {nameof(RabbitMqSubscriberOptions.AckMode)} is {nameof(RabbitMqAckMode.AckAfterEnqueue)}.
 285                }
 286
 64287                AsyncResponseChannelOptions.EnsureTimerBacked(subscriberOptions.BackgroundDrainTimeout, optionPath, name
 62288                AsyncResponseChannelOptions.EnsureTimerBacked(transportOptions.ShutdownTimeout, nameof(RabbitMqAsyncResp
 289
 290                // RabbitMQ arms ShutdownTimeout TWICE on the stop path — once for BasicCancel,
 291                // then (after the background drain) a fresh budget for the channel and connection
 292                // closes — so the worst case is ShutdownTimeout + drain + ShutdownTimeout, and all
 293                // three must fit inside the host budget. Summing only one close term let a
 294                // configuration that overran the host by a full ShutdownTimeout start.
 60295                ShutdownBudgetValidator.Validate(
 60296                    "RabbitMQ",
 60297                    $"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.HostShutdownTimeout)}"
 60298                    transportOptions.HostShutdownTimeout,
 60299                    ($"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.ShutdownTimeout)} (co
 60300                    ($"{optionPath}.{nameof(RabbitMqSubscriberOptions.BackgroundDrainTimeout)}", subscriberOptions.Backg
 60301                    ($"{nameof(RabbitMqAsyncResponseOptions)}.{nameof(RabbitMqAsyncResponseOptions.ShutdownTimeout)} (ch
 302
 54303                return;
 304
 305            default:
 2306                throw new InvalidOperationException(
 2307                    $"{optionPath}.{nameof(RabbitMqSubscriberOptions.AckMode)} has unsupported value '{subscriberOptions
 308        }
 309    }
 310
 311    /// <summary>Handles the delivered message.</summary>
 312    public abstract Task HandleAsync(
 313        RabbitMqDelivery delivery,
 314        IRabbitMqChannel channel,
 315        CancellationToken subscriberCancellationToken);
 316
 317    /// <summary>
 318    /// Binds the channel of the subscriber attempt that is about to consume; disposing the returned
 319    /// handle unbinds it when that attempt ends. The dispatcher lives as long as the hosted
 320    /// subscriber and is fed by every attempt, so only a dispatcher that works after the delivery
 321    /// callback returns needs to know which channel is the live one.
 322    /// </summary>
 0323    public virtual IDisposable AttachChannel(IRabbitMqChannel channel) => NoChannelAttachment.Instance;
 324
 325    /// <summary>Releases resources held by this instance.</summary>
 823326    public virtual ValueTask DisposeAsync() => ValueTask.CompletedTask;
 327
 328    private sealed class NoChannelAttachment : IDisposable
 329    {
 0330        public static readonly NoChannelAttachment Instance = new();
 331
 332        public void Dispose()
 333        {
 0334        }
 335    }
 336
 337    /// <summary>Runs the ExecuteHandlerAsync operation.</summary>
 338    protected async Task ExecuteHandlerAsync(
 339        RabbitMqDelivery delivery,
 340        CancellationToken cancellationToken,
 341        bool logFailures = true)
 342    {
 506343        using var activity = AsyncResponseDiagnostics.StartActivity(
 506344            "asyncresponse.rabbitmq.receive",
 506345            ActivityKind.Consumer);
 506346        activity?.SetTag("asyncresponse.transport", "rabbitmq");
 506347        activity?.SetTag("asyncresponse.rabbitmq.role", _role.ToString());
 506348        activity?.SetTag("asyncresponse.rabbitmq.ack_mode", _subscriberOptions.AckMode.ToString());
 506349        activity?.SetTag("messaging.system", "rabbitmq");
 506350        activity?.SetTag("messaging.destination.name", _queue);
 506351        activity?.SetTag("messaging.rabbitmq.exchange", delivery.Exchange);
 506352        activity?.SetTag("messaging.rabbitmq.routing_key", delivery.RoutingKey);
 506353        activity?.SetTag("messaging.rabbitmq.delivery_tag", delivery.DeliveryTag);
 506354        activity?.SetTag("messaging.message.id", delivery.BasicProperties.MessageId);
 355
 506356        if (!string.IsNullOrWhiteSpace(delivery.BasicProperties.CorrelationId))
 107357            AsyncResponseDiagnostics.SetCorrelationId(activity, delivery.BasicProperties.CorrelationId);
 358
 359        try
 360        {
 506361            await _handler(delivery, cancellationToken).ConfigureAwait(false);
 460362        }
 46363        catch (Exception ex)
 364        {
 46365            if (logFailures)
 30366                Logger.LogError(ex, "RabbitMQ message handling failed for delivery {DeliveryTag}.", delivery.DeliveryTag
 46367            AsyncResponseDiagnostics.SetError(activity, ex);
 46368            throw;
 369        }
 460370    }
 371
 372    /// <summary>Runs the NotifyBackgroundFailureAsync operation.</summary>
 373    protected async ValueTask NotifyBackgroundFailureAsync(
 374        RabbitMqDelivery delivery,
 375        Exception exception,
 376        string queue,
 377        RabbitMqSubscriberRole role)
 378    {
 16379        var callback = _subscriberOptions.OnBackgroundFailure;
 16380        if (callback is null)
 8381            return;
 382
 383        try
 384        {
 8385            await callback(new RabbitMqBackgroundFailureContext(
 8386                queue,
 8387                role.ToString(),
 8388                delivery.Exchange,
 8389                delivery.RoutingKey,
 8390                delivery.DeliveryTag,
 8391                exception)).ConfigureAwait(false);
 6392        }
 2393        catch (Exception callbackException)
 394        {
 2395            Logger.LogError(
 2396                callbackException,
 2397                "RabbitMQ background failure callback failed for already-ACKed delivery {DeliveryTag} on {Queue}.",
 2398                delivery.DeliveryTag,
 2399                queue);
 2400        }
 16401    }
 402}
 403
 404internal sealed class AwaitingRabbitMqMessageDispatcher(
 405    Func<RabbitMqDelivery, CancellationToken, Task> handler,
 406    RabbitMqAsyncResponseOptions transportOptions,
 407    RabbitMqSubscriberOptions subscriberOptions,
 408    ILogger logger,
 409    string queue,
 410    RabbitMqSubscriberRole role)
 411    : RabbitMqMessageDispatcher(handler, transportOptions, subscriberOptions, logger, queue, role)
 412{
 413    /// <summary>Failed parks in a row; paces the requeue of the next one (reset by a park that lands).</summary>
 414    private int _consecutiveParkFailures;
 415
 416    /// <summary>Handles the delivered message.</summary>
 417    public override async Task HandleAsync(
 418        RabbitMqDelivery delivery,
 419        IRabbitMqChannel channel,
 420        CancellationToken subscriberCancellationToken)
 421    {
 422        // Pre-execution cap (NATS/DB-transport parity), BEFORE the handler runs: a delivery whose
 423        // previous attempt ended WITHOUT a thrown exception — the process OOM-killed mid-handler,
 424        // FailFast, a hang that tripped the broker's consumer_timeout — is requeued by the broker
 425        // and never reaches the catch below, so nothing ever judged it against the cap and one
 426        // poison message crash-looped every replica in turn whatever MaxDeliveryAttempts said.
 427        if (MaxDeliveryAttempts > 0 && ResolveDeliveryAttempt(delivery) > EffectiveDeliveryCap(delivery))
 428        {
 429            if (ReadDeathCount(delivery.BasicProperties) == 0)
 430            {
 431                await TryNackAsync(delivery, channel, requeue: false).ConfigureAwait(false);
 432            }
 433            else
 434            {
 435                await ParkAtCapAsync(
 436                    delivery,
 437                    channel,
 438                    new InvalidOperationException($"RabbitMQ delivery exceeded {MaxDeliveryAttempts} delivery attempts b
 439                    subscriberCancellationToken).ConfigureAwait(false);
 440            }
 441
 442            return;
 443        }
 444
 445        try
 446        {
 447            await ExecuteHandlerAsync(delivery, subscriberCancellationToken).ConfigureAwait(false);
 448        }
 449        catch (OperationCanceledException) when (subscriberCancellationToken.IsCancellationRequested)
 450        {
 451            // Shutdown, not a handler failure: NACKing here would count a healthy delivery against
 452            // the cap — and at the cap reject it without requeue, dropping (or dead-lettering)
 453            // work whose side effects never ran. Leave it un-ACKed; the broker redelivers it when
 454            // the channel closes.
 455            return;
 456        }
 457        catch (Exception ex)
 458        {
 459            // Requeue for redelivery, unless a delivery cap is configured and this delivery has reached it —
 460            // then reject without requeue so the broker dead-letters (or drops) it instead of hot-looping.
 461            // Once the broker has dead-lettered the message (x-death present), a plain requeue can never
 462            // advance the attempt again — x-death only counts dead-letter hops and `redelivered` is already
 463            // set — so every retry BELOW the cap must ride the dead-letter cycle, which is what makes the
 464            // operator's cap countable at all. AT the cap with x-death present that same cycle is exactly
 465            // what must not run again: the dead-letter exchange already brought this message back once,
 466            // so a reject re-entered it at the cycle's TTL rate forever and the cap never parked anything
 467            // (EffectiveDeliveryCap was unreachable on the very path it was written for). Park it here.
 468            var deathCount = ReadDeathCount(delivery.BasicProperties);
 469            var belowCap = ResolveDeliveryAttempt(delivery) < EffectiveDeliveryCap(delivery);
 470            if (MaxDeliveryAttempts <= 0 || (deathCount == 0 && belowCap))
 471                await TryNackAsync(delivery, channel, requeue: true).ConfigureAwait(false);
 472            else if (deathCount == 0 || belowCap)
 473                await TryNackAsync(delivery, channel, requeue: false).ConfigureAwait(false);
 474            else
 475                await ParkAtCapAsync(delivery, channel, ex, subscriberCancellationToken).ConfigureAwait(false);
 476            return;
 477        }
 478
 479        // The ACK sits outside the handler's try/catch: a transient BasicAck failure after a
 480        // successful handler must not be NACKed — the broker would redeliver and re-run side
 481        // effects that already completed. The un-ACKed delivery is redelivered anyway when the
 482        // channel closes, which is the unavoidable at-least-once floor. Settlement deliberately
 483        // ignores cancellation (as every sibling transport does): a shutdown racing the ACK would
 484        // abort the settle and redeliver work whose handler already ran.
 485        try
 486        {
 487            await channel.BasicAckAsync(delivery.DeliveryTag, CancellationToken.None).ConfigureAwait(false);
 488        }
 489        catch (Exception ex)
 490        {
 491            Logger.LogError(
 492                ex,
 493                "Failed to ACK RabbitMQ delivery {DeliveryTag} for {Queue} after a successful handler; the broker will r
 494                delivery.DeliveryTag,
 495                QueueName);
 496        }
 497    }
 498
 499    /// <summary>
 500    /// Terminal settlement for a delivery at its cap whose <c>x-death</c> shows the dead-letter
 501    /// exchange already returned it once: another reject would only re-enter that cycle. The
 502    /// message is copied to <see cref="RabbitMqAsyncResponseOptions.ParkQueue"/> (or, without one,
 503    /// <see cref="RabbitMqAsyncResponseOptions.DeadLetterQueue"/>) through the default exchange —
 504    /// bypassing the exchange that cycles — and the delivery is ACKed so the loop ends; without a
 505    /// queue the drop is logged as an error. A failed copy is handed back to the broker with a
 506    /// requeue after a backoff, so it is redelivered and the park retries.
 507    /// </summary>
 508    private async Task ParkAtCapAsync(
 509        RabbitMqDelivery delivery,
 510        IRabbitMqChannel channel,
 511        Exception exception,
 512        CancellationToken subscriberCancellationToken)
 513    {
 514        // A closed channel already requeued every un-ACKed delivery; it comes back with the same
 515        // x-death count and parks on its next attempt.
 516        if (!channel.IsOpen)
 517            return;
 518
 519        var parkQueue = !string.IsNullOrWhiteSpace(TransportOptions.ParkQueue)
 520            ? TransportOptions.ParkQueue
 521            : TransportOptions.DeadLetterQueue;
 522        if (!string.IsNullOrWhiteSpace(parkQueue))
 523        {
 524            try
 525            {
 526                await channel.BasicPublishAsync(
 527                    string.Empty,
 528                    parkQueue,
 529                    BuildDeadLetterProperties(delivery, exception),
 530                    delivery.Body,
 531                    CancellationToken.None).ConfigureAwait(false);
 532                Volatile.Write(ref _consecutiveParkFailures, 0);
 533                Logger.LogWarning(
 534                    exception,
 535                    "RabbitMQ delivery {DeliveryTag} on {Queue} reached {MaxDeliveryAttempts} delivery attempts after ri
 536                    delivery.DeliveryTag,
 537                    QueueName,
 538                    MaxDeliveryAttempts,
 539                    parkQueue);
 540            }
 541            catch (Exception publishException)
 542            {
 543                // Hand the delivery back explicitly. AMQP never redelivers an un-ACKed delivery
 544                // while its channel stays open — leaving it unsettled only pinned one prefetch
 545                // credit, and PrefetchCount failed parks later the consumer received nothing at
 546                // all, most likely during the very incident that is filling the park queue. The
 547                // pause keeps the requeue → redeliver → failed-park loop off broker rate; it runs
 548                // inside the delivery callback, so this channel's deliveries wait with it.
 549                var retryDelay = AsyncResponseRetry.Backoff(
 550                    Interlocked.Increment(ref _consecutiveParkFailures),
 551                    TransportOptions.SubscriberRetryBaseDelay,
 552                    TransportOptions.SubscriberRetryMaxDelay);
 553                Logger.LogError(
 554                    publishException,
 555                    "Failed to park capped RabbitMQ delivery {DeliveryTag} on {Queue} in {DeadLetterQueue}; requeueing i
 556                    delivery.DeliveryTag,
 557                    QueueName,
 558                    parkQueue,
 559                    retryDelay);
 560
 561                try
 562                {
 563                    await Task.Delay(retryDelay, subscriberCancellationToken).ConfigureAwait(false);
 564                }
 565                catch (OperationCanceledException)
 566                {
 567                    // Stopping: skip the rest of the pause, not the requeue.
 568                }
 569
 570                await TryNackAsync(delivery, channel, requeue: true).ConfigureAwait(false);
 571                return;
 572            }
 573        }
 574        else
 575        {
 576            Logger.LogError(
 577                exception,
 578                "RabbitMQ delivery {DeliveryTag} on {Queue} reached {MaxDeliveryAttempts} delivery attempts after riding
 579                delivery.DeliveryTag,
 580                QueueName,
 581                MaxDeliveryAttempts);
 582        }
 583
 584        try
 585        {
 586            await channel.BasicAckAsync(delivery.DeliveryTag, CancellationToken.None).ConfigureAwait(false);
 587        }
 588        catch (Exception ackException)
 589        {
 590            Logger.LogWarning(
 591                ackException,
 592                "Failed to ACK parked RabbitMQ delivery {DeliveryTag} on {Queue}; the broker redelivers it when the chan
 593                delivery.DeliveryTag,
 594                QueueName);
 595        }
 596    }
 597
 598    private async ValueTask TryNackAsync(RabbitMqDelivery delivery, IRabbitMqChannel channel, bool requeue)
 599    {
 600        // Never throw from here: this runs inside the client's delivery callback, and an escaped
 601        // exception would leave the delivery neither ACKed nor NACKed — a prefetch credit pinned
 602        // with no app-visible trace and the requeue/reject decision silently lost.
 603        // A closed channel already returned every un-ACKed delivery to the queue; NACKing it would throw.
 604        if (!channel.IsOpen)
 605        {
 606            Logger.LogWarning(
 607                "Skipping NACK ({NackDecision}) of RabbitMQ delivery {DeliveryTag} for {Queue}: the channel is closed, s
 608                requeue ? "requeue" : "reject",
 609                delivery.DeliveryTag,
 610                QueueName);
 611            return;
 612        }
 613
 614        try
 615        {
 616            await channel.BasicNackAsync(delivery.DeliveryTag, requeue, CancellationToken.None).ConfigureAwait(false);
 617        }
 618        catch (Exception ex)
 619        {
 620            Logger.LogWarning(
 621                ex,
 622                "Failed to NACK ({NackDecision}) RabbitMQ delivery {DeliveryTag} for {Queue}; the broker redelivers it w
 623                requeue ? "requeue" : "reject",
 624                delivery.DeliveryTag,
 625                QueueName);
 626        }
 627    }
 628}
 629
 630internal sealed class QueuedRabbitMqMessageDispatcher : RabbitMqMessageDispatcher
 631{
 632    private readonly Channel<RabbitMqDelivery> _queue;
 633    private readonly Task[] _workers;
 634    private readonly CancellationTokenSource _drainCancellation = new();
 635
 636    /// <summary>Serializes best-effort dead-letter publishes from concurrent background workers.</summary>
 637    private readonly SemaphoreSlim _deadLetterPublishGate = new(1, 1);
 638
 639    /// <summary>
 640    /// The subscriber's channel, captured per delivery: background workers need it to publish an
 641    /// already-ACKed failed delivery to the dead-letter exchange (the native reject-without-requeue
 642    /// DLX route is unreachable once the ACK happened at enqueue).
 643    /// </summary>
 644    private volatile IRabbitMqChannel? _channel;
 645
 646    /// <summary>
 647    /// The live subscriber attempt's channel. This dispatcher outlives attempts — queued work that
 648    /// was already ACKed must survive a channel shutdown or connection blip instead of being
 649    /// drained as if the host were stopping — so a background failure that lands while an attempt
 650    /// is being rebuilt publishes through the NEXT attempt's channel, never the dead one.
 651    /// </summary>
 652    private readonly object _attachGate = new();
 653    private ChannelAttachment? _attachment;
 654    private TaskCompletionSource _attached = new(TaskCreationOptions.RunContinuationsAsynchronously);
 655
 656    /// <summary>Signalled when disposal starts: no further attempt will attach a channel.</summary>
 657    private readonly CancellationTokenSource _stopping = new();
 658    private readonly CancellationToken _stoppingToken;
 659    private readonly TimeSpan _deadLetterChannelWait;
 660    private readonly TimeSpan _drainTimeout;
 661    private readonly string _queueName;
 662    private readonly RabbitMqSubscriberRole _role;
 663    private int _pendingCount;
 664    private int _runningCount;
 665    private int _disposeStarted;
 666
 667    /// <summary>Runs the QueuedRabbitMqMessageDispatcher operation.</summary>
 668    public QueuedRabbitMqMessageDispatcher(
 669        Func<RabbitMqDelivery, CancellationToken, Task> handler,
 670        RabbitMqAsyncResponseOptions transportOptions,
 671        RabbitMqSubscriberOptions subscriberOptions,
 672        ILogger logger,
 673        string queue,
 674        RabbitMqSubscriberRole role)
 675        : base(handler, transportOptions, subscriberOptions, logger, queue, role)
 676    {
 677        _drainTimeout = subscriberOptions.BackgroundDrainTimeout;
 678        _stoppingToken = _stopping.Token;
 679
 680        // Twice the supervisor's longest backoff: a reachable broker yields the next attempt's
 681        // channel well inside it, and an unreachable one must not hold a background worker (and
 682        // the queued work behind it) for the length of the outage.
 683        var channelWait = transportOptions.SubscriberRetryMaxDelay + transportOptions.SubscriberRetryMaxDelay;
 684        _deadLetterChannelWait = channelWait > AsyncResponseChannelOptions.MaxTimerBackedTimeout
 685            ? AsyncResponseChannelOptions.MaxTimerBackedTimeout
 686            : channelWait;
 687        _queueName = queue;
 688        _role = role;
 689        _queue = Channel.CreateBounded<RabbitMqDelivery>(new BoundedChannelOptions(subscriberOptions.BackgroundQueueCapa
 690        {
 691            AllowSynchronousContinuations = false,
 692            FullMode = BoundedChannelFullMode.Wait,
 693            SingleReader = subscriberOptions.BackgroundWorkerCount == 1,
 694            SingleWriter = false
 695        });
 696
 697        _workers = Enumerable.Range(0, subscriberOptions.BackgroundWorkerCount)
 698            .Select(workerIndex => Task.Run(() => RunWorkerAsync(workerIndex)))
 699            .ToArray();
 700
 701        Logger.LogInformation(
 702            "Created RabbitMQ ACK-after-enqueue dispatcher for {Queue} with {WorkerCount} worker(s), queue capacity {Que
 703            _queueName,
 704            subscriberOptions.BackgroundWorkerCount,
 705            subscriberOptions.BackgroundQueueCapacity,
 706            _drainTimeout);
 707    }
 708
 709    internal int PendingCount => Volatile.Read(ref _pendingCount);
 710    internal int RunningCount => Volatile.Read(ref _runningCount);
 711
 712    /// <summary>Handles the delivered message.</summary>
 713    public override async Task HandleAsync(
 714        RabbitMqDelivery delivery,
 715        IRabbitMqChannel channel,
 716        CancellationToken subscriberCancellationToken)
 717    {
 718        // The client owns the delivery body's memory only until the consumer callback returns
 719        // ("Accessing the body at a later point is unsafe as its memory can be already
 720        // released" — RabbitMQ.Client v7). This dispatcher hands the delivery to background
 721        // workers that read the body after the callback, so materialize a private copy now.
 722        // The awaiting dispatcher consumes the body inside the callback and stays zero-copy.
 723        delivery = delivery with { Body = delivery.Body.ToArray() };
 724        _channel = channel;
 725
 726        Interlocked.Increment(ref _pendingCount);
 727        if (!_queue.Writer.TryWrite(delivery))
 728        {
 729            // Saturated: wait for a worker to free a slot instead of NACKing. The early ACK below has
 730            // already released the prefetch credit, so QoS cannot bound a NACK/redeliver cycle — the
 731            // broker would redeliver within ~1 RTT and spin at network rate. RabbitMQ.Client dispatches
 732            // a channel's deliveries sequentially, so blocking here pauses this channel's delivery
 733            // loop, which is the actual backpressure (mirrors the Kafka pause and the NATS wait).
 734            Logger.LogDebug(
 735                "RabbitMQ background queue for {Queue} is full; pausing the delivery loop until capacity frees. Pending=
 736                _queueName,
 737                PendingCount,
 738                RunningCount);
 739            // The park also ends with the attempt that delivered it. The queue outlives attempts,
 740            // so a write parked under a channel that has since died would otherwise land later —
 741            // after the broker already requeued that un-ACKed delivery for the next attempt — and
 742            // the job would run twice.
 743            using var parked = CancellationTokenSource.CreateLinkedTokenSource(
 744                subscriberCancellationToken,
 745                AttachmentEnded(channel));
 746            try
 747            {
 748                await _queue.Writer.WriteAsync(delivery, parked.Token).ConfigureAwait(false);
 749            }
 750            catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException)
 751            {
 752                // Subscriber stopping, its attempt ending, or dispatcher draining while parked: the
 753                // delivery was never enqueued (and never ACKed), so hand it back to the broker — one
 754                // NACK, not a spin. A closed channel requeues the un-ACKed delivery on its own;
 755                // never throw from here, this runs inside the client's delivery callback.
 756                Interlocked.Decrement(ref _pendingCount);
 757                await TryRequeueAsync(delivery, channel).ConfigureAwait(false);
 758                return;
 759            }
 760        }
 761
 762        // The delivery now belongs to a background worker, which decrements _pendingCount when it dequeues.
 763        // Do not touch the counter or NACK here, even if the ACK below fails — the message is already
 764        // executing in-process and a NACK would trigger a duplicate execution via requeue. Settlement
 765        // deliberately ignores cancellation (as every sibling transport does): a shutdown racing the
 766        // ACK would abort the settle and redeliver a job the background worker is still running.
 767        try
 768        {
 769            await channel.BasicAckAsync(delivery.DeliveryTag, CancellationToken.None).ConfigureAwait(false);
 770        }
 771        catch (Exception ex)
 772        {
 773            Logger.LogError(
 774                ex,
 775                "Failed to ACK RabbitMQ delivery {DeliveryTag} for {Queue} after enqueue; it is being processed but the 
 776                delivery.DeliveryTag,
 777                _queueName);
 778        }
 779    }
 780
 781    /// <summary>
 782    /// Publishes an already-ACKed failed delivery to the configured dead-letter exchange
 783    /// (Kafka/Redis dispatcher parity). The native reject-without-requeue DLX route is
 784    /// unreachable here — the ACK happened at enqueue — so without this copy a permanently
 785    /// failing job vanished with one log line: no requeue, no DLX record, no forensic trail.
 786    /// Best-effort: on any failure the loss stays observable via the log and OnBackgroundFailure,
 787    /// exactly as before. Mirrors native dead-lettering's routing: the original routing key,
 788    /// unless DeadLetterRoutingKey overrides it (the same rule the topology binds the
 789    /// dead-letter queue with).
 790    /// </summary>
 791    private async Task TryDeadLetterAlreadyAckedAsync(RabbitMqDelivery delivery, Exception exception)
 792    {
 793        if (string.IsNullOrWhiteSpace(TransportOptions.DeadLetterExchange))
 794            return;
 795
 796        var properties = BuildDeadLetterProperties(delivery, exception);
 797
 798        var routingKey = string.IsNullOrWhiteSpace(TransportOptions.DeadLetterRoutingKey)
 799            ? delivery.RoutingKey
 800            : TransportOptions.DeadLetterRoutingKey;
 801
 802        // The channel this delivery arrived on may be gone: the dispatcher outlives subscriber
 803        // attempts. Publish through the live attempt's channel, waiting for the next one while the
 804        // subscriber is being rebuilt — bounded, and not at all once disposal has started (no
 805        // further attempt follows).
 806        using var channelWait = CancellationTokenSource.CreateLinkedTokenSource(_stoppingToken);
 807        channelWait.CancelAfter(_deadLetterChannelWait);
 808
 809        while (true)
 810        {
 811            var channel = await WaitForOpenChannelAsync(channelWait.Token).ConfigureAwait(false);
 812            if (channel is null)
 813            {
 814                Logger.LogError(
 815                    "Cannot dead-letter already-ACKed RabbitMQ delivery {DeliveryTag} on {Queue}: the subscriber channel
 816                    delivery.DeliveryTag,
 817                    _queueName);
 818                return;
 819            }
 820
 821            // Serialized: multiple background workers can fail concurrently, and they share the
 822            // subscriber's one channel.
 823            await _deadLetterPublishGate.WaitAsync().ConfigureAwait(false);
 824            try
 825            {
 826                await channel.BasicPublishAsync(
 827                    TransportOptions.DeadLetterExchange!,
 828                    routingKey,
 829                    properties,
 830                    delivery.Body,
 831                    CancellationToken.None).ConfigureAwait(false);
 832                Logger.LogInformation(
 833                    "Dead-lettered already-ACKed RabbitMQ delivery {DeliveryTag} from {Queue} to exchange {DeadLetterExc
 834                    delivery.DeliveryTag,
 835                    _queueName,
 836                    TransportOptions.DeadLetterExchange);
 837                return;
 838            }
 839            catch (Exception publishException) when (!channel.IsOpen && !channelWait.IsCancellationRequested)
 840            {
 841                // The channel died under the publish (a broker nack or an unroutable return leaves
 842                // it open): the next attempt's channel takes the copy.
 843                Logger.LogDebug(
 844                    publishException,
 845                    "The subscriber channel closed while dead-lettering already-ACKed RabbitMQ delivery {DeliveryTag} on
 846                    delivery.DeliveryTag,
 847                    _queueName);
 848            }
 849            catch (Exception publishException)
 850            {
 851                Logger.LogError(
 852                    publishException,
 853                    "Failed to dead-letter already-ACKed RabbitMQ delivery {DeliveryTag} on {Queue}; the failure is only
 854                    delivery.DeliveryTag,
 855                    _queueName);
 856                return;
 857            }
 858            finally
 859            {
 860                _deadLetterPublishGate.Release();
 861            }
 862        }
 863    }
 864
 865    /// <summary>Binds the live subscriber attempt's channel.</summary>
 866    public override IDisposable AttachChannel(IRabbitMqChannel channel)
 867    {
 868        var attachment = new ChannelAttachment(this, channel);
 869        lock (_attachGate)
 870        {
 871            _attachment = attachment;
 872            _attached.TrySetResult();
 873        }
 874
 875        return attachment;
 876    }
 877
 878    private void Detach(ChannelAttachment attachment)
 879    {
 880        lock (_attachGate)
 881        {
 882            if (ReferenceEquals(_attachment, attachment))
 883                _attachment = null;
 884        }
 885    }
 886
 887    /// <summary>
 888    /// Cancelled when the attempt that attached <paramref name="channel"/> ends; never, for a
 889    /// channel no attempt attached (a caller driving <see cref="HandleAsync"/> directly).
 890    /// </summary>
 891    private CancellationToken AttachmentEnded(IRabbitMqChannel channel)
 892    {
 893        lock (_attachGate)
 894        {
 895            return _attachment is { } attachment && ReferenceEquals(attachment.Channel, channel)
 896                ? attachment.Ended
 897                : CancellationToken.None;
 898        }
 899    }
 900
 901    /// <summary>
 902    /// The open channel to publish through — the attached attempt's, else the one the last delivery
 903    /// arrived on — or <c>null</c> once <paramref name="cancellationToken"/> fires with none open.
 904    /// </summary>
 905    private async ValueTask<IRabbitMqChannel?> WaitForOpenChannelAsync(CancellationToken cancellationToken)
 906    {
 907        while (true)
 908        {
 909            Task attached;
 910            lock (_attachGate)
 911            {
 912                if ((_attachment?.Channel ?? _channel) is { IsOpen: true } channel)
 913                    return channel;
 914
 915                // A closed channel whose attempt has not unwound yet still counts as attached:
 916                // wait for the NEXT attach, not the one that already happened.
 917                if (_attached.Task.IsCompleted)
 918                    _attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 919                attached = _attached.Task;
 920            }
 921
 922            try
 923            {
 924                await attached.WaitAsync(cancellationToken).ConfigureAwait(false);
 925            }
 926            catch (OperationCanceledException)
 927            {
 928                return null;
 929            }
 930        }
 931    }
 932
 933    private sealed class ChannelAttachment : IDisposable
 934    {
 935        private readonly QueuedRabbitMqMessageDispatcher _owner;
 936        private readonly CancellationTokenSource _ended = new();
 937        private int _disposed;
 938
 939        public ChannelAttachment(QueuedRabbitMqMessageDispatcher owner, IRabbitMqChannel channel)
 940        {
 941            _owner = owner;
 942            Channel = channel;
 943            Ended = _ended.Token;
 944        }
 945
 946        public IRabbitMqChannel Channel { get; }
 947        public CancellationToken Ended { get; }
 948
 949        public void Dispose()
 950        {
 951            if (Interlocked.Exchange(ref _disposed, 1) != 0)
 952                return;
 953
 954            _owner.Detach(this);
 955            _ended.Cancel();
 956            _ended.Dispose();
 957        }
 958    }
 959
 960    private async ValueTask TryRequeueAsync(RabbitMqDelivery delivery, IRabbitMqChannel channel)
 961    {
 962        // A closed channel already returned every un-ACKed delivery to the queue; NACKing it would throw.
 963        if (!channel.IsOpen)
 964            return;
 965
 966        try
 967        {
 968            await channel.BasicNackAsync(delivery.DeliveryTag, requeue: true, CancellationToken.None).ConfigureAwait(fal
 969        }
 970        catch (Exception ex)
 971        {
 972            Logger.LogDebug(
 973                ex,
 974                "Failed to NACK delivery {DeliveryTag} for {Queue} during shutdown; the broker requeues it when the chan
 975                delivery.DeliveryTag,
 976                _queueName);
 977        }
 978    }
 979
 980    /// <summary>Releases resources held by this instance.</summary>
 981    public override async ValueTask DisposeAsync()
 982    {
 983        if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
 984            return;
 985
 986        Logger.LogInformation(
 987            "Draining RabbitMQ ACK-after-enqueue dispatcher for {Queue}. Pending={PendingCount}, Running={RunningCount}.
 988            _queueName,
 989            PendingCount,
 990            RunningCount);
 991        _queue.Writer.TryComplete();
 992
 993        // Workers read the token captured at construction, never the source, so disposing it
 994        // under them is safe.
 995        _stopping.Cancel();
 996        _stopping.Dispose();
 997
 998        try
 999        {
 1000            await Task.WhenAll(_workers).WaitAsync(_drainTimeout).ConfigureAwait(false);
 1001            _drainCancellation.Dispose();
 1002        }
 1003        catch (TimeoutException ex)
 1004        {
 1005            _drainCancellation.Cancel();
 1006            Logger.LogWarning(
 1007                ex,
 1008                "Timed out while draining RabbitMQ ACK-after-enqueue dispatcher for {Queue}. Pending={PendingCount}, Run
 1009                _queueName,
 1010                PendingCount,
 1011                RunningCount);
 1012
 1013            // The workers are still running and read _drainCancellation.Token each loop, so disposing it now
 1014            // would throw ObjectDisposedException inside them. Dispose once they actually finish, off the
 1015            // shutdown path, so the source is not leaked either.
 1016            _ = Task.WhenAll(_workers).ContinueWith(
 1017                _ => _drainCancellation.Dispose(),
 1018                CancellationToken.None,
 1019                TaskContinuationOptions.ExecuteSynchronously,
 1020                TaskScheduler.Default);
 1021        }
 1022        catch (Exception ex)
 1023        {
 1024            // A worker faulted outside its own handler guard (DB/NATS dispatcher parity). WhenAll
 1025            // only completes once every worker has finished, so the source is safe to dispose here
 1026            // — and the fault must not escape DisposeAsync and mask the real shutdown path.
 1027            Logger.LogDebug(ex, "RabbitMQ ACK-after-enqueue dispatcher drain for {Queue} ended with an error.", _queueNa
 1028            _drainCancellation.Dispose();
 1029        }
 1030    }
 1031
 1032    private async Task RunWorkerAsync(int workerIndex)
 1033    {
 1034        await foreach (var delivery in _queue.Reader.ReadAllAsync().ConfigureAwait(false))
 1035        {
 1036            Interlocked.Decrement(ref _pendingCount);
 1037
 1038            // Once the drain budget has lapsed, STOP executing (DB/Redis/Pub-Sub parity). The
 1039            // token below cannot stop the real handler — it is
 1040            // `_ingress.HandleWorkerMessageAsync(payload)`, whose target takes no
 1041            // CancellationToken — so past the budget the loop kept starting fresh work beyond the
 1042            // host's shutdown budget, and every entry still queued at process exit vanished with
 1043            // no record (ACKed at enqueue, so the broker never redelivers it).
 1044            if (_drainCancellation.IsCancellationRequested)
 1045            {
 1046                var lapsed = new OperationCanceledException(
 1047                    "The ACK-after-enqueue drain budget lapsed before this already-ACKed message was handled.");
 1048                Logger.LogWarning(
 1049                    "RabbitMQ background handler for already-ACKed delivery {DeliveryTag} on {Queue} was not started: th
 1050                    delivery.DeliveryTag,
 1051                    _queueName);
 1052                await NotifyBackgroundFailureAsync(delivery, lapsed, _queueName, _role).ConfigureAwait(false);
 1053                await TryDeadLetterAlreadyAckedAsync(delivery, lapsed).ConfigureAwait(false);
 1054                continue;
 1055            }
 1056
 1057            Interlocked.Increment(ref _runningCount);
 1058
 1059            try
 1060            {
 1061                await ExecuteHandlerAsync(
 1062                    delivery,
 1063                    _drainCancellation.Token,
 1064                    logFailures: false).ConfigureAwait(false);
 1065            }
 1066            catch (Exception ex)
 1067            {
 1068                Logger.LogError(
 1069                    ex,
 1070                    "RabbitMQ background handler failed for already-ACKed delivery {DeliveryTag} on {Queue}.",
 1071                    delivery.DeliveryTag,
 1072                    _queueName);
 1073                await NotifyBackgroundFailureAsync(
 1074                    delivery,
 1075                    ex,
 1076                    _queueName,
 1077                    _role).ConfigureAwait(false);
 1078                await TryDeadLetterAlreadyAckedAsync(delivery, ex).ConfigureAwait(false);
 1079            }
 1080            finally
 1081            {
 1082                Interlocked.Decrement(ref _runningCount);
 1083            }
 1084        }
 1085    }
 1086}