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

Information
Class: AsyncResponse.Transports.SQS.SqsResponseIngressSubscriber
Assembly: AsyncResponse.Transports.SQS
File(s): /_/src/Transports/AsyncResponse.Transports.SQS/SqsSubscriberServices.cs
Line coverage
100%
Covered lines: 10
Uncovered lines: 0
Coverable lines: 10
Total lines: 503
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.SQS/SqsSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4
 5namespace AsyncResponse.Transports.SQS;
 6
 7internal abstract class SqsSubscriberService : BackgroundService
 8{
 9    private readonly ISqsClient _client;
 10
 11    protected SqsSubscriberService(
 12        IOptions<SqsAsyncResponseOptions> options,
 13        ISqsClient client,
 14        ILogger logger)
 15    {
 16        Options = options.Value;
 17        SqsOptionsValidator.ValidateCommon(Options);
 18        _client = client;
 19        Logger = logger;
 20    }
 21
 22    protected SqsAsyncResponseOptions Options { get; }
 23    protected ILogger Logger { get; }
 24
 25    /// <summary>Measures how long a delivery has been in flight; replaced by tests to reach the 12-hour ceiling.</summa
 26    internal TimeProvider Clock { get; set; } = TimeProvider.System;
 27
 28    protected abstract string QueueName { get; }
 29    protected abstract SqsSubscriberOptions SubscriberOptions { get; }
 30    protected abstract SqsSubscriberRole SubscriberRole { get; }
 31    /// <summary>Handles the delivered message.</summary>
 32    protected abstract Task HandleMessageAsync(SqsTransportDelivery delivery, CancellationToken cancellationToken);
 33
 34    /// <summary>Runs this background operation until cancellation is requested.</summary>
 35    /// <summary>
 36    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 37    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 38    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 39    /// background-exception handling — or never, when a fast stop discards the queued work —
 40    /// instead of failing host startup synchronously.
 41    /// </summary>
 42    public override Task StartAsync(CancellationToken cancellationToken)
 43    {
 44        _ = QueueName; // Resolving the name enforces its Required check at startup too.
 45        SqsMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 46        return base.StartAsync(cancellationToken);
 47    }
 48
 49    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 50    {
 51        var queue = QueueName;
 52
 53        // The dispatcher outlives the supervised attempts below. In ACK-after-enqueue mode it
 54        // holds work that was already DELETED at the broker, and disposing it runs the stop-time
 55        // drain — so scoped to one attempt, any receive fault (throttling, a network blip) on a
 56        // host that is NOT stopping paused consumption for the drain budget and then surfaced
 57        // the still-queued work as lapsed, work SQS can never redeliver. It captures nothing
 58        // per-attempt (deliveries carry their own settlement), so only host stop drains it.
 59        await using var dispatcher = SqsMessageDispatcher.Create(
 60            HandleMessageAsync,
 61            Options,
 62            SubscriberOptions,
 63            Logger,
 64            queue,
 65            SubscriberRole);
 66
 67        await SubscriberSupervisor.RunAsync(
 68            ct => RunSubscriberAsync(queue, dispatcher, ct),
 69            stoppingToken,
 70            failures => AsyncResponseRetry.Backoff(
 71                failures,
 72                Options.SubscriberRetryBaseDelay,
 73                Options.SubscriberRetryMaxDelay),
 74            (ex, retryDelay) => Logger.LogWarning(
 75                ex,
 76                "SQS subscriber failed for queue {Queue} ({Role}); retrying in {RetryDelay}.",
 77                queue,
 78                SubscriberRole,
 79                retryDelay)).ConfigureAwait(false);
 80    }
 81
 82    private async Task RunSubscriberAsync(string queue, SqsMessageDispatcher dispatcher, CancellationToken stoppingToken
 83    {
 84        // A queue configured by name resolves through GetQueueUrl; failures here (queue not yet
 85        // provisioned, endpoint still starting) surface to the retry loop above.
 86        var queueUrl = SqsQueueAddress.IsUrl(queue)
 87            ? queue
 88            : await _client.GetQueueUrlAsync(queue, stoppingToken).ConfigureAwait(false);
 89
 90        Logger.LogInformation(
 91            "SQS subscriber started. Queue: {Queue}. Role: {Role}. AckMode: {AckMode}.",
 92            queue,
 93            SubscriberRole,
 94            SubscriberOptions.AckMode);
 95
 96        while (!stoppingToken.IsCancellationRequested)
 97        {
 98            // In early-ACK mode, receiving while the background queue is saturated would burn the
 99            // queue's redrive policy (SQS counts every receive), so wait for free capacity and never
 100            // request more messages than the dispatcher can accept.
 101            await dispatcher.WaitForCapacityAsync(stoppingToken).ConfigureAwait(false);
 102            var maxMessages = Math.Min(Options.MaxMessagesPerReceive, dispatcher.FreeCapacity);
 103
 104            // Stamped BEFORE the call: the 12-hour in-flight ceiling counts from the broker-side
 105            // receive, which happens somewhere inside the long poll, so measuring from here can
 106            // only over-estimate a delivery's age — the safe direction for the renewal clamp.
 107            var receiveStarted = Clock.GetTimestamp();
 108            var deliveries = await _client.ReceiveMessagesAsync(
 109                new SqsReceiveRequest(
 110                    queueUrl,
 111                    maxMessages,
 112                    Options.ReceiveWaitTime,
 113                    SubscriberOptions.VisibilityTimeout),
 114                stoppingToken).ConfigureAwait(false);
 115
 116            await DispatchBatchAsync(dispatcher, deliveries, queue, receiveStarted, stoppingToken).ConfigureAwait(false)
 117        }
 118    }
 119
 120    private async Task DispatchBatchAsync(
 121        SqsMessageDispatcher dispatcher,
 122        IReadOnlyList<SqsTransportDelivery> deliveries,
 123        string queue,
 124        long receiveStarted,
 125        CancellationToken stoppingToken)
 126    {
 127        if (deliveries.Count == 0)
 128            return;
 129
 130        if (SubscriberOptions.AckMode is not SqsAckMode.AckAfterHandlerCompletes
 131            || SubscriberOptions.VisibilityRenewalInterval is not { } renewalInterval
 132            || SubscriberOptions.VisibilityTimeout is not { } visibilityTimeout)
 133        {
 134            for (var index = 0; index < deliveries.Count; index++)
 135            {
 136                // The handler takes no token, so a stop cannot interrupt the message in hand —
 137                // but it must not START the rest of the batch: every fresh handler runs against
 138                // the host's shutdown budget and is killed mid-flight when that lapses.
 139                if (stoppingToken.IsCancellationRequested)
 140                {
 141                    await HandBackUnstartedAsync(deliveries, index, progress: null, queue).ConfigureAwait(false);
 142                    return;
 143                }
 144
 145                await dispatcher.HandleAsync(deliveries[index], stoppingToken).ConfigureAwait(false);
 146            }
 147
 148            return;
 149        }
 150
 151        // The batch is processed serially, so a slow handler lets the visibility timeout of the later
 152        // (still unprocessed) messages lapse and a competing consumer processes them a second time.
 153        // While the batch is in flight, a heartbeat resets every unsettled message's invisibility to
 154        // the configured visibility timeout.
 155        var progress = new BatchProgress(deliveries.Count);
 156        // NOT linked to the stop token: the handler in flight when the host stops keeps running
 157        // (it takes no token), and ending its heartbeat at that moment let its visibility lapse
 158        // under a live handler — a competing consumer then ran the same job a second time on
 159        // every rolling deploy. The heartbeat ends when the batch loop does.
 160        using var renewalCancellation = new CancellationTokenSource();
 161        var renewalTask = RenewVisibilityLoopAsync(
 162            deliveries,
 163            progress,
 164            renewalInterval,
 165            visibilityTimeout,
 166            queue,
 167            receiveStarted,
 168            renewalCancellation.Token);
 169        var handBack = Task.CompletedTask;
 170        try
 171        {
 172            for (var index = 0; index < deliveries.Count; index++)
 173            {
 174                // Same stop rule as the renewal-free path above. Without it the loop kept
 175                // starting the rest of the batch serially after the stop — with the heartbeat
 176                // already cancelled, so those handlers outlived their visibility.
 177                if (stoppingToken.IsCancellationRequested)
 178                {
 179                    handBack = HandBackUnstartedAsync(deliveries, index, progress, queue);
 180                    break;
 181                }
 182
 183                var delivery = deliveries[index];
 184                var batchIndex = index;
 185                // The dispatcher's failure path shortens visibility to RedeliveryDelay while the
 186                // heartbeat still counts the message as unsettled (MarkSettled runs only after
 187                // HandleAsync returns). Routing the dispatcher's visibility changes through a
 188                // suppression mark blocks future renewals; the per-message gate joins any renewal
 189                // already in flight before applying the shorter retry delay.
 190                var tracked = delivery with
 191                {
 192                    ChangeVisibilityAsync = async (timeout, token) =>
 193                    {
 194                        progress.SuppressRenewal(batchIndex);
 195                        // A renewal may already be in flight. Its reply must settle before the
 196                        // retry delay is applied, otherwise it can overwrite that shorter delay.
 197                        var gate = progress.VisibilityGate(batchIndex);
 198                        if (!await gate.WaitAsync(Options.ShutdownTimeout, stoppingToken).ConfigureAwait(false))
 199                            throw new TimeoutException("SQS visibility renewal did not settle before the retry-delay upd
 200                        try
 201                        {
 202                            await delivery.ChangeVisibilityAsync(timeout, token).ConfigureAwait(false);
 203                        }
 204                        finally
 205                        {
 206                            gate.Release();
 207                        }
 208                    }
 209                };
 210                try
 211                {
 212                    await dispatcher.HandleAsync(tracked, stoppingToken).ConfigureAwait(false);
 213                }
 214                finally
 215                {
 216                    progress.MarkSettled();
 217                }
 218            }
 219        }
 220        finally
 221        {
 222            renewalCancellation.Cancel();
 223            try
 224            {
 225                // Cancellation exits the sweep between messages and reaches the in-flight
 226                // ChangeVisibility call through its token, so this join normally completes at
 227                // once. The bound covers the one case the token cannot end promptly — an SDK call
 228                // mid-retry against a degraded endpoint — which would otherwise stall the receive
 229                // loop (and, at shutdown, the host's stop budget) for the SDK retry budget.
 230                await renewalTask.WaitAsync(Options.ShutdownTimeout).ConfigureAwait(false);
 231            }
 232            catch (TimeoutException)
 233            {
 234                Logger.LogWarning(
 235                    "SQS visibility renewal for {Queue} ({Role}) did not stop within the shutdown budget ({ShutdownTimeo
 236                    queue,
 237                    SubscriberRole,
 238                    Options.ShutdownTimeout);
 239            }
 240
 241            // Started before the join above and bounded by the same ShutdownTimeout, so the two
 242            // overlap: the stop path still spends one ShutdownTimeout here, not two.
 243            await handBack.ConfigureAwait(false);
 244        }
 245    }
 246
 247    /// <summary>
 248    /// Makes the batch messages that were never started visible again at once. Left alone they
 249    /// stay invisible for the rest of their visibility timeout although nothing is processing
 250    /// them, stalling that work for as long on every rolling deploy; the receive already counted
 251    /// toward the redrive policy either way. Best-effort and bounded by
 252    /// <see cref="SqsAsyncResponseOptions.ShutdownTimeout"/>: a message that cannot be handed back
 253    /// simply reappears when its visibility timeout lapses.
 254    /// </summary>
 255    private async Task HandBackUnstartedAsync(
 256        IReadOnlyList<SqsTransportDelivery> deliveries,
 257        int firstUnstarted,
 258        BatchProgress? progress,
 259        string queue)
 260    {
 261        var budget = new CancellationTokenSource(Options.ShutdownTimeout);
 262        var releases = new Task[deliveries.Count - firstUnstarted];
 263        for (var index = firstUnstarted; index < deliveries.Count; index++)
 264            releases[index - firstUnstarted] = ReleaseAsync(index);
 265
 266        try
 267        {
 268            await Task.WhenAll(releases).WaitAsync(Options.ShutdownTimeout).ConfigureAwait(false);
 269            budget.Dispose();
 270        }
 271        catch (TimeoutException)
 272        {
 273            // An SDK call mid-retry ignored the budget token. The source stays undisposed: the
 274            // abandoned calls still hold its token.
 275            Logger.LogWarning(
 276                "Handing unstarted SQS messages back to {Queue} ({Role}) did not finish within the shutdown budget ({Shu
 277                queue,
 278                SubscriberRole,
 279                Options.ShutdownTimeout);
 280        }
 281
 282        async Task ReleaseAsync(int index)
 283        {
 284            var delivery = deliveries[index];
 285            SemaphoreSlim? gate = null;
 286            try
 287            {
 288                if (progress is not null)
 289                {
 290                    // Same ordering rule as the retry delay: a renewal already in flight for this
 291                    // receipt must settle first, or its reply overwrites the release.
 292                    progress.SuppressRenewal(index);
 293                    await progress.VisibilityGate(index).WaitAsync(budget.Token).ConfigureAwait(false);
 294                    gate = progress.VisibilityGate(index);
 295                }
 296
 297                await delivery.ChangeVisibilityAsync(TimeSpan.Zero, budget.Token).ConfigureAwait(false);
 298            }
 299            catch (Exception ex)
 300            {
 301                Logger.LogWarning(
 302                    ex,
 303                    "Failed to hand unstarted SQS message {MessageId} back to {Queue} while stopping; it reappears when 
 304                    delivery.MessageId,
 305                    queue);
 306            }
 307            finally
 308            {
 309                gate?.Release();
 310            }
 311        }
 312    }
 313
 314    /// <summary>
 315    /// The visibility a renewal may still request for a delivery received
 316    /// <paramref name="inFlight"/> ago, or <c>null</c> once nothing is left. SQS never keeps a
 317    /// message invisible for more than 12 hours from its receive and REJECTS — it does not
 318    /// truncate — a <c>ChangeMessageVisibility</c> that would cross that, so an unclamped renewal
 319    /// fails on every beat from <c>12 h − VisibilityTimeout</c> onward and forfeits the tail of
 320    /// the ceiling. Rounded down to whole seconds because the adapter rounds requests up.
 321    /// </summary>
 322    internal static TimeSpan? ClampRenewalToInFlightCeiling(TimeSpan visibilityTimeout, TimeSpan inFlight)
 323    {
 324        var remaining = TimeSpan.FromSeconds(Math.Floor((SqsWorkerTransport.SqsMaxInFlightDuration - inFlight).TotalSeco
 325        if (remaining <= TimeSpan.Zero)
 326            return null;
 327
 328        return visibilityTimeout < remaining ? visibilityTimeout : remaining;
 329    }
 330
 331    private async Task RenewVisibilityLoopAsync(
 332        IReadOnlyList<SqsTransportDelivery> deliveries,
 333        BatchProgress progress,
 334        TimeSpan renewalInterval,
 335        TimeSpan visibilityTimeout,
 336        string queue,
 337        long receiveStarted,
 338        CancellationToken cancellationToken)
 339    {
 340        try
 341        {
 342            while (true)
 343            {
 344                await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false);
 345
 346                // Renew from the first unsettled message onward: that covers the message currently in
 347                // the handler plus everything still waiting its turn. Two settle paths race this
 348                // sweep, and only one of them is harmless. A handled message was deleted, so a late
 349                // renewal merely fails and is logged — SQS redelivery keeps at-least-once intact. A
 350                // failed message was NOT deleted (its receipt handle stays live) and already carries
 351                // the failure path's shortened RedeliveryDelay, so a late renewal here would SUCCEED
 352                // and stretch that fast retry back out to the full visibility timeout — the
 353                // suppression mark and the per-message re-read of the settled prefix keep the sweep
 354                // away from it.
 355                for (var i = progress.SettledCount; i < deliveries.Count; i++)
 356                {
 357                    // The batch finished or the subscriber is stopping: exit quietly between
 358                    // messages instead of spending up to a full SDK retry budget on each remaining
 359                    // renew (ASB-twin parity).
 360                    if (cancellationToken.IsCancellationRequested)
 361                        return;
 362
 363                    if (i < progress.SettledCount || progress.IsRenewalSuppressed(i))
 364                        continue;
 365
 366                    var delivery = deliveries[i];
 367                    try
 368                    {
 369                        var gate = progress.VisibilityGate(i);
 370                        await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
 371                        try
 372                        {
 373                            if (i < progress.SettledCount || progress.IsRenewalSuppressed(i))
 374                                continue;
 375
 376                            var extension = ClampRenewalToInFlightCeiling(visibilityTimeout, Clock.GetElapsedTime(receiv
 377                            if (extension is { } clamped)
 378                                await delivery.ChangeVisibilityAsync(clamped, cancellationToken).ConfigureAwait(false);
 379
 380                            if (extension != visibilityTimeout)
 381                            {
 382                                // The ceiling, not a renewal fault: nothing can extend this
 383                                // delivery any further, so say so once and stop asking instead of
 384                                // logging a rejected renewal on every remaining beat.
 385                                progress.SuppressRenewal(i);
 386                                Logger.LogWarning(
 387                                    "SQS message {MessageId} on {Queue} has reached the 12-hour SQS in-flight ceiling; i
 388                                    delivery.MessageId,
 389                                    queue);
 390                            }
 391                        }
 392                        finally
 393                        {
 394                            gate.Release();
 395                        }
 396                    }
 397                    catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellation
 398                    {
 399                        // Only OUR token ends the sweep. The AWS SDK surfaces its own client-side
 400                        // HTTP timeout as TaskCanceledException with the caller's token untouched,
 401                        // and excluding every OperationCanceledException let that escape to the
 402                        // outer catch — whose body is just a comment — silently ending renewal for
 403                        // the whole remaining batch. Messages 3..N then went visible mid-processing
 404                        // and a peer re-ran them: systematic duplicate execution with no log line.
 405                        // Same idiom the durable-flow start ladder and the DB channel already use.
 406                        Logger.LogWarning(
 407                            ex,
 408                            "Failed to renew visibility of SQS message {MessageId} on {Queue}; it may redeliver while st
 409                            delivery.MessageId,
 410                            queue);
 411                    }
 412                }
 413            }
 414        }
 415        catch (OperationCanceledException)
 416        {
 417            // The batch finished or the subscriber is stopping.
 418        }
 419    }
 420
 421    private sealed class BatchProgress
 422    {
 423        // Gates are per message: a stuck renewal for one receipt cannot block another receipt's
 424        // retry. Do not dispose gates while an abandoned SDK call may still release one.
 425        private readonly bool[] _renewalSuppressed;
 426        private readonly SemaphoreSlim[] _visibilityGates;
 427        private int _settledCount;
 428
 429        public BatchProgress(int batchSize)
 430        {
 431            _renewalSuppressed = new bool[batchSize];
 432            _visibilityGates = Enumerable.Range(0, batchSize).Select(_ => new SemaphoreSlim(1, 1)).ToArray();
 433        }
 434
 435        public SemaphoreSlim VisibilityGate(int index) => _visibilityGates[index];
 436
 437        public int SettledCount => Volatile.Read(ref _settledCount);
 438
 439        public void MarkSettled() => Interlocked.Increment(ref _settledCount);
 440
 441        /// <summary>Marks the message at <paramref name="index"/> as owning its own visibility; the renewal sweep must 
 442        public void SuppressRenewal(int index) => Volatile.Write(ref _renewalSuppressed[index], true);
 443
 444        public bool IsRenewalSuppressed(int index) => Volatile.Read(ref _renewalSuppressed[index]);
 445    }
 446}
 447
 448internal sealed class SqsWorkerSubscriber : SqsSubscriberService
 449{
 450    private readonly IAsyncResponseIngress _ingress;
 451
 452    /// <summary>Creates a worker subscriber for the configured SQS worker queue.</summary>
 453    public SqsWorkerSubscriber(
 454        IOptions<SqsAsyncResponseOptions> options,
 455        ISqsClient client,
 456        IAsyncResponseIngress ingress,
 457        ILogger<SqsWorkerSubscriber> logger)
 458        : base(options, client, logger)
 459    {
 460        _ingress = ingress;
 461    }
 462
 463    protected override string QueueName
 464        => SqsOptionsValidator.Required(Options.WorkerQueue, nameof(Options.WorkerQueue));
 465
 466    protected override SqsSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 467    protected override SqsSubscriberRole SubscriberRole => SqsSubscriberRole.Worker;
 468
 469    /// <summary>Handles the delivered message.</summary>
 470    protected override Task HandleMessageAsync(SqsTransportDelivery delivery, CancellationToken cancellationToken)
 471        => _ingress.HandleWorkerMessageAsync(delivery.Body);
 472}
 473
 474internal sealed class SqsResponseIngressSubscriber : SqsSubscriberService
 475{
 476    private readonly IAsyncResponseIngress _ingress;
 477
 478    /// <summary>Creates a response subscriber for the configured SQS response queue.</summary>
 479    public SqsResponseIngressSubscriber(
 480        IOptions<SqsAsyncResponseOptions> options,
 481        ISqsClient client,
 482        IAsyncResponseIngress ingress,
 483        ILogger<SqsResponseIngressSubscriber> logger)
 198484        : base(options, client, logger)
 485    {
 198486        _ingress = ingress;
 198487    }
 488
 489    protected override string QueueName
 388490        => SqsOptionsValidator.Required(Options.ResponseQueue, nameof(Options.ResponseQueue));
 491
 789492    protected override SqsSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 582493    protected override SqsSubscriberRole SubscriberRole => SqsSubscriberRole.ResponseIngress;
 494
 495    /// <summary>Handles the delivered message.</summary>
 496    protected override Task HandleMessageAsync(SqsTransportDelivery delivery, CancellationToken cancellationToken)
 497    {
 2498        var correlationId = !_ingress.IsOverInboundBudget(delivery.Body)
 2499            ? SqsCorrelationIdExtractor.Extract(delivery, delivery.Body, Options)
 2500            : null;
 2501        return _ingress.HandleResponseMessageAsync(delivery.Body, correlationId);
 502    }
 503}