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

Information
Class: AsyncResponse.Transports.SqlServer.SqlServerWorkerSubscriber
Assembly: AsyncResponse.Transports.SqlServer
File(s): /_/src/Transports/AsyncResponse.Transports.SqlServer/SqlServerSubscriberServices.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 194
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
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_Queue()100%11100%
get_SubscriberOptions()100%11100%
get_Role()100%11100%
HandleMessageAsync(...)100%11100%

File(s)

/_/src/Transports/AsyncResponse.Transports.SqlServer/SqlServerSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Threading.Channels;
 5
 6namespace AsyncResponse.Transports.SqlServer;
 7
 8/// <summary>
 9/// Base hosted service that consumes one SQL Server queue and routes rows to AsyncResponse ingress
 10/// with configured acknowledgement, redelivery, and dead-letter behavior.
 11/// </summary>
 12internal abstract class SqlServerSubscriberService : BackgroundService
 13{
 14    private readonly SqlServerTransportStore _store;
 15    private readonly Channel<bool> _signals = Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
 16    {
 17        SingleReader = true,
 18        SingleWriter = false,
 19        FullMode = BoundedChannelFullMode.DropWrite
 20    });
 21
 22    protected SqlServerSubscriberService(
 23        IOptions<SqlServerAsyncResponseTransportOptions> options,
 24        SqlServerTransportStore store,
 25        ILogger logger)
 26    {
 27        Options = options.Value;
 28        SqlServerTransportOptionsValidator.ValidateCommon(Options);
 29        _store = store;
 30        Logger = logger;
 31    }
 32
 33    protected SqlServerAsyncResponseTransportOptions Options { get; }
 34    protected ILogger Logger { get; }
 35
 36    protected abstract string Queue { get; }
 37    protected abstract SqlServerSubscriberOptions SubscriberOptions { get; }
 38    protected abstract SqlServerSubscriberRole Role { get; }
 39    protected abstract Task HandleMessageAsync(SqlServerTransportDelivery delivery, CancellationToken cancellationToken)
 40
 41    /// <inheritdoc />
 42    /// <summary>
 43    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 44    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 45    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 46    /// background-exception handling — or never, when a fast stop discards the queued work —
 47    /// instead of failing host startup synchronously.
 48    /// </summary>
 49    public override Task StartAsync(CancellationToken cancellationToken)
 50    {
 51        SqlServerTransportOptionsValidator.ValidateSubscriber(Options, SubscriberOptions, Role.ToString());
 52        return base.StartAsync(cancellationToken);
 53    }
 54
 55    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 56    {
 57        // ONE dispatcher for the service's lifetime, outliving every supervised attempt below; only
 58        // the host stopping disposes it. In early-ACK mode it owns the queued and running work
 59        // whose queue items the ACK already deleted, and its DisposeAsync IS the stop-time drain:
 60        // wait out BackgroundDrainTimeout, then cancel and dead-letter whatever is still queued.
 61        // Built inside the attempt, every poll fault — a claim timeout, a deadlock victim, a
 62        // failover: routine for a loop that polls the database several times a second — ran that
 63        // drain on a host that was NOT stopping: consumption paused for the whole budget, then
 64        // healthy already-ACKed work was dead-lettered as "drain budget lapsed" — or, when the
 65        // dead-letter write needed the same failing database, survived only as an Error log line.
 66        await using var dispatcher = new SqlServerMessageDispatcher(
 67            HandleMessageAsync,
 68            Options,
 69            SubscriberOptions,
 70            Logger,
 71            Role);
 72
 73        await SubscriberSupervisor.RunAsync(
 74            attemptToken => RunSubscriberAsync(dispatcher, attemptToken),
 75            stoppingToken,
 76            failures => AsyncResponseRetry.Backoff(failures, Options.SubscriberRetryBaseDelay, Options.SubscriberRetryMa
 77            (ex, delay) => Logger.LogWarning(ex, "SQL Server subscriber failed for queue {Queue} ({Role}); retrying in {
 78    }
 79
 80    private async Task RunSubscriberAsync(SqlServerMessageDispatcher dispatcher, CancellationToken stoppingToken)
 81    {
 82        await _store.EnsureCreatedAsync(stoppingToken).ConfigureAwait(false);
 83
 84        // Same-process wake: publishes to this queue (or a NAK release, queue == null) signal the
 85        // loop directly since SQL Server has no LISTEN/NOTIFY; cross-process publishes are picked up
 86        // by the EmptyPollDelay poll below.
 87        Action<string?> onPublished = queue =>
 88        {
 89            if (queue is null || string.Equals(queue, Queue, StringComparison.Ordinal))
 90                _signals.Writer.TryWrite(true);
 91        };
 92
 93        // The subscription happens inside the try so ANY escape — a throwing logger provider
 94        // included — runs the unsubscribing finally: the store is a singleton, so a handler leaked
 95        // by one failed run survives every retry and every later publish invokes it. A -= that the
 96        // += never preceded is a harmless no-op.
 97        try
 98        {
 99            _store.MessagePublished += onPublished;
 100
 101            Logger.LogInformation(
 102                "SQL Server subscriber started. Queue: {Queue}. Role: {Role}. AckMode: {AckMode}.",
 103                Queue,
 104                Role,
 105                SubscriberOptions.AckMode);
 106
 107            while (!stoppingToken.IsCancellationRequested)
 108            {
 109                var claimed = 0;
 110                await foreach (var delivery in _store.ClaimBatchAsync(Queue, SubscriberOptions.BatchSize, Options.LockTi
 111                {
 112                    claimed++;
 113                    await dispatcher.HandleAsync(delivery, stoppingToken).ConfigureAwait(false);
 114                }
 115
 116                if (claimed > 0)
 117                    continue;
 118
 119                await WaitForSignalOrDelayAsync(stoppingToken).ConfigureAwait(false);
 120            }
 121        }
 122        finally
 123        {
 124            _store.MessagePublished -= onPublished;
 125        }
 126    }
 127
 128    private async Task WaitForSignalOrDelayAsync(CancellationToken cancellationToken)
 129    {
 130        // The WhenAny loser is cancelled via the per-iteration linked source (mirroring the
 131        // channel-side CollectDispatchScopeAsync): an abandoned WaitToReadAsync would otherwise
 132        // stay parked in the channel's blocked-reader list until the next signal — one per empty
 133        // poll, accumulating without bound on an idle queue.
 134        using var iteration = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 135        var delay = Task.Delay(SubscriberOptions.EmptyPollDelay, iteration.Token);
 136        var signal = _signals.Reader.WaitToReadAsync(iteration.Token).AsTask();
 137        var completed = await Task.WhenAny(delay, signal).ConfigureAwait(false);
 138        iteration.Cancel();
 139        if (completed == signal)
 140        {
 141            await signal.ConfigureAwait(false);
 142            while (_signals.Reader.TryRead(out _))
 143            {
 144            }
 145        }
 146    }
 147}
 148
 149/// <summary>Consumes worker-job rows and executes them through the AsyncResponse ingress.</summary>
 150internal sealed class SqlServerWorkerSubscriber : SqlServerSubscriberService
 151{
 152    private readonly IAsyncResponseIngress _ingress;
 153
 154    public SqlServerWorkerSubscriber(
 155        IOptions<SqlServerAsyncResponseTransportOptions> options,
 156        SqlServerTransportStore store,
 157        IAsyncResponseIngress ingress,
 158        ILogger<SqlServerWorkerSubscriber> logger)
 205159        : base(options, store, logger)
 205160        => _ingress = ingress;
 161
 1497162    protected override string Queue => Options.WorkerQueue;
 2182163    protected override SqlServerSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 585164    protected override SqlServerSubscriberRole Role => SqlServerSubscriberRole.Worker;
 165
 166    protected override Task HandleMessageAsync(SqlServerTransportDelivery delivery, CancellationToken cancellationToken)
 413167        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 168}
 169
 170/// <summary>Consumes response rows and feeds them into the AsyncResponse ingress.</summary>
 171internal sealed class SqlServerResponseIngressSubscriber : SqlServerSubscriberService
 172{
 173    private readonly IAsyncResponseIngress _ingress;
 174
 175    public SqlServerResponseIngressSubscriber(
 176        IOptions<SqlServerAsyncResponseTransportOptions> options,
 177        SqlServerTransportStore store,
 178        IAsyncResponseIngress ingress,
 179        ILogger<SqlServerResponseIngressSubscriber> logger)
 180        : base(options, store, logger)
 181        => _ingress = ingress;
 182
 183    protected override string Queue => Options.ResponseQueue;
 184    protected override SqlServerSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 185    protected override SqlServerSubscriberRole Role => SqlServerSubscriberRole.ResponseIngress;
 186
 187    protected override Task HandleMessageAsync(SqlServerTransportDelivery delivery, CancellationToken cancellationToken)
 188    {
 189        var correlationId = !_ingress.IsOverInboundBudget(delivery.Payload)
 190            ? SqlServerCorrelationIdExtractor.Extract(delivery.Headers, delivery.Payload, Options)
 191            : null;
 192        return _ingress.HandleResponseMessageAsync(delivery.Payload, correlationId);
 193    }
 194}