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

Information
Class: AsyncResponse.Transports.NATS.NatsSubscriberService
Assembly: AsyncResponse.Transports.NATS
File(s): /_/src/Transports/AsyncResponse.Transports.NATS/NatsSubscriberServices.cs
Line coverage
75%
Covered lines: 106
Uncovered lines: 35
Coverable lines: 141
Total lines: 451
Line coverage: 75.1%
Branch coverage
82%
Covered branches: 33
Total branches: 40
Branch coverage: 82.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
.ctor(...)50%22100%
get_Options()100%11100%
get_Logger()100%11100%
get_Schema()100%11100%
StartAsync(...)100%11100%
ExecuteAsync()100%22100%
RunSubscriberAsync()100%1616100%
BackOffAfterEmptyLongPollAsync()50%28615.78%
DispatchBatchAsync()75%8896.15%
ReleaseUnstartedAsync()100%210%
get_RenewalInterval()100%11100%
RenewInProgressLoopAsync()83.33%7666.66%
get_SettledCount()100%11100%
MarkSettled()100%11100%

File(s)

/_/src/Transports/AsyncResponse.Transports.NATS/NatsSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using NATS.Client.Core;
 5using NATS.Net;
 6
 7namespace AsyncResponse.Transports.NATS;
 8
 9/// <summary>
 10/// Base hosted service that consumes a JetStream subject through a durable consumer and routes each
 11/// message to the AsyncResponse ingress with the configured acknowledgement/redelivery/dead-letter
 12/// policy. A failed consume loop is retried with bounded backoff so a transient NATS outage does not
 13/// kill the subscriber.
 14/// </summary>
 15internal abstract class NatsSubscriberService : BackgroundService
 16{
 17    /// <summary>
 18    /// Re-arm period of the idle long-poll fetch (the NATS.Net default fetch period). Purely how
 19    /// often an empty wait returns to re-check cancellation — not a delivery deadline.
 20    /// </summary>
 621    private static readonly TimeSpan LongPollExpires = TimeSpan.FromSeconds(30);
 22
 23    /// <summary>
 24    /// A long poll that comes back empty sooner than this did not expire: the server holds a pull
 25    /// request for the whole <see cref="LongPollExpires"/> when there is nothing to deliver. Half
 26    /// the period leaves room for a poll cut short by a reconnect without ever mistaking a
 27    /// millisecond answer for an expiry.
 28    /// </summary>
 629    private static readonly TimeSpan FastEmptyPollThreshold = LongPollExpires / 2;
 30
 31    /// <summary>
 32    /// Consecutive fast-empty long polls after which the attempt is handed back to the supervisor,
 33    /// so the stream/consumer provisioning runs again.
 34    /// </summary>
 35    private const int MaxConsecutiveFastEmptyPolls = 5;
 36
 37    private readonly INatsJetStreamTransport _jetStream;
 38    private readonly TimeProvider _timeProvider;
 39
 40    /// <summary>Runs the NatsSubscriberService operation.</summary>
 41    protected NatsSubscriberService(
 42        IOptions<NatsAsyncResponseTransportOptions> options,
 43        INatsConnection connection,
 44        ILogger logger)
 39245        : this(options, new NatsJetStreamTransportAdapter(connection.CreateJetStreamContext(), logger, options.Value.Str
 46    {
 39247    }
 48
 49    /// <summary>Runs the NatsSubscriberService operation.</summary>
 41050    protected NatsSubscriberService(
 41051        IOptions<NatsAsyncResponseTransportOptions> options,
 41052        INatsJetStreamTransport jetStream,
 41053        ILogger logger,
 41054        TimeProvider? timeProvider = null)
 55    {
 41056        Options = options.Value;
 41057        NatsTransportOptionsValidator.ValidateCommon(Options);
 41058        _jetStream = jetStream;
 41059        Logger = logger;
 41060        Schema = new NatsTransportSubjectSchema(Options);
 61
 62        // Times the idle long poll and paces its fast-empty backoff. The system clock in
 63        // production — what is measured is a real server's answer; the seam exists for tests.
 41064        _timeProvider = timeProvider ?? TimeProvider.System;
 41065    }
 66
 878367    protected NatsAsyncResponseTransportOptions Options { get; }
 81368    protected ILogger Logger { get; }
 466869    protected NatsTransportSubjectSchema Schema { get; }
 70
 71    protected abstract string Subject { get; }
 72    protected abstract string Stream { get; }
 73    protected abstract string Consumer { get; }
 74    protected abstract NatsSubscriberOptions SubscriberOptions { get; }
 75    protected abstract NatsSubscriberRole Role { get; }
 76    /// <summary>Handles the delivered message.</summary>
 77    protected abstract Task HandleMessageAsync(NatsJobDelivery delivery, CancellationToken cancellationToken);
 78
 79    /// <summary>Runs this background operation until cancellation is requested.</summary>
 80    /// <summary>
 81    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 82    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 83    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 84    /// background-exception handling — or never, when a fast stop discards the queued work —
 85    /// instead of failing host startup synchronously.
 86    /// </summary>
 87    public override Task StartAsync(CancellationToken cancellationToken)
 88    {
 40289        NatsTransportOptionsValidator.ValidateSubscriber(Options, SubscriberOptions, Role.ToString());
 40090        return base.StartAsync(cancellationToken);
 91    }
 92
 93    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 94    {
 95        // The dispatcher — and with it the ACK-after-enqueue queue and its workers — belongs to
 96        // the hosted service, not to one supervised attempt. Disposing it IS the stop-time drain
 97        // (wait BackgroundDrainTimeout, then cancel and dead-letter whatever is still queued), so
 98        // owning it per attempt ran that drain on every fetch-loop failure of a host that was NOT
 99        // stopping: a NATS blip or a JetStream leader election paused consumption for the drain
 100        // budget and then buried queued, already-ACKed work as "drain budget lapsed" — or lost it
 101        // outright when the dead-letter publish rode the same failing connection. Nothing in it
 102        // is per attempt (the JetStream adapter wraps the host's reconnecting connection, and each
 103        // delivery carries its own settlement handles), so every rebuilt attempt feeds this one
 104        // instance and only the host stop drains it.
 400105        await using var dispatcher = new NatsMessageDispatcher(
 400106            HandleMessageAsync,
 400107            _jetStream,
 400108            Options,
 400109            SubscriberOptions,
 400110            Schema,
 400111            Logger,
 400112            Role,
 400113            Consumer);
 114
 400115        await SubscriberSupervisor.RunAsync(
 409116            attemptToken => RunSubscriberAsync(dispatcher, attemptToken),
 400117            stoppingToken,
 9118            failures => AsyncResponseRetry.Backoff(failures, Options.SubscriberRetryBaseDelay, Options.SubscriberRetryMa
 409119            (ex, retryDelay) => Logger.LogWarning(ex, "NATS subscriber failed for subject {Subject} ({Role}); retrying i
 400120    }
 121
 122    private async Task RunSubscriberAsync(NatsMessageDispatcher dispatcher, CancellationToken stoppingToken)
 123    {
 409124        if (Options.CreateStreams)
 125        {
 409126            await _jetStream.EnsureStreamAsync(Stream, Subject, Options.StreamMaxMessages, stoppingToken).ConfigureAwait
 402127            if (Options.DeadLetterEnabled)
 402128                await _jetStream.EnsureDeadLetterStreamAsync(Schema.DeadLetterStream, Schema.DeadLetterSubject, Options.
 129        }
 130
 402131        await _jetStream.EnsureConsumerAsync(Stream, Consumer, Options.AckWait, stoppingToken).ConfigureAwait(false);
 132
 400133        Logger.LogInformation(
 400134            "NATS subscriber started. Subject: {Subject}. Stream: {Stream}. Consumer: {Consumer}. Role: {Role}. AckMode:
 400135            Subject, Stream, Consumer, Role, SubscriberOptions.AckMode);
 136
 137        // JetStream counts a delivery when it hands the message over, not when a handler starts.
 138        // ACK-after-handler runs its batch serially, so every message prefetched behind a handler
 139        // that kills the process (stack overflow, OOM, FailFast) came back with its count bumped
 140        // without ever having run — and MaxDeliveryAttempts crashes later the pre-execution cap
 141        // dead-lettered up to BatchSize-1 healthy batch-mates along with the poison one. One
 142        // message per fetch leaves the rest on the stream, where nothing is counted and any peer
 143        // can take it. ACK-after-enqueue settles each message as it is accepted, so it keeps the
 144        // batch.
 400145        var fetchSize = SubscriberOptions.AckMode is NatsAckMode.AckAfterEnqueue ? SubscriberOptions.BatchSize : 1;
 400146        var batch = new List<NatsJobDelivery>(fetchSize);
 400147        var fastEmptyPolls = 0;
 1218148        while (!stoppingToken.IsCancellationRequested)
 149        {
 851150            batch.Clear();
 151
 152            // Drain whatever is already available, up to the fetch size. The batch is
 153            // materialized out of the client buffer BEFORE dispatch so the in-progress heartbeat
 154            // below can reach every waiting message: the server starts each message's AckWait
 155            // clock at delivery, and an open-ended consume buffered the whole prefetch
 156            // client-side — a serial batch whose handlers together outlast AckWait had its tail
 157            // redelivered to a competing consumer (and NumDelivered climbed toward the Term cap)
 158            // while it was still queued here.
 2200159            await foreach (var delivery in _jetStream.FetchNoWaitAsync(Stream, Consumer, fetchSize, stoppingToken).Confi
 249160                batch.Add(delivery);
 161
 833162            if (batch.Count == 0)
 163            {
 164                // Nothing waiting: long-poll for a single message so idle delivery latency stays
 165                // push-like. Siblings arriving behind the long-polled message stay ON the stream
 166                // — where AckWait has not started — until the next no-wait drain.
 584167                var pollStarted = _timeProvider.GetTimestamp();
 1544168                await foreach (var delivery in _jetStream.FetchAsync(Stream, Consumer, maxMessages: 1, LongPollExpires, 
 188169                    batch.Add(delivery);
 170
 569171                if (batch.Count == 0)
 172                {
 381173                    fastEmptyPolls = await BackOffAfterEmptyLongPollAsync(_timeProvider.GetElapsedTime(pollStarted), fas
 381174                    continue;
 175                }
 176            }
 177
 437178            fastEmptyPolls = 0;
 437179            await DispatchBatchAsync(dispatcher, batch, stoppingToken).ConfigureAwait(false);
 180        }
 367181    }
 182
 183    /// <summary>
 184    /// Tells an expired long poll (re-arm at once) from one the server never held. A pull request
 185    /// that reaches no live consumer — the durable or its stream was deleted, or JetStream has no
 186    /// leader for it — is answered "503 no responders", which the client ends WITHOUT an
 187    /// exception: exactly what an expiry looks like, only in a millisecond. Re-arming on that spun
 188    /// the loop thousands of times a second against a cluster that was already in trouble, and
 189    /// since nothing ever threw, the supervisor never reran the provisioning that would have
 190    /// recreated the consumer — the subscriber stayed dead until the process restarted. (A poll
 191    /// in flight when the consumer is deleted does throw; the silent case is the replica that was
 192    /// inside a handler at that moment.) Returns the updated consecutive fast-empty count.
 193    /// </summary>
 194    private async Task<int> BackOffAfterEmptyLongPollAsync(TimeSpan pollDuration, int fastEmptyPolls, CancellationToken 
 195    {
 381196        if (pollDuration >= FastEmptyPollThreshold || stoppingToken.IsCancellationRequested)
 381197            return 0; // the long poll expired empty; re-arm
 198
 0199        fastEmptyPolls++;
 0200        if (fastEmptyPolls >= MaxConsecutiveFastEmptyPolls)
 201        {
 0202            throw new InvalidOperationException(
 0203                $"NATS consumer '{Consumer}' on stream '{Stream}' answered {fastEmptyPolls} consecutive long polls empty
 0204                $"(the last one returned after {pollDuration.TotalMilliseconds:F0} ms of a {LongPollExpires.TotalSeconds
 0205                "the pull requests are not reaching a live consumer — it or its stream was deleted, or JetStream has no 
 206        }
 207
 0208        var delay = AsyncResponseRetry.Backoff(fastEmptyPolls, Options.SubscriberRetryBaseDelay, Options.SubscriberRetry
 0209        Logger.LogDebug(
 0210            "NATS long poll for {Role} returned empty after {PollDuration} instead of being held; backing off {Delay} be
 0211            Role,
 0212            pollDuration,
 0213            delay,
 0214            fastEmptyPolls,
 0215            MaxConsecutiveFastEmptyPolls);
 0216        await Task.Delay(delay, _timeProvider, stoppingToken).ConfigureAwait(false);
 0217        return fastEmptyPolls;
 381218    }
 219
 220    private async Task DispatchBatchAsync(
 221        NatsMessageDispatcher dispatcher,
 222        List<NatsJobDelivery> batch,
 223        CancellationToken stoppingToken)
 224    {
 225        // The batch is dispatched serially, so a slow handler lets the server-side AckWait of the
 226        // later (still unsettled) messages lapse into redelivery. While the batch is in flight, a
 227        // heartbeat signals in-progress for every unsettled message to reset its AckWait window.
 228        // The heartbeat is NOT tied to the stop token: a handler takes no token, so it outlives
 229        // the stop signal, and cancelling its renewal there let AckWait lapse under the live
 230        // handler on every rolling deploy — the job was redelivered to a peer and ran twice. It
 231        // ends only when this loop has let go of every message.
 437232        var progress = new BatchProgress();
 437233        using var renewalCancellation = new CancellationTokenSource();
 437234        var renewalTask = RenewInProgressLoopAsync(batch, progress, renewalCancellation.Token);
 437235        var next = 0;
 236        try
 237        {
 1311238            for (; next < batch.Count; next++)
 239            {
 240                // Stopping: do not start what has not started. The rest of the batch used to run
 241                // on, handler after handler, past the stop signal.
 437242                if (stoppingToken.IsCancellationRequested)
 243                    break;
 244
 245                try
 246                {
 437247                    await dispatcher.HandleAsync(batch[next], stoppingToken).ConfigureAwait(false);
 437248                }
 249                finally
 250                {
 437251                    progress.MarkSettled();
 252                }
 253            }
 254        }
 255        finally
 256        {
 257            // Hand back whatever never started (a stop, or a handler cancelled by it, cut the
 258            // batch short) while the heartbeat still covers it.
 874259            for (var i = Math.Max(next, progress.SettledCount); i < batch.Count; i++)
 0260                await ReleaseUnstartedAsync(batch[i]).ConfigureAwait(false);
 261
 437262            renewalCancellation.Cancel();
 263            try
 264            {
 265                // Cancellation exits the sweep between messages and aborts the in-flight
 266                // heartbeat (the token reaches the SDK call), so this normally completes at once.
 267                // The bound is the hard backstop for a heartbeat the client cannot abort — a
 268                // write wedged on a dead socket: an unbounded join here held the loop after every
 269                // message in the batch had settled, so no further batch was fetched and a stop
 270                // never completed, with nothing for the supervisor to restart. Past one heartbeat
 271                // interval the loop is abandoned; the server's AckWait settles whatever it left.
 437272                await renewalTask.WaitAsync(RenewalInterval).ConfigureAwait(false);
 435273            }
 2274            catch (TimeoutException)
 275            {
 2276                Logger.LogWarning(
 2277                    "NATS in-progress heartbeat for {Role} did not stop within {RenewalInterval} after its batch settled
 2278                    Role,
 2279                    RenewalInterval);
 2280                _ = renewalTask.ContinueWith(
 0281                    static (task, state) => ((ILogger)state!).LogWarning(task.Exception, "Abandoned NATS in-progress hea
 2282                    Logger,
 2283                    CancellationToken.None,
 2284                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
 2285                    TaskScheduler.Default);
 2286            }
 287        }
 437288    }
 289
 290    /// <summary>
 291    /// Hands a prefetched message that never started back to the server, with no redelivery delay
 292    /// so an idle peer can take it at once. Leaving it unsettled instead would pin it for the rest
 293    /// of its AckWait window — the stop that cut the batch short is usually a rolling deploy, and
 294    /// the messages behind the handler are exactly the work the surviving replicas should pick up.
 295    /// A failure here is not worth failing the stop over: the window lapses and the server
 296    /// redelivers anyway, which is the same outcome one AckWait later.
 297    /// </summary>
 298    private async Task ReleaseUnstartedAsync(NatsJobDelivery delivery)
 299    {
 300        try
 301        {
 0302            await delivery.NakAsync(TimeSpan.Zero).ConfigureAwait(false);
 0303        }
 0304        catch (Exception ex)
 305        {
 0306            Logger.LogDebug(
 0307                ex,
 0308                "Failed to hand back an unstarted NATS message on subject {Subject} ({Role}); it redelivers when its Ack
 0309                delivery.Subject,
 0310                Role);
 0311        }
 0312    }
 313
 314    /// <summary>
 315    /// ~AckWait/3: two chances to land a renewal inside every AckWait window even when one sweep
 316    /// is delayed by a slow round trip. Also the bound on joining the renewal loop after a batch.
 317    /// </summary>
 876318    private TimeSpan RenewalInterval => TimeSpan.FromMilliseconds(Math.Max(1, Options.AckWait.TotalMilliseconds / 3));
 319
 320    private async Task RenewInProgressLoopAsync(
 321        List<NatsJobDelivery> batch,
 322        BatchProgress progress,
 323        CancellationToken cancellationToken)
 324    {
 437325        var interval = RenewalInterval;
 326        try
 327        {
 4328            while (true)
 329            {
 441330                await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
 331
 332                // Renew from the first unsettled message onward: that covers the message
 333                // currently in the handler plus everything still waiting its turn. A settle
 334                // racing this sweep is harmless — Ack/Nak/Term has already consumed the delivery
 335                // server-side, and a late in-progress signal for it is ignored rather than
 336                // un-settling anything, so no suppression mark is needed (unlike the SQS twin,
 337                // whose failure path re-arms a visibility a late renewal could stretch).
 20338                for (var i = progress.SettledCount; i < batch.Count; i++)
 339                {
 340                    // The batch finished or the subscriber is stopping: exit quietly between
 341                    // messages instead of spending a round trip on each remaining renewal.
 6342                    if (cancellationToken.IsCancellationRequested)
 0343                        return;
 344
 6345                    if (i < progress.SettledCount)
 346                        continue;
 347
 6348                    var delivery = batch[i];
 349                    try
 350                    {
 6351                        await delivery.ProgressAsync(cancellationToken).ConfigureAwait(false);
 4352                    }
 2353                    catch (Exception ex) when (ex is not OperationCanceledException)
 354                    {
 0355                        Logger.LogWarning(
 0356                            ex,
 0357                            "Failed to signal in-progress for NATS message on subject {Subject} ({Role}); its AckWait ma
 0358                            delivery.Subject,
 0359                            Role);
 0360                    }
 4361                }
 362            }
 363        }
 437364        catch (OperationCanceledException)
 365        {
 366            // The batch finished or the subscriber is stopping.
 437367        }
 437368    }
 369
 370    private sealed class BatchProgress
 371    {
 372        // Settled only ever increments, so a monotonic volatile read is enough — no lock, and a
 373        // stale read only renews an already-settled message once more (which the server ignores).
 374        private int _settledCount;
 375
 449376        public int SettledCount => Volatile.Read(ref _settledCount);
 377
 437378        public void MarkSettled() => Interlocked.Increment(ref _settledCount);
 379    }
 380}
 381
 382/// <summary>Consumes worker-job messages and executes them through the AsyncResponse ingress.</summary>
 383internal sealed class NatsWorkerSubscriber : NatsSubscriberService
 384{
 385    private readonly IAsyncResponseIngress _ingress;
 386
 387    /// <summary>Runs the NatsWorkerSubscriber operation.</summary>
 388    public NatsWorkerSubscriber(
 389        IOptions<NatsAsyncResponseTransportOptions> options,
 390        INatsConnection connection,
 391        IAsyncResponseIngress ingress,
 392        ILogger<NatsWorkerSubscriber> logger)
 393        : base(options, connection, logger)
 394        => _ingress = ingress;
 395
 396    internal NatsWorkerSubscriber(
 397        IOptions<NatsAsyncResponseTransportOptions> options,
 398        INatsJetStreamTransport jetStream,
 399        IAsyncResponseIngress ingress,
 400        ILogger<NatsWorkerSubscriber> logger)
 401        : base(options, jetStream, logger)
 402        => _ingress = ingress;
 403
 404    protected override string Subject => Schema.WorkerSubject;
 405    protected override string Stream => Schema.WorkerStream;
 406    protected override string Consumer => Options.WorkerConsumer;
 407    protected override NatsSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 408    protected override NatsSubscriberRole Role => NatsSubscriberRole.Worker;
 409
 410    /// <summary>Handles the delivered message.</summary>
 411    protected override Task HandleMessageAsync(NatsJobDelivery delivery, CancellationToken cancellationToken)
 412        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 413}
 414
 415/// <summary>Consumes response messages and feeds them into the AsyncResponse ingress, correlated by header or JSON body
 416internal sealed class NatsResponseIngressSubscriber : NatsSubscriberService
 417{
 418    private readonly IAsyncResponseIngress _ingress;
 419
 420    /// <summary>Runs the NatsResponseIngressSubscriber operation.</summary>
 421    public NatsResponseIngressSubscriber(
 422        IOptions<NatsAsyncResponseTransportOptions> options,
 423        INatsConnection connection,
 424        IAsyncResponseIngress ingress,
 425        ILogger<NatsResponseIngressSubscriber> logger)
 426        : base(options, connection, logger)
 427        => _ingress = ingress;
 428
 429    internal NatsResponseIngressSubscriber(
 430        IOptions<NatsAsyncResponseTransportOptions> options,
 431        INatsJetStreamTransport jetStream,
 432        IAsyncResponseIngress ingress,
 433        ILogger<NatsResponseIngressSubscriber> logger)
 434        : base(options, jetStream, logger)
 435        => _ingress = ingress;
 436
 437    protected override string Subject => Schema.ResponseSubject;
 438    protected override string Stream => Schema.ResponseStream;
 439    protected override string Consumer => Options.ResponseConsumer;
 440    protected override NatsSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 441    protected override NatsSubscriberRole Role => NatsSubscriberRole.ResponseIngress;
 442
 443    /// <summary>Handles the delivered message.</summary>
 444    protected override Task HandleMessageAsync(NatsJobDelivery delivery, CancellationToken cancellationToken)
 445    {
 446        var correlationId = !_ingress.IsOverInboundBudget(delivery.Payload)
 447            ? NatsCorrelationIdExtractor.Extract(delivery.Headers, delivery.Payload, Options)
 448            : null;
 449        return _ingress.HandleResponseMessageAsync(delivery.Payload, correlationId);
 450    }
 451}