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

Information
Class: AsyncResponse.Transports.SQS.SqsQueueProvisioningService<T>
Assembly: AsyncResponse.Transports.SQS
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.SQS/SqsQueueProvisioningService.cs
Line coverage
98%
Covered lines: 57
Uncovered lines: 1
Coverable lines: 58
Total lines: 130
Line coverage: 98.2%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
StartAsync()100%66100%
StopAsync(...)100%11100%
EnsureQueueWithDeadLetterAsync()100%44100%
CreateOrUpdateQueueAsync()100%11100%
CreateAsync()100%2285.71%
RetryAsync()50%22100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.SQS/SqsQueueProvisioningService.cs

#LineLine coverage
 1using Amazon.SQS;
 2using Amazon.SQS.Model;
 3using Microsoft.Extensions.Hosting;
 4using Microsoft.Extensions.Logging;
 5using Microsoft.Extensions.Options;
 6using System.Text.Json;
 7
 8namespace AsyncResponse.Transports.SQS;
 9
 10/// <summary>
 11/// Provisions the worker and response queues (each with a dead-letter queue wired through a native
 12/// redrive policy) when <see cref="SqsAsyncResponseOptions.CreateQueues"/> is enabled. Registered
 13/// before the subscribers so provisioning completes before consumption starts; queues configured as
 14/// URLs are assumed to exist and are skipped.
 15/// </summary>
 316internal sealed class SqsQueueProvisioningService(
 317    IOptions<SqsAsyncResponseOptions> options,
 318    ISqsClient client,
 319    ILogger<SqsQueueProvisioningService> logger) : IHostedService
 20{
 21    private const int MaxAttempts = 40;
 22
 23    /// <summary>Creates the configured queues and their dead-letter pairs.</summary>
 24    public async Task StartAsync(CancellationToken cancellationToken)
 25    {
 326        var o = options.Value;
 327        if (!o.CreateQueues)
 228            return;
 29
 330        SqsOptionsValidator.ValidateCommon(o);
 31
 332        foreach (var queue in new[] { o.WorkerQueue, o.ResponseQueue })
 33        {
 334            if (SqsQueueAddress.IsUrl(queue))
 35            {
 236                logger.LogInformation("SQS queue {Queue} is configured as a URL; skipping provisioning.", queue);
 237                continue;
 38            }
 39
 340            await EnsureQueueWithDeadLetterAsync(o, queue, cancellationToken).ConfigureAwait(false);
 41        }
 342    }
 43
 44    /// <summary>No-op.</summary>
 345    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 46
 47    internal async Task EnsureQueueWithDeadLetterAsync(
 48        SqsAsyncResponseOptions o,
 49        string queueName,
 50        CancellationToken cancellationToken)
 51    {
 352        var fifo = SqsQueueAddress.IsFifo(queueName);
 53        // A FIFO queue's dead-letter queue must itself be FIFO, and FIFO names must end in ".fifo".
 354        var deadLetterQueueName = fifo
 355            ? $"{queueName[..^".fifo".Length]}{o.DeadLetterQueueSuffix}.fifo"
 356            : $"{queueName}{o.DeadLetterQueueSuffix}";
 57
 358        var fifoAttributes = fifo
 359            ? new Dictionary<string, string>(StringComparer.Ordinal) { [QueueAttributeName.FifoQueue] = "true" }
 360            : new Dictionary<string, string>(StringComparer.Ordinal);
 61
 362        var deadLetterQueueUrl = await CreateOrUpdateQueueAsync(deadLetterQueueName, fifoAttributes, cancellationToken)
 363            .ConfigureAwait(false);
 364        var deadLetterQueueArn = await RetryAsync(
 365            () => client.GetQueueArnAsync(deadLetterQueueUrl, cancellationToken),
 366            cancellationToken).ConfigureAwait(false);
 67
 368        var attributes = new Dictionary<string, string>(fifoAttributes, StringComparer.Ordinal)
 369        {
 370            [QueueAttributeName.RedrivePolicy] = AsyncResponseJson.Serialize(new Dictionary<string, string>
 371            {
 372                ["deadLetterTargetArn"] = deadLetterQueueArn,
 373                ["maxReceiveCount"] = o.MaxReceiveCount.ToString()
 374            })
 375        };
 76
 377        await CreateOrUpdateQueueAsync(queueName, attributes, cancellationToken).ConfigureAwait(false);
 78
 379        logger.LogInformation(
 380            "SQS queue {Queue} provisioned with dead-letter queue {DeadLetterQueue} (maxReceiveCount={MaxReceiveCount}).
 381            queueName,
 382            deadLetterQueueName,
 383            o.MaxReceiveCount);
 384    }
 85
 86    private async Task<string> CreateOrUpdateQueueAsync(
 87        string queueName,
 88        IReadOnlyDictionary<string, string> attributes,
 89        CancellationToken cancellationToken)
 90    {
 391        return await RetryAsync(CreateAsync, cancellationToken).ConfigureAwait(false);
 92
 93        async Task<string> CreateAsync()
 94        {
 95            try
 96            {
 397                return await client.CreateQueueAsync(queueName, attributes, cancellationToken).ConfigureAwait(false);
 98            }
 99            catch (QueueNameExistsException)
 100            {
 101                // The queue exists with different attributes (for example a redrive policy set by an
 102                // earlier run against a different DLQ ARN): converge by re-applying the attributes.
 2103                var queueUrl = await client.GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false);
 2104                if (attributes.Count > 0)
 2105                    await client.SetQueueAttributesAsync(queueUrl, attributes, cancellationToken).ConfigureAwait(false);
 2106                return queueUrl;
 107            }
 0108        }
 3109    }
 110
 111    private async Task<T> RetryAsync<T>(Func<Task<T>> operation, CancellationToken cancellationToken)
 112    {
 3113        var o = options.Value;
 3114        for (var attempt = 1; ; attempt++)
 115        {
 116            try
 117            {
 3118                return await operation().ConfigureAwait(false);
 119            }
 2120            catch (Exception ex) when (attempt < MaxAttempts && !cancellationToken.IsCancellationRequested)
 121            {
 122                // Startup ordering against a fresh endpoint (LocalStack still booting, transient
 123                // networking) resolves within a few retries; anything persistent still surfaces.
 2124                var delay = AsyncResponseRetry.Backoff(attempt, o.SubscriberRetryBaseDelay, o.SubscriberRetryMaxDelay);
 2125                logger.LogWarning(ex, "SQS queue provisioning attempt {Attempt} failed; retrying in {Delay}.", attempt, 
 2126                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 127            }
 128        }
 3129    }
 130}