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

Information
Class: AsyncResponse.Transports.AzureServiceBus.AzureServiceBusResponseIngressSubscriber
Assembly: AsyncResponse.Transports.AzureServiceBus
File(s): /_/src/Transports/AsyncResponse.Transports.AzureServiceBus/AzureServiceBusSubscriberServices.cs
Line coverage
100%
Covered lines: 10
Uncovered lines: 0
Coverable lines: 10
Total lines: 278
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%
get_QueueName()100%11100%
get_SubscriberOptions()100%11100%
get_SubscriberRole()100%11100%
HandleMessageAsync(...)50%22100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4
 5namespace AsyncResponse.Transports.AzureServiceBus;
 6
 7internal abstract class AzureServiceBusSubscriberService : BackgroundService
 8{
 9    private readonly IAzureServiceBusClient _client;
 10
 11    protected AzureServiceBusSubscriberService(
 12        IOptions<AzureServiceBusAsyncResponseOptions> options,
 13        IAzureServiceBusClient client,
 14        ILogger logger)
 15    {
 16        Options = options.Value;
 17        AzureServiceBusOptionsValidator.ValidateCommon(Options);
 18        _client = client;
 19        Logger = logger;
 20    }
 21
 22    protected AzureServiceBusAsyncResponseOptions Options { get; }
 23    protected ILogger Logger { get; }
 24
 25    protected abstract string QueueName { get; }
 26    protected abstract AzureServiceBusSubscriberOptions SubscriberOptions { get; }
 27    protected abstract AzureServiceBusSubscriberRole SubscriberRole { get; }
 28    /// <summary>Handles the delivered message.</summary>
 29    protected abstract Task HandleMessageAsync(AzureServiceBusTransportDelivery delivery, CancellationToken cancellation
 30
 31    /// <summary>Runs this background operation until cancellation is requested.</summary>
 32    /// <summary>
 33    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 34    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 35    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 36    /// background-exception handling — or never, when a fast stop discards the queued work —
 37    /// instead of failing host startup synchronously.
 38    /// </summary>
 39    public override Task StartAsync(CancellationToken cancellationToken)
 40    {
 41        _ = QueueName; // Resolving the name enforces its Required check at startup too.
 42        AzureServiceBusMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 43        return base.StartAsync(cancellationToken);
 44    }
 45
 46    protected override Task ExecuteAsync(CancellationToken stoppingToken)
 47    {
 48        var queue = QueueName;
 49        return SubscriberSupervisor.RunAsync(
 50            ct => RunSubscriberAsync(queue, ct),
 51            stoppingToken,
 52            failures => AsyncResponseRetry.Backoff(failures, Options.SubscriberRetryBaseDelay, Options.SubscriberRetryMa
 53            (ex, retryDelay) => Logger.LogWarning(
 54                ex,
 55                "Azure Service Bus subscriber failed for queue {Queue} ({Role}); retrying in {RetryDelay}.",
 56                queue,
 57                SubscriberRole,
 58                retryDelay));
 59    }
 60
 61    private async Task RunSubscriberAsync(string queue, CancellationToken stoppingToken)
 62    {
 63        await using var receiver = _client.CreateReceiver(queue, SubscriberOptions);
 64        await using var dispatcher = AzureServiceBusMessageDispatcher.Create(
 65            HandleMessageAsync,
 66            Options,
 67            SubscriberOptions,
 68            Logger,
 69            queue,
 70            SubscriberRole);
 71
 72        Logger.LogInformation(
 73            "Azure Service Bus subscriber started. Queue: {Queue}. Role: {Role}. AckMode: {AckMode}.",
 74            queue,
 75            SubscriberRole,
 76            SubscriberOptions.AckMode);
 77
 78        try
 79        {
 80            while (!stoppingToken.IsCancellationRequested)
 81            {
 82                // In early-ACK mode, receiving while the background queue is saturated would burn
 83                // DeliveryCount via queue-full abandons, so wait for free capacity and never request
 84                // more messages than the dispatcher can accept.
 85                await dispatcher.WaitForCapacityAsync(stoppingToken).ConfigureAwait(false);
 86                var maxMessages = Math.Min(Options.MaxMessagesPerReceive, dispatcher.FreeCapacity);
 87
 88                var messages = await receiver.ReceiveMessagesAsync(
 89                    maxMessages,
 90                    Options.ReceiveWaitTime,
 91                    stoppingToken).ConfigureAwait(false);
 92
 93                await DispatchBatchAsync(dispatcher, messages, queue, stoppingToken).ConfigureAwait(false);
 94            }
 95        }
 96        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 97        {
 98            using var shutdown = new CancellationTokenSource(Options.ShutdownTimeout);
 99            await receiver.CloseAsync(shutdown.Token).ConfigureAwait(false);
 100        }
 101    }
 102
 103    private async Task DispatchBatchAsync(
 104        AzureServiceBusMessageDispatcher dispatcher,
 105        IReadOnlyList<AzureServiceBusTransportDelivery> messages,
 106        string queue,
 107        CancellationToken stoppingToken)
 108    {
 109        if (messages.Count == 0)
 110            return;
 111
 112        if (SubscriberOptions.AckMode is not AzureServiceBusAckMode.AckAfterHandlerCompletes
 113            || SubscriberOptions.LockRenewalInterval is not { } renewalInterval)
 114        {
 115            foreach (var message in messages)
 116                await dispatcher.HandleAsync(message, stoppingToken).ConfigureAwait(false);
 117            return;
 118        }
 119
 120        // The batch is processed serially, so a slow handler lets the peek locks of the later (still
 121        // unsettled) messages expire and Service Bus redelivers them to a competing consumer while
 122        // they are still queued here — systematic duplicate processing. While the batch is in flight,
 123        // a background loop renews the lock of every unsettled message each interval.
 124        var progress = new BatchProgress();
 125        using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
 126        var renewalTask = RenewLocksLoopAsync(messages, progress, renewalInterval, queue, renewalCancellation.Token);
 127        try
 128        {
 129            foreach (var message in messages)
 130            {
 131                try
 132                {
 133                    await dispatcher.HandleAsync(message, stoppingToken).ConfigureAwait(false);
 134                }
 135                finally
 136                {
 137                    progress.MarkSettled();
 138                }
 139            }
 140        }
 141        finally
 142        {
 143            renewalCancellation.Cancel();
 144            try
 145            {
 146                // Cancellation exits the sweep between messages and interrupts the in-flight renew
 147                // call, so this normally completes immediately. The bound is the hard backstop: an
 148                // unbounded await here would let a degraded namespace hold the receive loop (and, at
 149                // shutdown, the whole host budget) hostage for the SDK retry budget per message.
 150                await renewalTask.WaitAsync(Options.ShutdownTimeout).ConfigureAwait(false);
 151            }
 152            catch (TimeoutException)
 153            {
 154                Logger.LogWarning(
 155                    "Azure Service Bus lock renewal for {Queue} ({Role}) did not stop within the shutdown budget ({Shutd
 156                    queue,
 157                    SubscriberRole,
 158                    Options.ShutdownTimeout);
 159            }
 160        }
 161    }
 162
 163    private async Task RenewLocksLoopAsync(
 164        IReadOnlyList<AzureServiceBusTransportDelivery> messages,
 165        BatchProgress progress,
 166        TimeSpan renewalInterval,
 167        string queue,
 168        CancellationToken cancellationToken)
 169    {
 170        try
 171        {
 172            while (true)
 173            {
 174                await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false);
 175
 176                // Renew from the first unsettled message onward: that covers the message currently in
 177                // the handler plus everything still waiting its turn. A renewal racing a just-settled
 178                // message merely fails and is logged; redelivery keeps at-least-once intact.
 179                for (var i = progress.SettledCount; i < messages.Count; i++)
 180                {
 181                    // The batch finished or the subscriber is stopping: exit quietly between messages
 182                    // instead of spending up to a full SDK retry budget on each remaining renew.
 183                    if (cancellationToken.IsCancellationRequested)
 184                        return;
 185
 186                    var message = messages[i];
 187                    try
 188                    {
 189                        await message.RenewLockAsync(cancellationToken).ConfigureAwait(false);
 190                    }
 191                    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 192                    {
 193                        // Our token interrupted the in-flight renew; not a renewal failure.
 194                        return;
 195                    }
 196                    catch (Exception ex)
 197                    {
 198                        Logger.LogWarning(
 199                            ex,
 200                            "Failed to renew the lock of Azure Service Bus message {MessageId} on {Queue}; it may redeli
 201                            message.MessageId,
 202                            queue);
 203                    }
 204                }
 205            }
 206        }
 207        catch (OperationCanceledException)
 208        {
 209            // The batch finished or the subscriber is stopping.
 210        }
 211    }
 212
 213    private sealed class BatchProgress
 214    {
 215        private int _settledCount;
 216
 217        public int SettledCount => Volatile.Read(ref _settledCount);
 218
 219        public void MarkSettled() => Interlocked.Increment(ref _settledCount);
 220    }
 221}
 222
 223internal sealed class AzureServiceBusWorkerSubscriber : AzureServiceBusSubscriberService
 224{
 225    private readonly IAsyncResponseIngress _ingress;
 226
 227    /// <summary>Creates a worker subscriber for the configured Service Bus worker queue.</summary>
 228    public AzureServiceBusWorkerSubscriber(
 229        IOptions<AzureServiceBusAsyncResponseOptions> options,
 230        IAzureServiceBusClient client,
 231        IAsyncResponseIngress ingress,
 232        ILogger<AzureServiceBusWorkerSubscriber> logger)
 233        : base(options, client, logger)
 234    {
 235        _ingress = ingress;
 236    }
 237
 238    protected override string QueueName
 239        => AzureServiceBusOptionsValidator.Required(Options.WorkerQueue, nameof(Options.WorkerQueue));
 240
 241    protected override AzureServiceBusSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 242    protected override AzureServiceBusSubscriberRole SubscriberRole => AzureServiceBusSubscriberRole.Worker;
 243
 244    /// <summary>Handles the delivered message.</summary>
 245    protected override Task HandleMessageAsync(AzureServiceBusTransportDelivery delivery, CancellationToken cancellation
 246        => _ingress.HandleWorkerMessageAsync(delivery.Body);
 247}
 248
 249internal sealed class AzureServiceBusResponseIngressSubscriber : AzureServiceBusSubscriberService
 250{
 251    private readonly IAsyncResponseIngress _ingress;
 252
 253    /// <summary>Creates a response subscriber for the configured Service Bus response queue.</summary>
 254    public AzureServiceBusResponseIngressSubscriber(
 255        IOptions<AzureServiceBusAsyncResponseOptions> options,
 256        IAzureServiceBusClient client,
 257        IAsyncResponseIngress ingress,
 258        ILogger<AzureServiceBusResponseIngressSubscriber> logger)
 198259        : base(options, client, logger)
 260    {
 198261        _ingress = ingress;
 198262    }
 263
 264    protected override string QueueName
 388265        => AzureServiceBusOptionsValidator.Required(Options.ResponseQueue, nameof(Options.ResponseQueue));
 266
 780267    protected override AzureServiceBusSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 582268    protected override AzureServiceBusSubscriberRole SubscriberRole => AzureServiceBusSubscriberRole.ResponseIngress;
 269
 270    /// <summary>Handles the delivered message.</summary>
 271    protected override Task HandleMessageAsync(AzureServiceBusTransportDelivery delivery, CancellationToken cancellation
 272    {
 2273        var correlationId = !_ingress.IsOverInboundBudget(delivery.Body)
 2274            ? AzureServiceBusCorrelationIdExtractor.Extract(delivery, delivery.Body, Options)
 2275            : null;
 2276        return _ingress.HandleResponseMessageAsync(delivery.Body, correlationId);
 277    }
 278}