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

Information
Class: AsyncResponse.Transports.AzureServiceBus.AzureServiceBusWorkerTransport
Assembly: AsyncResponse.Transports.AzureServiceBus
File(s): /_/src/Transports/AsyncResponse.Transports.AzureServiceBus/AzureServiceBusWorkerTransport.cs
Line coverage
100%
Covered lines: 87
Uncovered lines: 0
Coverable lines: 87
Total lines: 195
Line coverage: 100%
Branch coverage
83%
Covered branches: 30
Total branches: 36
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
GetSenderAsync()100%44100%
PublishAsync(...)100%11100%
get_MaxPublishDelay()100%11100%
PublishAsync(...)50%22100%
PublishCoreAsync()72.22%181896.66%
SendWithRetryAsync()100%11100%
IsTransient(...)100%22100%
DisposeAsync()100%1010100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Options;
 2using Azure.Messaging.ServiceBus;
 3using System.Diagnostics;
 4using System.Text.Json;
 5
 6namespace AsyncResponse.Transports.AzureServiceBus;
 7
 8/// <summary>Publishes <see cref="WorkerJobEnvelope"/> messages to an Azure Service Bus queue.</summary>
 9/// <remarks>
 10/// A Service Bus sender is created lazily and reused for the lifetime of the transport. Send
 11/// operations are explicitly awaited so the method completes only after Service Bus accepts the
 12/// transfer or the Azure SDK reports a failure.
 13/// </remarks>
 14public sealed class AzureServiceBusWorkerTransport : IWorkerTransport, IDelayedWorkerTransport, IAsyncDisposable
 15{
 16    private readonly AzureServiceBusAsyncResponseOptions _options;
 17    private readonly IAzureServiceBusClient _client;
 18    private readonly bool _disposeClient;
 23219    private readonly SemaphoreSlim _senderGate = new(1, 1);
 20    private IAzureServiceBusSender? _sender;
 21    private int _disposeGate;
 22    private bool _disposed;
 23
 24    /// <summary>Creates a worker transport backed by the configured connection string.</summary>
 25    public AzureServiceBusWorkerTransport(IOptions<AzureServiceBusAsyncResponseOptions> options)
 426        : this(
 427            options,
 428            new AzureServiceBusClientAdapter(new ServiceBusClient(
 429                AzureServiceBusOptionsValidator.Required(options.Value.ConnectionString, nameof(options.Value.Connection
 430                ownsClient: true),
 431            disposeClient: true)
 32    {
 233    }
 34
 35    internal AzureServiceBusWorkerTransport(
 36        IOptions<AzureServiceBusAsyncResponseOptions> options,
 37        IAzureServiceBusClient client)
 23038        : this(options, client, disposeClient: false)
 39    {
 21840    }
 41
 23242    private AzureServiceBusWorkerTransport(
 23243        IOptions<AzureServiceBusAsyncResponseOptions> options,
 23244        IAzureServiceBusClient client,
 23245        bool disposeClient)
 46    {
 23247        _options = options.Value;
 23248        AzureServiceBusOptionsValidator.ValidateCommon(_options);
 22049        _client = client;
 22050        _disposeClient = disposeClient;
 22051    }
 52
 53    private async Task<IAzureServiceBusSender> GetSenderAsync(CancellationToken cancellationToken)
 54    {
 42955        var sender = Volatile.Read(ref _sender);
 42956        if (sender is not null)
 21657            return sender;
 58
 21359        await _senderGate.WaitAsync(cancellationToken).ConfigureAwait(false);
 60        try
 61        {
 21362            ObjectDisposedException.ThrowIf(_disposed, this);
 21163            _sender ??= _client.CreateSender(_options.WorkerQueue);
 21164            return _sender;
 65        }
 66        finally
 67        {
 21368            _senderGate.Release();
 69        }
 42770    }
 71
 72    /// <summary>Publishes the supplied worker job.</summary>
 73    public Task PublishAsync(WorkerJobEnvelope job, CancellationToken cancellationToken = default)
 43074        => PublishCoreAsync(job, delay: null, cancellationToken);
 75
 76    /// <inheritdoc/>
 77    /// <remarks>Service Bus scheduled messages accept any future enqueue time; no per-hop chunking is needed.</remarks>
 578    public TimeSpan MaxPublishDelay => AsyncResponseChannelOptions.MaxPersistenceTtl;
 79
 80    /// <inheritdoc/>
 81    public Task PublishAsync(WorkerJobEnvelope job, TimeSpan delay, CancellationToken cancellationToken = default)
 82    {
 183        ArgumentOutOfRangeException.ThrowIfGreaterThan(delay, MaxPublishDelay);
 184        return PublishCoreAsync(job, delay > TimeSpan.Zero ? delay : null, cancellationToken);
 85    }
 86
 87    private async Task PublishCoreAsync(WorkerJobEnvelope job, TimeSpan? delay, CancellationToken cancellationToken)
 88    {
 43189        ArgumentNullException.ThrowIfNull(job);
 90
 42991        using var activity = AsyncResponseDiagnostics.StartActivity(
 42992            "asyncresponse.worker.publish",
 42993            ActivityKind.Producer,
 42994            job.CorrelationId);
 42995        activity?.SetTag("asyncresponse.transport", "azure_service_bus");
 42996        activity?.SetTag("messaging.system", "azure_service_bus");
 42997        activity?.SetTag("messaging.destination.name", _options.WorkerQueue);
 42998        AsyncResponseDiagnostics.SetReplyTarget(activity, job.ReplyTarget);
 42999        AsyncResponseDiagnostics.SetWorker(activity, job.Call);
 100
 101        try
 102        {
 103            // Every message carries a fresh MessageId: MessageId is the duplicate-detection key, so
 104            // reusing the correlation id would silently drop the second job of a flow published on a
 105            // dedup-enabled queue inside its detection window (distinct jobs of one flow share the
 106            // correlation id). The correlation id still travels in the Service Bus CorrelationId
 107            // system property and the configured application property.
 429108            var messageId = Guid.NewGuid().ToString("N");
 429109            var properties = new Dictionary<string, object?>(StringComparer.Ordinal);
 429110            if (!string.IsNullOrWhiteSpace(job.CorrelationId))
 115111                properties[_options.CorrelationIdProperty] = job.CorrelationId;
 429112            var message = new AzureServiceBusOutboundMessage(
 429113                AsyncResponseJson.Serialize(job),
 429114                messageId,
 429115                string.IsNullOrWhiteSpace(job.CorrelationId) ? null : job.CorrelationId,
 429116                properties,
 429117                delay is { } pending ? DateTimeOffset.UtcNow.Add(pending) : null);
 429118            if (delay is { } delayTag)
 1119                activity?.SetTag("asyncresponse.worker.delay_seconds", delayTag.TotalSeconds);
 120
 429121            var sender = await GetSenderAsync(cancellationToken).ConfigureAwait(false);
 427122            await SendWithRetryAsync(sender, message, cancellationToken).ConfigureAwait(false);
 421123            activity?.SetTag("messaging.message.id", messageId);
 421124        }
 8125        catch (Exception ex)
 126        {
 8127            AsyncResponseDiagnostics.SetError(activity, ex);
 8128            throw;
 129        }
 421130    }
 131
 132    private async Task SendWithRetryAsync(
 133        IAzureServiceBusSender sender,
 134        AzureServiceBusOutboundMessage message,
 135        CancellationToken cancellationToken)
 136    {
 433137        for (var attempt = 1; ; attempt++)
 138        {
 139            try
 140            {
 433141                await sender.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
 421142                return;
 143            }
 12144            catch (Exception ex) when (IsTransient(ex) && attempt < _options.PublishMaxAttempts && !cancellationToken.Is
 145            {
 6146                var delay = AsyncResponseRetry.Backoff(attempt, _options.PublishRetryBaseDelay, _options.PublishRetryMax
 6147                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 148            }
 149        }
 421150    }
 151
 152    /// <summary>Runs the IsTransient operation.</summary>
 153    internal static bool IsTransient(Exception exception)
 12154        => exception is ServiceBusException { IsTransient: true };
 155
 156    /// <summary>Releases resources held by this instance.</summary>
 157    public async ValueTask DisposeAsync()
 158    {
 398159        if (Interlocked.Exchange(ref _disposeGate, 1) != 0)
 196160            return;
 161
 202162        await _senderGate.WaitAsync().ConfigureAwait(false);
 163        try
 164        {
 202165            _disposed = true;
 202166            if (_sender is not null)
 167            {
 197168                using var cts = new CancellationTokenSource(_options.ShutdownTimeout);
 169                try
 170                {
 197171                    await _sender.CloseAsync(cts.Token).ConfigureAwait(false);
 195172                }
 2173                catch
 174                {
 175                    // Best effort: the sender link may already be closed by broker-side shutdown.
 2176                }
 177
 197178                await _sender.DisposeAsync().ConfigureAwait(false);
 197179            }
 180
 202181            if (_disposeClient)
 2182                await _client.DisposeAsync().ConfigureAwait(false);
 202183        }
 184        finally
 185        {
 186            // Release, never Dispose: SemaphoreSlim.Dispose does not complete pending WaitAsync
 187            // waiters, so disposing here would strand publishers parked on the gate forever (and
 188            // the first woken waiter's finally would throw trying to Release a disposed
 189            // semaphore, never handing the permit on). Released, each parked waiter wakes in
 190            // turn and observes _disposed; the gate holds no unmanaged resources, so leaving it
 191            // undisposed leaks nothing.
 202192            _senderGate.Release();
 193        }
 398194    }
 195}