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

Information
Class: AsyncResponse.Transports.PostgreSQL.PostgreSqlWorkerSubscriber
Assembly: AsyncResponse.Transports.PostgreSQL
File(s): /_/src/Transports/AsyncResponse.Transports.PostgreSQL/PostgreSqlSubscriberServices.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 235
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.PostgreSQL/PostgreSqlSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Threading.Channels;
 5
 6namespace AsyncResponse.Transports.PostgreSQL;
 7
 8/// <summary>
 9/// Base hosted service that consumes one PostgreSQL queue and routes rows to AsyncResponse ingress
 10/// with configured acknowledgement, redelivery, and dead-letter behavior.
 11/// </summary>
 12internal abstract class PostgreSqlSubscriberService : BackgroundService
 13{
 14    private readonly PostgreSqlTransportStore _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 PostgreSqlSubscriberService(
 23        IOptions<PostgreSqlAsyncResponseTransportOptions> options,
 24        PostgreSqlTransportStore store,
 25        ILogger logger)
 26    {
 27        Options = options.Value;
 28        PostgreSqlTransportOptionsValidator.ValidateCommon(Options);
 29        _store = store;
 30        Logger = logger;
 31    }
 32
 33    protected PostgreSqlAsyncResponseTransportOptions Options { get; }
 34    protected ILogger Logger { get; }
 35
 36    protected abstract string Queue { get; }
 37    protected abstract PostgreSqlSubscriberOptions SubscriberOptions { get; }
 38    protected abstract PostgreSqlSubscriberRole Role { get; }
 39    protected abstract Task HandleMessageAsync(PostgreSqlTransportDelivery 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        PostgreSqlTransportOptionsValidator.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 PostgreSqlMessageDispatcher(
 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, "PostgreSQL subscriber failed for queue {Queue} ({Role}); retrying in {
 78    }
 79
 80    private async Task RunSubscriberAsync(PostgreSqlMessageDispatcher dispatcher, CancellationToken stoppingToken)
 81    {
 82        await _store.EnsureCreatedAsync(stoppingToken).ConfigureAwait(false);
 83        using var signalCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
 84
 85        // The LISTEN task starts inside the try so ANY escape — a throwing logger provider
 86        // included — runs the cancelling finally. Disposing signalCts does NOT cancel it, so an
 87        // escape before the finally would otherwise leave ListenLoopAsync parked in
 88        // connection.WaitAsync holding a pooled connection, one per retry until pool exhaustion.
 89        Task? listenTask = null;
 90        try
 91        {
 92            listenTask = Task.Run(() => ListenLoopAsync(signalCts.Token), signalCts.Token);
 93
 94            Logger.LogInformation(
 95                "PostgreSQL subscriber started. Queue: {Queue}. Role: {Role}. AckMode: {AckMode}.",
 96                Queue,
 97                Role,
 98                SubscriberOptions.AckMode);
 99
 100            while (!stoppingToken.IsCancellationRequested)
 101            {
 102                var claimed = 0;
 103                await foreach (var delivery in _store.ClaimBatchAsync(Queue, SubscriberOptions.BatchSize, Options.LockTi
 104                {
 105                    claimed++;
 106                    await dispatcher.HandleAsync(delivery, stoppingToken).ConfigureAwait(false);
 107                }
 108
 109                if (claimed > 0)
 110                    continue;
 111
 112                await WaitForSignalOrDelayAsync(stoppingToken).ConfigureAwait(false);
 113            }
 114        }
 115        finally
 116        {
 117            await signalCts.CancelAsync().ConfigureAwait(false);
 118            if (listenTask is not null)
 119            {
 120                try
 121                {
 122                    await listenTask.WaitAsync(Options.ShutdownTimeout).ConfigureAwait(false);
 123                }
 124                catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
 125                {
 126                }
 127            }
 128        }
 129    }
 130
 131    private async Task ListenLoopAsync(CancellationToken cancellationToken)
 132    {
 133        // Retry with backoff, mirroring the channel-side listener: a transient LISTEN failure
 134        // (network blip, failover) must not permanently degrade this subscriber from push wake
 135        // to poll-only latency for the rest of the process's uptime.
 136        var failures = 0;
 137        while (!cancellationToken.IsCancellationRequested)
 138        {
 139            try
 140            {
 141                await _store.ExecuteListenAsync(() =>
 142                {
 143                    _signals.Writer.TryWrite(true);
 144                    return Task.CompletedTask;
 145                }, cancellationToken).ConfigureAwait(false);
 146                failures = 0;
 147            }
 148            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 149            {
 150                return;
 151            }
 152            catch (Exception ex)
 153            {
 154                failures++;
 155                var delay = AsyncResponseRetry.Backoff(failures, Options.SubscriberRetryBaseDelay, Options.SubscriberRet
 156                Logger.LogWarning(ex, "PostgreSQL LISTEN helper for queue {Queue} failed; retrying in {RetryDelay} (poll
 157                try
 158                {
 159                    await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 160                }
 161                catch (OperationCanceledException)
 162                {
 163                    return;
 164                }
 165            }
 166        }
 167    }
 168
 169    private async Task WaitForSignalOrDelayAsync(CancellationToken cancellationToken)
 170    {
 171        // The WhenAny loser is cancelled via the per-iteration linked source (mirroring the
 172        // channel-side CollectDispatchScopeAsync): an abandoned WaitToReadAsync would otherwise
 173        // stay parked in the channel's blocked-reader list until the next signal — one per empty
 174        // poll, accumulating without bound on an idle queue.
 175        using var iteration = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 176        var delay = Task.Delay(SubscriberOptions.EmptyPollDelay, iteration.Token);
 177        var signal = _signals.Reader.WaitToReadAsync(iteration.Token).AsTask();
 178        var completed = await Task.WhenAny(delay, signal).ConfigureAwait(false);
 179        iteration.Cancel();
 180        if (completed == signal)
 181        {
 182            await signal.ConfigureAwait(false);
 183            while (_signals.Reader.TryRead(out _))
 184            {
 185            }
 186        }
 187    }
 188}
 189
 190/// <summary>Consumes worker-job rows and executes them through the AsyncResponse ingress.</summary>
 191internal sealed class PostgreSqlWorkerSubscriber : PostgreSqlSubscriberService
 192{
 193    private readonly IAsyncResponseIngress _ingress;
 194
 195    public PostgreSqlWorkerSubscriber(
 196        IOptions<PostgreSqlAsyncResponseTransportOptions> options,
 197        PostgreSqlTransportStore store,
 198        IAsyncResponseIngress ingress,
 199        ILogger<PostgreSqlWorkerSubscriber> logger)
 203200        : base(options, store, logger)
 203201        => _ingress = ingress;
 202
 1120203    protected override string Queue => Options.WorkerQueue;
 2265204    protected override PostgreSqlSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 585205    protected override PostgreSqlSubscriberRole Role => PostgreSqlSubscriberRole.Worker;
 206
 207    protected override Task HandleMessageAsync(PostgreSqlTransportDelivery delivery, CancellationToken cancellationToken
 411208        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 209}
 210
 211/// <summary>Consumes response rows and feeds them into the AsyncResponse ingress.</summary>
 212internal sealed class PostgreSqlResponseIngressSubscriber : PostgreSqlSubscriberService
 213{
 214    private readonly IAsyncResponseIngress _ingress;
 215
 216    public PostgreSqlResponseIngressSubscriber(
 217        IOptions<PostgreSqlAsyncResponseTransportOptions> options,
 218        PostgreSqlTransportStore store,
 219        IAsyncResponseIngress ingress,
 220        ILogger<PostgreSqlResponseIngressSubscriber> logger)
 221        : base(options, store, logger)
 222        => _ingress = ingress;
 223
 224    protected override string Queue => Options.ResponseQueue;
 225    protected override PostgreSqlSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 226    protected override PostgreSqlSubscriberRole Role => PostgreSqlSubscriberRole.ResponseIngress;
 227
 228    protected override Task HandleMessageAsync(PostgreSqlTransportDelivery delivery, CancellationToken cancellationToken
 229    {
 230        var correlationId = !_ingress.IsOverInboundBudget(delivery.Payload)
 231            ? PostgreSqlCorrelationIdExtractor.Extract(delivery.Headers, delivery.Payload, Options)
 232            : null;
 233        return _ingress.HandleResponseMessageAsync(delivery.Payload, correlationId);
 234    }
 235}