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

Information
Class: AsyncResponse.Transports.AzureServiceBus.AwaitingAzureServiceBusMessageDispatcher
Assembly: AsyncResponse.Transports.AzureServiceBus
File(s): /_/src/Transports/AsyncResponse.Transports.AzureServiceBus/AzureServiceBusMessageDispatcher.cs
Line coverage
100%
Covered lines: 36
Uncovered lines: 0
Coverable lines: 36
Total lines: 505
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%44100%

File(s)

/_/src/Transports/AsyncResponse.Transports.AzureServiceBus/AzureServiceBusMessageDispatcher.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using System.Diagnostics;
 3using System.Threading.Channels;
 4
 5namespace AsyncResponse.Transports.AzureServiceBus;
 6
 7internal enum AzureServiceBusSubscriberRole
 8{
 9    Worker,
 10    ResponseIngress
 11}
 12
 13internal abstract class AzureServiceBusMessageDispatcher : IAsyncDisposable
 14{
 15    private readonly Func<AzureServiceBusTransportDelivery, CancellationToken, Task> _handler;
 16    private readonly AzureServiceBusAsyncResponseOptions _transportOptions;
 17    private readonly AzureServiceBusSubscriberOptions _subscriberOptions;
 18    private readonly string _queue;
 19    private readonly AzureServiceBusSubscriberRole _role;
 20
 21    protected AzureServiceBusMessageDispatcher(
 22        Func<AzureServiceBusTransportDelivery, CancellationToken, Task> handler,
 23        AzureServiceBusAsyncResponseOptions transportOptions,
 24        AzureServiceBusSubscriberOptions subscriberOptions,
 25        ILogger logger,
 26        string queue,
 27        AzureServiceBusSubscriberRole role)
 28    {
 29        _handler = handler;
 30        _transportOptions = transportOptions;
 31        _subscriberOptions = subscriberOptions;
 32        Logger = logger;
 33        _queue = queue;
 34        _role = role;
 35    }
 36
 37    protected ILogger Logger { get; }
 38    protected int MaxDeliveryAttempts => _subscriberOptions.MaxDeliveryAttempts;
 39
 40    /// <summary>
 41    /// Service Bus rejects a dead-letter reason or description longer than 4096 characters with
 42    /// ArgumentOutOfRangeException, thrown client-side before any network call. The surrounding
 43    /// catch could not tell that apart from a lost lock, so a handler whose exception message ran
 44    /// long could never be dead-lettered at all: the library's MaxDeliveryAttempts cap went
 45    /// silently inoperative and the handler re-ran until the ENTITY's own MaxDeliveryCount.
 46    /// </summary>
 47    protected const int MaxDeadLetterDescriptionLength = 4096;
 48
 49    protected static string TruncateDeadLetterDescription(string? description)
 50        // Surrogate-aware cut: an exception message is arbitrary text, and a fixed-index slice
 51        // through a non-BMP character left a lone high surrogate that the AMQP encoder replaces
 52        // with U+FFFD — corrupting the forensic text exactly where it was cut.
 53        => string.IsNullOrEmpty(description)
 54            ? string.Empty
 55            : PortableText.TruncateWellFormed(description, MaxDeadLetterDescriptionLength);
 56
 57    /// <summary>Creates the dispatcher configured by the subscriber options.</summary>
 58    public static AzureServiceBusMessageDispatcher Create(
 59        Func<AzureServiceBusTransportDelivery, CancellationToken, Task> handler,
 60        AzureServiceBusAsyncResponseOptions transportOptions,
 61        AzureServiceBusSubscriberOptions subscriberOptions,
 62        ILogger logger,
 63        string queue,
 64        AzureServiceBusSubscriberRole role)
 65    {
 66        AzureServiceBusOptionsValidator.ValidateSubscriber(transportOptions, subscriberOptions, role);
 67
 68        return subscriberOptions.AckMode == AzureServiceBusAckMode.AckAfterHandlerCompletes
 69            ? new AwaitingAzureServiceBusMessageDispatcher(
 70                handler,
 71                transportOptions,
 72                subscriberOptions,
 73                logger,
 74                queue,
 75                role)
 76            : new QueuedAzureServiceBusMessageDispatcher(
 77                handler,
 78                transportOptions,
 79                subscriberOptions,
 80                logger,
 81                queue,
 82                role);
 83    }
 84
 85    /// <summary>Validates the supplied subscriber options.</summary>
 86    public static void ValidateOptions(
 87        AzureServiceBusAsyncResponseOptions transportOptions,
 88        AzureServiceBusSubscriberOptions subscriberOptions,
 89        AzureServiceBusSubscriberRole role)
 90        => AzureServiceBusOptionsValidator.ValidateSubscriber(transportOptions, subscriberOptions, role);
 91
 92    /// <summary>Handles the delivered message.</summary>
 93    public abstract Task HandleAsync(
 94        AzureServiceBusTransportDelivery delivery,
 95        CancellationToken subscriberCancellationToken);
 96
 97    /// <summary>
 98    /// Whether the dispatcher can accept more deliveries right now. Awaiting dispatchers always can
 99    /// (handlers run inline); the queued dispatcher returns <c>false</c> while its bounded queue is
 100    /// saturated so the receive loop stops pulling messages instead of receiving and abandoning them —
 101    /// every abandon burns <c>DeliveryCount</c> toward the entity's MaxDeliveryCount.
 102    /// </summary>
 103    public virtual bool CanAcceptMore => true;
 104
 105    /// <summary>
 106    /// Number of deliveries the dispatcher can accept right now. The receive loop requests at most
 107    /// this many messages per receive in early-ACK mode so a burst never overflows the background queue.
 108    /// </summary>
 109    public virtual int FreeCapacity => int.MaxValue;
 110
 111    /// <summary>
 112    /// Waits until the dispatcher can accept at least one more delivery. Completes immediately for
 113    /// awaiting dispatchers; the queued dispatcher waits for a background worker to free a slot.
 114    /// </summary>
 115    public virtual ValueTask WaitForCapacityAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
 116
 117    /// <summary>Releases resources held by this instance.</summary>
 118    public virtual ValueTask DisposeAsync() => ValueTask.CompletedTask;
 119
 120    protected async Task ExecuteHandlerAsync(
 121        AzureServiceBusTransportDelivery delivery,
 122        CancellationToken cancellationToken,
 123        bool logFailures = true)
 124    {
 125        using var activity = AsyncResponseDiagnostics.StartActivity(
 126            "asyncresponse.azure_service_bus.receive",
 127            ActivityKind.Consumer);
 128        activity?.SetTag("asyncresponse.transport", "azure_service_bus");
 129        activity?.SetTag("asyncresponse.azure_service_bus.role", _role.ToString());
 130        activity?.SetTag("asyncresponse.azure_service_bus.ack_mode", _subscriberOptions.AckMode.ToString());
 131        activity?.SetTag("messaging.system", "azure_service_bus");
 132        activity?.SetTag("messaging.destination.name", _queue);
 133        activity?.SetTag("messaging.message.id", delivery.MessageId);
 134        activity?.SetTag("messaging.azure_service_bus.sequence_number", delivery.SequenceNumber);
 135
 136        if (!string.IsNullOrWhiteSpace(delivery.CorrelationId))
 137            AsyncResponseDiagnostics.SetCorrelationId(activity, delivery.CorrelationId);
 138
 139        try
 140        {
 141            await _handler(delivery, cancellationToken).ConfigureAwait(false);
 142        }
 143        catch (Exception ex)
 144        {
 145            if (logFailures)
 146                Logger.LogError(ex, "Azure Service Bus message handling failed for message {MessageId}.", delivery.Messa
 147            AsyncResponseDiagnostics.SetError(activity, ex);
 148            throw;
 149        }
 150    }
 151
 152    protected async ValueTask NotifyBackgroundFailureAsync(
 153        AzureServiceBusTransportDelivery delivery,
 154        Exception exception,
 155        string queue,
 156        AzureServiceBusSubscriberRole role)
 157    {
 158        var callback = _subscriberOptions.OnBackgroundFailure;
 159        if (callback is null)
 160            return;
 161
 162        try
 163        {
 164            var context = new AzureServiceBusBackgroundFailureContext(
 165                queue,
 166                role.ToString(),
 167                delivery.SequenceNumber,
 168                delivery.MessageId,
 169                delivery.CorrelationId ?? TryReadApplicationCorrelationId(delivery),
 170                exception);
 171            await callback(context).ConfigureAwait(false);
 172        }
 173        catch (Exception callbackException)
 174        {
 175            Logger.LogError(
 176                callbackException,
 177                "Azure Service Bus background failure callback failed for already-completed message {MessageId} on {Queu
 178                delivery.MessageId,
 179                queue);
 180        }
 181    }
 182
 183    private string? TryReadApplicationCorrelationId(AzureServiceBusTransportDelivery delivery)
 184    {
 185        if (!string.IsNullOrWhiteSpace(_transportOptions.CorrelationIdProperty)
 186            && delivery.ApplicationProperties.TryGetValue(_transportOptions.CorrelationIdProperty, out var value))
 187        {
 188            return AzureServiceBusCorrelationIdExtractor.TryConvertProperty(value);
 189        }
 190
 191        return null;
 192    }
 193}
 194
 195internal sealed class AwaitingAzureServiceBusMessageDispatcher(
 196    Func<AzureServiceBusTransportDelivery, CancellationToken, Task> handler,
 197    AzureServiceBusAsyncResponseOptions transportOptions,
 198    AzureServiceBusSubscriberOptions subscriberOptions,
 199    ILogger logger,
 200    string queue,
 201    AzureServiceBusSubscriberRole role)
 426202    : AzureServiceBusMessageDispatcher(handler, transportOptions, subscriberOptions, logger, queue, role)
 203{
 204    /// <summary>Handles the delivered message.</summary>
 205    public override async Task HandleAsync(
 206        AzureServiceBusTransportDelivery delivery,
 207        CancellationToken subscriberCancellationToken)
 208    {
 209        try
 210        {
 446211            await ExecuteHandlerAsync(delivery, subscriberCancellationToken).ConfigureAwait(false);
 424212        }
 2213        catch (OperationCanceledException) when (subscriberCancellationToken.IsCancellationRequested)
 214        {
 215            // Host shutdown, not a handler failure: abandoning would burn a delivery count on
 216            // work that never ran, and at the cap the branch below would dead-letter healthy
 217            // work. Leave the delivery unsettled — the peek lock lapses on its own and
 218            // at-least-once redelivery applies after restart (parity with the
 219            // RabbitMQ/Redis/Kafka/DB dispatchers).
 2220            throw;
 221        }
 20222        catch (Exception ex)
 223        {
 224            // Failure-path settlement is guarded like the Complete below: a slow handler that
 225            // outlived its peek lock makes DeadLetter/Abandon throw MessageLockLost, and an
 226            // escaping settlement would tear down the whole receiver — dropping the rest of the
 227            // already-received batch un-settled. On a lost settle the lock lapses on its own and
 228            // at-least-once redelivery applies (DeliveryCount still advances broker-side).
 20229            if (MaxDeliveryAttempts > 0 && delivery.DeliveryCount >= MaxDeliveryAttempts)
 230            {
 231                try
 232                {
 11233                    await delivery.DeadLetterAsync(
 11234                        "AsyncResponseHandlerFailed",
 11235                        TruncateDeadLetterDescription(ex.Message)).ConfigureAwait(false);
 9236                }
 2237                catch (Exception settleEx)
 238                {
 2239                    Logger.LogWarning(
 2240                        settleEx,
 2241                        "Failed to dead-letter Azure Service Bus message {MessageId} after a failed handler; the lock wi
 2242                        delivery.MessageId);
 2243                }
 244
 11245                return;
 246            }
 247
 248            try
 249            {
 9250                await delivery.AbandonAsync().ConfigureAwait(false);
 7251            }
 2252            catch (Exception settleEx)
 253            {
 2254                Logger.LogWarning(
 2255                    settleEx,
 2256                    "Failed to abandon Azure Service Bus message {MessageId} after a failed handler; the lock will lapse
 2257                    delivery.MessageId);
 2258            }
 259
 9260            return;
 261        }
 262
 263        // The Complete sits outside the handler's try/catch: a transient settlement failure after
 264        // a successful handler must not be misread as a handler failure — dead-lettering or
 265        // abandoning here would redeliver (or bury) work whose side effects already completed.
 266        // Swallow and log instead; the peek-lock lapses on its own and at-least-once redelivery
 267        // applies.
 268        try
 269        {
 424270            await delivery.CompleteAsync().ConfigureAwait(false);
 422271        }
 2272        catch (Exception ex)
 273        {
 2274            Logger.LogWarning(
 2275                ex,
 2276                "Failed to complete Azure Service Bus message {MessageId} after a successful handler; the lock will laps
 2277                delivery.MessageId);
 2278        }
 444279    }
 280}
 281
 282internal sealed class QueuedAzureServiceBusMessageDispatcher : AzureServiceBusMessageDispatcher
 283{
 284    private readonly Channel<AzureServiceBusTransportDelivery> _queue;
 285    private readonly Task[] _workers;
 286    private readonly CancellationTokenSource _drainCancellation = new();
 287    private readonly TimeSpan _drainTimeout;
 288    private readonly int _capacity;
 289    private readonly string _queueName;
 290    private readonly AzureServiceBusSubscriberRole _role;
 291    private int _pendingCount;
 292    private int _runningCount;
 293    private int _disposeStarted;
 294
 295    /// <summary>Creates an ACK-after-enqueue dispatcher with a bounded background queue.</summary>
 296    public QueuedAzureServiceBusMessageDispatcher(
 297        Func<AzureServiceBusTransportDelivery, CancellationToken, Task> handler,
 298        AzureServiceBusAsyncResponseOptions transportOptions,
 299        AzureServiceBusSubscriberOptions subscriberOptions,
 300        ILogger logger,
 301        string queue,
 302        AzureServiceBusSubscriberRole role)
 303        : base(handler, transportOptions, subscriberOptions, logger, queue, role)
 304    {
 305        _drainTimeout = subscriberOptions.BackgroundDrainTimeout;
 306        _capacity = subscriberOptions.BackgroundQueueCapacity;
 307        _queueName = queue;
 308        _role = role;
 309        _queue = Channel.CreateBounded<AzureServiceBusTransportDelivery>(new BoundedChannelOptions(subscriberOptions.Bac
 310        {
 311            AllowSynchronousContinuations = false,
 312            FullMode = BoundedChannelFullMode.Wait,
 313            SingleReader = subscriberOptions.BackgroundWorkerCount == 1,
 314            SingleWriter = false
 315        });
 316
 317        _workers = Enumerable.Range(0, subscriberOptions.BackgroundWorkerCount)
 318            .Select(workerIndex => Task.Run(() => RunWorkerAsync(workerIndex)))
 319            .ToArray();
 320
 321        Logger.LogInformation(
 322            "Created Azure Service Bus ACK-after-enqueue dispatcher for {Queue} with {WorkerCount} worker(s), queue capa
 323            _queueName,
 324            subscriberOptions.BackgroundWorkerCount,
 325            subscriberOptions.BackgroundQueueCapacity,
 326            _drainTimeout);
 327    }
 328
 329    internal int PendingCount => Volatile.Read(ref _pendingCount);
 330    internal int RunningCount => Volatile.Read(ref _runningCount);
 331
 332    public override bool CanAcceptMore => Volatile.Read(ref _pendingCount) < _capacity;
 333
 334    public override int FreeCapacity => Math.Max(0, _capacity - Volatile.Read(ref _pendingCount));
 335
 336    public override async ValueTask WaitForCapacityAsync(CancellationToken cancellationToken)
 337    {
 338        // WaitToWriteAsync completes when the bounded channel has room (or the channel is completed
 339        // during dispose, in which case there is nothing left to gate).
 340        while (!CanAcceptMore)
 341        {
 342            if (!await _queue.Writer.WaitToWriteAsync(cancellationToken).ConfigureAwait(false))
 343                return;
 344        }
 345    }
 346
 347    /// <summary>Handles the delivered message.</summary>
 348    public override async Task HandleAsync(
 349        AzureServiceBusTransportDelivery delivery,
 350        CancellationToken subscriberCancellationToken)
 351    {
 352        Interlocked.Increment(ref _pendingCount);
 353        if (!_queue.Writer.TryWrite(delivery))
 354        {
 355            // The receive loop gates on free capacity, so this only covers the residual race between
 356            // its capacity check and this write. The abandon burns one DeliveryCount, but the loop
 357            // never receives while saturated, so a healthy message cannot repeat this path toward
 358            // the entity's MaxDeliveryCount.
 359            Interlocked.Decrement(ref _pendingCount);
 360            Logger.LogWarning(
 361                "Azure Service Bus background queue rejected message {MessageId} for {Queue}; abandoning for redelivery.
 362                delivery.MessageId,
 363                _queueName,
 364                PendingCount,
 365                RunningCount);
 366            try
 367            {
 368                await delivery.AbandonAsync().ConfigureAwait(false);
 369            }
 370            catch (Exception ex)
 371            {
 372                // Guarded like every other settlement: an escaping MessageLockLost would tear down
 373                // the receiver, and the lock lapsing redelivers the message on its own anyway.
 374                Logger.LogWarning(
 375                    ex,
 376                    "Failed to abandon Azure Service Bus message {MessageId} for {Queue}; the lock will lapse and the me
 377                    delivery.MessageId,
 378                    _queueName);
 379            }
 380
 381            return;
 382        }
 383
 384        // The delivery now belongs to a background worker, which decrements _pendingCount when it dequeues.
 385        // Do not touch the counter or abandon here, even if the Complete below fails — the message is already
 386        // executing in-process and abandoning it would trigger a duplicate execution via redelivery.
 387        try
 388        {
 389            await delivery.CompleteAsync().ConfigureAwait(false);
 390        }
 391        catch (Exception ex)
 392        {
 393            Logger.LogError(
 394                ex,
 395                "Failed to complete Azure Service Bus message {MessageId} for {Queue} after enqueue; it is being process
 396                delivery.MessageId,
 397                _queueName);
 398        }
 399    }
 400
 401    /// <summary>Releases resources held by this instance.</summary>
 402    public override async ValueTask DisposeAsync()
 403    {
 404        if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
 405            return;
 406
 407        Logger.LogInformation(
 408            "Draining Azure Service Bus ACK-after-enqueue dispatcher for {Queue}. Pending={PendingCount}, Running={Runni
 409            _queueName,
 410            PendingCount,
 411            RunningCount);
 412        _queue.Writer.TryComplete();
 413
 414        try
 415        {
 416            await Task.WhenAll(_workers).WaitAsync(_drainTimeout).ConfigureAwait(false);
 417            _drainCancellation.Dispose();
 418        }
 419        catch (TimeoutException ex)
 420        {
 421            _drainCancellation.Cancel();
 422            Logger.LogWarning(
 423                ex,
 424                "Timed out while draining Azure Service Bus ACK-after-enqueue dispatcher for {Queue}. Pending={PendingCo
 425                _queueName,
 426                PendingCount,
 427                RunningCount);
 428
 429            _ = Task.WhenAll(_workers).ContinueWith(
 430                _ => _drainCancellation.Dispose(),
 431                CancellationToken.None,
 432                TaskContinuationOptions.ExecuteSynchronously,
 433                TaskScheduler.Default);
 434        }
 435        catch (Exception ex)
 436        {
 437            // A worker faulted outside its own handler guard (DB/NATS dispatcher parity). WhenAll
 438            // only completes once every worker has finished, so the source is safe to dispose here
 439            // — and the fault must not escape DisposeAsync and mask the real shutdown path.
 440            Logger.LogDebug(ex, "Azure Service Bus ACK-after-enqueue dispatcher drain for {Queue} ended with an error.",
 441            _drainCancellation.Dispose();
 442        }
 443    }
 444
 445    private async Task RunWorkerAsync(int workerIndex)
 446    {
 447        await foreach (var delivery in _queue.Reader.ReadAllAsync().ConfigureAwait(false))
 448        {
 449            Interlocked.Decrement(ref _pendingCount);
 450
 451            // Once the drain budget has lapsed, STOP executing (DB/Redis/Pub-Sub parity). The
 452            // token below cannot stop the real handler — it is
 453            // `_ingress.HandleWorkerMessageAsync(payload)`, whose target takes no
 454            // CancellationToken — so past the budget the loop kept starting fresh work beyond the
 455            // host's shutdown budget, and every entry still queued at process exit vanished with
 456            // no record (completed at enqueue, so the broker never redelivers it). The settled
 457            // lock rules out a DLQ write; OnBackgroundFailure is the record.
 458            if (_drainCancellation.IsCancellationRequested)
 459            {
 460                var lapsed = new OperationCanceledException(
 461                    "The ACK-after-enqueue drain budget lapsed before this already-completed message was handled.");
 462                Logger.LogWarning(
 463                    "Azure Service Bus background handler for already-completed message {MessageId} on {Queue} was not s
 464                    delivery.MessageId,
 465                    _queueName);
 466                await NotifyBackgroundFailureAsync(delivery, lapsed, _queueName, _role).ConfigureAwait(false);
 467                continue;
 468            }
 469
 470            Interlocked.Increment(ref _runningCount);
 471
 472            try
 473            {
 474                Logger.LogDebug(
 475                    "Azure Service Bus background worker {WorkerIndex} handling message {MessageId} for {Queue}. Pending
 476                    workerIndex,
 477                    delivery.MessageId,
 478                    _queueName,
 479                    PendingCount,
 480                    RunningCount);
 481                await ExecuteHandlerAsync(
 482                    delivery,
 483                    _drainCancellation.Token,
 484                    logFailures: false).ConfigureAwait(false);
 485            }
 486            catch (Exception ex)
 487            {
 488                Logger.LogError(
 489                    ex,
 490                    "Azure Service Bus background handler failed for already-completed message {MessageId} on {Queue}.",
 491                    delivery.MessageId,
 492                    _queueName);
 493                await NotifyBackgroundFailureAsync(
 494                    delivery,
 495                    ex,
 496                    _queueName,
 497                    _role).ConfigureAwait(false);
 498            }
 499            finally
 500            {
 501                Interlocked.Decrement(ref _runningCount);
 502            }
 503        }
 504    }
 505}