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

Information
Class: AsyncResponse.Channels.NATS.NatsAsyncResponseChannel
Assembly: AsyncResponse.Channels.NATS
File(s): /_/src/Channels/AsyncResponse.Channels.NATS/NatsAsyncResponseChannel.cs
Line coverage
97%
Covered lines: 461
Uncovered lines: 11
Coverable lines: 472
Total lines: 1035
Line coverage: 97.6%
Branch coverage
92%
Covered branches: 164
Total branches: 178
Branch coverage: 92.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/_/src/Channels/AsyncResponse.Channels.NATS/NatsAsyncResponseChannel.cs

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Buffers;
 5using System.Diagnostics;
 6using System.Text;
 7using System.Text.Json;
 8
 9namespace AsyncResponse.Channels.NATS;
 10
 11/// <summary>
 12/// NATS-backed response channel:
 13/// <list type="bullet">
 14/// <item><description>Delivers responses over NATS Core request/reply on a subject keyed by
 15/// correlation id: a waiter subscribes and acks each message, and the publisher requests so the NATS
 16/// "no responders" signal reports precisely when nobody is listening.</description></item>
 17/// <item><description>Persists <see cref="RecoveryState"/> in a JetStream Key-Value bucket so a
 18/// response arriving after the waiter died (e.g. a redeploy) is routed through the lost-subscriber
 19/// dispatcher, which asks the payload's OnRecovery and invokes the resume or failure
 20/// callback with the materialized payload (or keeps the registration armed for a checkpoint).</description></item>
 21/// </list>
 22/// </summary>
 23internal sealed class NatsAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IRecoverableAsyncR
 24{
 25    private readonly INatsResponseChannelClient _client;
 26    private readonly IRecoveryStateStore _recoveryStateStore;
 27    private readonly AsyncResponseContextPropagation _propagation;
 28    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 29    private readonly NatsSubjectSchema _subjects;
 30    private readonly NatsAsyncResponseChannelOptions _options;
 31    private readonly ILogger<NatsAsyncResponseChannel> _logger;
 32    private readonly TimeProvider _timeProvider;
 33
 34    /// <summary>Creates a NATS-backed async-response channel.</summary>
 50535    public NatsAsyncResponseChannel(
 50536        IServiceScopeFactory scopeFactory,
 50537        INatsResponseChannelClient client,
 50538        IRecoveryStateStore recoveryStateStore,
 50539        IOptions<NatsAsyncResponseChannelOptions> options,
 50540        AsyncResponseContextPropagation propagation,
 50541        ILogger<NatsAsyncResponseChannel> logger,
 50542        TimeProvider? timeProvider = null)
 43    {
 50544        _timeProvider = timeProvider ?? TimeProvider.System;
 50545        _options = options.Value;
 50546        _options.Validate();
 50547        _client = client;
 50548        _recoveryStateStore = recoveryStateStore;
 50549        _propagation = propagation;
 50550        _subjects = new NatsSubjectSchema(_options.SubjectPrefix);
 50551        _logger = logger;
 50552        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger, _timeProvide
 50553    }
 54
 55    // ---------------------------------------------------------------------------------------
 56    // IAsyncResponseSubscriber / IRecoverableAsyncResponseSubscriber
 57
 58    /// <inheritdoc/>
 59    public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 60        string correlationId,
 61        Func<T, ValueTask<bool>>? completionPredicate = null,
 62        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 23563        => CreateResponseWaiterCore(correlationId, resumeCallback: null, failureCallback: null, completionPredicate, tim
 64
 65    /// <inheritdoc/>
 66    public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>(
 67        string correlationId,
 68        ReflectionCallDto? resumeCallback = null,
 69        ReflectionCallDto? failureCallback = null,
 70        Func<T, ValueTask<bool>>? completionPredicate = null,
 71        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 22672        => CreateResponseWaiterCore(correlationId, resumeCallback, failureCallback, completionPredicate, timeout);
 73
 74    /// <summary>
 75    /// The public <c>ResponseTask</c> surface: internal loop-fault settlements are marked with
 76    /// <see cref="NatsConsumeLoopException"/> so registration can abort atomically, but callers
 77    /// observe the original exception exactly as before.
 78    /// </summary>
 79    private static async Task<T> UnwrapConsumeLoopFaults<T>(Task<T> task) where T : IAsyncResponsePayload
 80    {
 81        try
 82        {
 44583            return await task.ConfigureAwait(false);
 84        }
 85        catch (NatsConsumeLoopException loopFault)
 86        {
 287            System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(loopFault.InnerException!).Throw();
 088            throw; // unreachable
 89        }
 38990    }
 91
 92    private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>(
 93        string correlationId,
 94        ReflectionCallDto? resumeCallback,
 95        ReflectionCallDto? failureCallback,
 96        Func<T, ValueTask<bool>>? completionPredicate,
 97        TimeSpan? timeout) where T : IAsyncResponsePayload
 98    {
 46199        CorrelationIdGuard.ThrowIfUnusable(correlationId);
 100
 101        // Recovery callbacks only make sense if the payload can say whether a late response should
 102        // resume or fail the flow. On this durable channel that decision is real (it survives a
 103        // redeploy), so require the override rather than letting the conservative default silently
 104        // route every recovered response to the failure callback.
 455105        if ((resumeCallback is not null || failureCallback is not null)
 455106            && !AsyncResponsePayloadReflection.OverridesOnRecovery(typeof(T)))
 107        {
 2108            throw new InvalidOperationException(
 2109                $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the NATS channel " +
 2110                $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.OnRecovery)}(). " 
 2111                "Override it to declare what each response does to the flow — RecoveryAction.Resume, " +
 2112                "RecoveryAction.Fail, or RecoveryAction.KeepWaiting for non-terminal checkpoints; the durable " +
 2113                "channel needs this to route a response that arrives after the waiter was lost.");
 114        }
 115
 116        // default: first envelope completes the wait
 615117        completionPredicate ??= _ => new ValueTask<bool>(true);
 118
 119        // Default timeout aligned with the recovery-state expiry: an infinite wait is never
 120        // meaningful, because once the recovery state expires the correlation id has no recovery
 121        // anyway. Timing out routes the flow through its normal failure handling instead of
 122        // leaving it stuck forever.
 453123        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 124        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 125        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 126        // subscription and recovery state existed, leaking both — and zero used to slip through
 127        // on some channels entirely, insta-timing-out a fully registered waiter.
 453128        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 129
 451130        var storedCorrelationId = correlationId;
 131        // Capture the subscribe-time ExecutionContext so app AsyncLocals (trace, principal, logging
 132        // scope) flow into the message handler, which runs on a background consume-loop thread.
 451133        var capturedContext = ExecutionContext.Capture();
 451134        var subject = _subjects.ResponseSubject(correlationId);
 135
 451136        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 451137        activity?.SetTag("asyncresponse.channel", "nats");
 451138        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 451139        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 140
 451141        if (_logger.IsEnabled(LogLevel.Debug))
 86142            _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correlatio
 143
 451144        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
 451145        var registrationId = Guid.NewGuid();
 146
 147        // Single-use cancellation token implementing the timeout. Armed only after subscribe + recovery
 148        // save succeed, but its callback is registered first so a very fast terminal message cleans up safely.
 149        // Clock-injected (DbChannelShared parity): CancelAfter on a default CTS is bound to the
 150        // system clock, so a virtual clock could never fire a production-sized waiter timeout.
 451151        var cancellationTokenSource = new CancellationTokenSource(Timeout.InfiniteTimeSpan, _timeProvider);
 451152        CancellationTokenRegistration timeoutRegistration = default;
 451153        INatsChannelSubscription? subscription = null;
 154
 155        // -------------------------------------------------------------------------
 156        // Local: CleanupOnceAsync — ends the stream (which ends the consume loop), deletes
 157        // recovery state, and tears down the timeout, exactly once.
 451158        int cleanupStarted = 0;
 451159        int teardownBudgetSpent = 0;
 451160        var subscriptionTornDown = false;
 451161        var cleanupGate = new object();
 451162        Task? cleanupTask = null;
 451163        var streamEndGate = new object();
 451164        Task? streamEndTask = null;
 451165        var consumeLoop = Task.CompletedTask;
 166
 167        // The ONE place the server-side subscription is disposed — the drain and the latched
 168        // cleanup both need the stream ended (whichever runs first), and having each dispose it
 169        // independently doubled the teardown for no benefit. TASK-latched and NEVER-faulting:
 170        // its failure is logged here exactly once, no matter how many latched callers observe
 171        // the task — and a caller that abandoned its bounded wait still gets the late outcome
 172        // recorded instead of it dying as a TaskScheduler.UnobservedTaskException. Callers read
 173        // "completed with subscriptionTornDown false" as teardown failure and backstop-cancel
 174        // (the cleanup core's finally, safe only after the timeout registration is gone).
 175        Task EndStreamOnce()
 176        {
 451177            lock (streamEndGate)
 178            {
 451179                return streamEndTask ??= EndStreamCoreAsync();
 180            }
 451181        }
 182
 183        async Task EndStreamCoreAsync()
 184        {
 451185            if (subscription is null)
 0186                return;
 187
 188            try
 189            {
 451190                await subscription.DisposeAsync().ConfigureAwait(false);
 445191                subscriptionTornDown = true;
 445192                _logger.LogDebug("Unsubscribed from subject {Subject}.", subject);
 445193            }
 6194            catch (Exception teardownEx)
 195            {
 6196                _logger.LogError(teardownEx, "Error during cleanup for subject {Subject}.", subject);
 6197            }
 451198        }
 199
 200        // Task-latched so EVERY caller completes only when the one real cleanup has finished —
 201        // the previous fire-once int latch let a second caller (a disposing waiter racing the
 202        // timeout) return before the task was settled. The core itself never waits on the consume
 203        // loop: draining happens BEFORE the latch (DrainThenCleanupAsync), because the loop's own
 204        // finally also enters this latch — a join inside the core would make the loop await a core
 205        // that is joining the loop.
 206        ValueTask CleanupOnceAsync()
 207        {
 208            Task task;
 892209            lock (cleanupGate)
 210            {
 892211                task = cleanupTask ??= CleanupCoreAsync();
 892212            }
 213
 892214            return task.IsCompletedSuccessfully ? ValueTask.CompletedTask : new ValueTask(task);
 215        }
 216
 217        // Dispose-path cleanup: DRAINS the in-flight delivery before settling. The consume loop
 218        // may be mid Until-predicate holding a claimed terminal message; ending the stream and
 219        // joining the loop guarantees that by the time the latched core cancels, the task is
 220        // either settled by that delivery or genuinely undelivered. Never called from the loop
 221        // itself — loop-invoked cleanup uses CleanupOnceAsync directly, its task already settled
 222        // by the terminal dispatch.
 223        //
 224        // One DisposalDrainTimeout budget covers BOTH steps — a wedged client library can hang
 225        // the subscription dispose just as a wedged Until predicate can hang the loop join. The
 226        // core's cancel is only truthful once the JOIN below has proven the loop ended; any
 227        // drain outcome short of that — budget lapse, anything unforeseen — leaves a delivery
 228        // possibly mid-predicate holding a message already consumed from the stream, and
 229        // "canceled" would tell a re-attaching caller nothing was delivered. Those paths fault
 230        // the task with the explicit indeterminate contract instead (routing durable flows to a
 231        // fresh idempotent restart) and cancel the subscription token so the loop still ends
 232        // once the predicate returns. (A teardown FAILURE no longer throws — the latched
 233        // teardown logs it and leaves subscriptionTornDown false — so it backstop-cancels and
 234        // still proves settlement through the join.)
 235        async ValueTask DrainThenCleanupAsync(Exception? terminalIfUndelivered = null)
 236        {
 457237            if (Volatile.Read(ref cleanupStarted) == 0)
 238            {
 38239                var drainTimeout = _options.DisposalDrainTimeout;
 240                // ONE budget for the whole disposal: the latched cleanup below skips its own
 241                // teardown wait when a drain already spent this budget on the same latched
 242                // teardown task — a second full wait there made disposal cost double the
 243                // configured DisposalDrainTimeout.
 38244                Volatile.Write(ref teardownBudgetSpent, 1);
 245                try
 246                {
 38247                    using var budget = new CancellationTokenSource(drainTimeout);
 38248                    await EndStreamOnce().WaitAsync(budget.Token).ConfigureAwait(false);
 249
 250                    // A failed teardown surfaces as "completed, subscriptionTornDown false" (the
 251                    // latched core logged it): backstop-cancel so the loop still ends, then FALL
 252                    // THROUGH to the join — a failed teardown proves nothing about a delivery
 253                    // mid-predicate, and skipping the join here let cleanup cancel a response the
 254                    // stream had already handed over.
 36255                    if (!subscriptionTornDown && subscription is not null)
 4256                        await DisarmThenCancelSubscriptionTokenAsync().ConfigureAwait(false);
 257
 258                    // Settlement is PROVEN only by the loop having ended within the remaining
 259                    // budget — either it settled the task with the in-flight delivery, or it
 260                    // ended with nothing in flight and the cleanup's cancel below is truthful.
 36261                    await consumeLoop.WaitAsync(budget.Token).ConfigureAwait(false);
 31262                }
 7263                catch (Exception drainEx)
 264                {
 7265                    _logger.LogWarning(
 7266                        "Disposal drain for correlationId {CorrelationId} did not prove settlement within {DrainTimeout}
 7267                        correlationId, drainTimeout);
 7268                    AsyncResponseDiagnostics.SetError(activity, "indeterminate_delivery", "Disposal drain did not prove 
 269                    // A TrySetResult from the late-finishing delivery loses against this and is
 270                    // dropped; the loop's own cleanup call is a no-op behind the latch. The
 271                    // non-cancellation exception case is unforeseen infrastructure failure —
 272                    // settlement is equally unproven there, so it must not fall back to cancel.
 7273                    if (drainEx is not OperationCanceledException)
 0274                        _logger.LogDebug(drainEx, "Disposal drain failed for subject {Subject}.", subject);
 7275                    tcs.TrySetException(new AsyncResponseIndeterminateDeliveryException(correlationId, drainTimeout));
 7276                    await DisarmThenCancelSubscriptionTokenAsync().ConfigureAwait(false);
 277                }
 278            }
 279
 280            // Settle AFTER the drain, never before it. The consume loop may already hold a message
 281            // the subscription received — the publisher was told "delivered", so it exists nowhere
 282            // else. Faulting first let a timeout beat that in-flight delivery and report a consumed
 283            // response as a timeout; TrySet loses here if the delivery won, which is the whole
 284            // point. (A lapsed drain budget has already faulted the task as indeterminate above,
 285            // and TrySet is a no-op behind it.)
 457286            if (terminalIfUndelivered is not null)
 8287                tcs.TrySetException(terminalIfUndelivered);
 288
 457289            await CleanupOnceAsync().ConfigureAwait(false);
 290        }
 291
 292        async ValueTask DisarmThenCancelSubscriptionTokenAsync()
 293        {
 294            // Disarm the waiter-timeout registration BEFORE the backstop cancel — the cancel
 295            // would otherwise fire it and stamp a spurious TimeoutException plus a waiter-timeout
 296            // metric onto a disposal that is not a timeout. Idempotent with the cleanup core's
 297            // own registration disposal.
 11298            await timeoutRegistration.DisposeAsync().ConfigureAwait(false);
 299            try
 300            {
 11301                cancellationTokenSource.Cancel();
 11302            }
 0303            catch (ObjectDisposedException)
 304            {
 305                // Cleanup already ran and disposed the source; the loop is ending regardless.
 0306            }
 11307        }
 308
 309        async Task CleanupCoreAsync()
 310        {
 451311            Interlocked.Exchange(ref cleanupStarted, 1);
 312
 313            try
 314            {
 315                try
 316                {
 317                    // Delete the recovery state BEFORE disposing the subscription. In the reverse
 318                    // order a publish landing in the window sees "no responders, state present" and
 319                    // fires a spurious recovery callback for a wait that already reached a terminal
 320                    // state. In this order the window shows a subscriber that drops the message — a
 321                    // late or duplicate terminal message is droppable; a resurrected recovery callback
 322                    // is not.
 451323                    await _recoveryStateStore.TryDeleteAsync(correlationId, registrationId).ConfigureAwait(false);
 447324                }
 4325                catch (Exception ex)
 326                {
 327                    // Best-effort: the KV entry expires on its own, and a transient store failure
 328                    // must not skip the subscription teardown below.
 4329                    _logger.LogError(ex, "Failed to delete recovery state for correlationId {CorrelationId}.", correlati
 4330                }
 331
 332                // End the stream if the drain has not already — dispatch-triggered cleanup (a
 333                // terminal delivery, a loop fault) reaches here without a drain. The task-latch
 334                // keeps the teardown single no matter which path got here first. Bounded like the
 335                // drain: this latched core is what a disposing waiter awaits when terminal
 336                // delivery started cleanup first (the drain skips itself on cleanupStarted), so
 337                // an unbudgeted teardown here let a wedged client library hold DisposeAsync
 338                // hostage past DisposalDrainTimeout. On the bound lapsing, the catch below logs
 339                // and the finally's backstop cancel still ends the consume loop; the abandoned
 340                // teardown task never faults (it logs its own late outcome). When a DRAIN
 341                // preceded this core, it already spent that budget on this same latched task —
 342                // waiting a second one here made disposal cost double the configured bound, so
 343                // the wait is skipped (the teardown is running and self-logging regardless).
 451344                if (Volatile.Read(ref teardownBudgetSpent) == 0)
 413345                    await EndStreamOnce().WaitAsync(_options.DisposalDrainTimeout).ConfigureAwait(false);
 449346                if (subscription is null)
 0347                    subscriptionTornDown = true;
 449348            }
 2349            catch (Exception ex)
 350            {
 2351                _logger.LogError(ex, "Error during cleanup for subject {Subject}.", subject);
 2352            }
 353            finally
 354            {
 451355                await timeoutRegistration.DisposeAsync().ConfigureAwait(false);
 451356                if (!subscriptionTornDown)
 357                {
 358                    // DisposeAsync did not complete, so the server-side subscription may still be
 359                    // pumping messages. Its lifetime is bound to this token (SubscribeAsync received
 360                    // it), and disposing a CTS never cancels — an explicit cancel is the backstop
 361                    // that ends the consume loop. Safe only after the timeout registration above is
 362                    // gone, or the cancel would fire a spurious waiter timeout.
 8363                    cancellationTokenSource.Cancel();
 364                }
 365
 366                // A waiter disposed before any terminal signal must not leave ResponseTask pending
 367                // forever for callers that hold it directly — the timeout died above, so nothing
 368                // else could ever complete the task. A no-op after a normal completion, timeout,
 369                // fault, or a delivery drained by DrainThenCleanupAsync.
 451370                tcs.TrySetCanceled();
 371
 451372                cancellationTokenSource.Dispose();
 451373                activity?.Dispose();
 374            }
 375        }
 376
 377        // -------------------------------------------------------------------------
 378        // Local: ProcessResponseAsync — deserializes and handles a single envelope, completes the TCS when terminal.
 379        async Task ProcessResponseAsync(string? payload)
 380        {
 548381            bool finished = false;
 382            try
 383            {
 548384                if (string.IsNullOrEmpty(payload))
 385                {
 386                    // A non-probe message with no body cannot be a response; ignore it rather than fault.
 2387                    _logger.LogWarning("Received empty response message for correlationId {CorrelationId}; ignoring.", c
 2388                    return;
 389                }
 390
 391                // JsonSafety, not the raw reader: a parse failure is logged below and handed to the
 392                // waiter, and the reader's own message quotes inbound property names and dictionary
 393                // keys (docs/security.md, "never logs a message body"). Size and position only.
 546394                var envelope = JsonSafety.SafeDeserialize(payload, AsyncResponseEnvelopeJson.TypeInfo<T>());
 395
 538396                if (envelope == null)
 397                {
 6398                    _logger.LogError("Failed to deserialize envelope for correlationId {CorrelationId}.", correlationId)
 6399                    finished = true;
 6400                    var deserializationError = new JsonException($"Failed to deserialize envelope for correlationId {cor
 6401                    AsyncResponseDiagnostics.SetError(activity, "deserialize_failure", deserializationError.Message);
 6402                    if (!tcs.TrySetException(deserializationError))
 2403                        _logger.LogWarning(deserializationError, "TaskCompletionSource already completed for correlation
 404                }
 532405                else if (!AsyncResponseEnvelopeSchema.IsReadable(envelope.SchemaVersion))
 406                {
 6407                    finished = true;
 6408                    var schemaError = new InvalidOperationException(
 6409                        $"Response envelope for correlationId {correlationId} has schema version {envelope.SchemaVersion
 6410                        $"which this build does not support (current: {AsyncResponseEnvelopeSchema.Current}).");
 6411                    AsyncResponseDiagnostics.SetError(activity, "schema_mismatch", schemaError.Message);
 6412                    if (!tcs.TrySetException(schemaError))
 2413                        _logger.LogWarning(schemaError, "TaskCompletionSource already completed for correlationId {Corre
 414                }
 526415                else if (!envelope.Success)
 416                {
 9417                    finished = true;
 9418                    var remoteFailure = new Exception(envelope.ExceptionMessage ?? "Unknown error during asynchronous pr
 9419                    if (!string.IsNullOrEmpty(envelope.ExceptionStackTrace))
 420                        // Cap on receive too: the publish-side cap only bounds traces we emit, not what
 421                        // a remote we do not control can push at us.
 2422                        remoteFailure.Data["RemoteStackTrace"] = RemoteStackTrace.Cap(envelope.ExceptionStackTrace, _opt
 423
 9424                    _logger.LogWarning("Received error response for correlationId {CorrelationId}: {ErrorMessage}", corr
 9425                    AsyncResponseDiagnostics.SetError(activity, "remote_failure", remoteFailure.Message);
 9426                    if (!tcs.TrySetException(remoteFailure))
 2427                        _logger.LogWarning(remoteFailure, "TaskCompletionSource already completed for correlationId {Cor
 428                }
 429                else
 430                {
 517431                    if (_logger.IsEnabled(LogLevel.Debug))
 42432                        _logger.LogDebug("Received response for correlationId {CorrelationId}.", correlationId);
 517433                    finished = await completionPredicate(envelope.Payload!).ConfigureAwait(false);
 515434                    if (finished && !tcs.TrySetResult(envelope.Payload!))
 7435                        _logger.LogWarning("TaskCompletionSource already completed for correlationId {CorrelationId}.", 
 436                }
 536437            }
 10438            catch (Exception ex)
 439            {
 10440                _logger.LogError(ex, "Error processing message on subject {Subject} for correlationId {CorrelationId}.",
 10441                finished = true;
 10442                AsyncResponseDiagnostics.SetError(activity, ex);
 10443                if (!tcs.TrySetException(ex))
 2444                    _logger.LogWarning(ex, "TaskCompletionSource already completed for correlationId {CorrelationId}.", 
 10445            }
 446            finally
 447            {
 548448                if (finished)
 427449                    await CleanupOnceAsync().ConfigureAwait(false);
 450            }
 451        }
 452
 453        // -------------------------------------------------------------------------
 454        // Local: ProcessUnderCapturedContextAsync — restores the waiter's subscribe-time
 455        // ExecutionContext (app AsyncLocals) plus the correlation id before processing, since the
 456        // consume loop runs on a background thread that never had them.
 457        Task ProcessUnderCapturedContextAsync(string? payload)
 458        {
 459            async Task Process()
 460            {
 548461                using var correlationScope = AsyncResponseContext.PushCorrelationId(storedCorrelationId);
 548462                await ProcessResponseAsync(payload).ConfigureAwait(false);
 548463            }
 464
 548465            if (capturedContext is null)
 2466                return Process();
 467
 546468            Task? task = null;
 1092469            ExecutionContext.Run(capturedContext, _ => task = Process(), null);
 546470            return task!;
 471        }
 472
 473        // -------------------------------------------------------------------------
 474        // Local: ConsumeLoopAsync — reads messages serially from the subscription until it is disposed.
 475        async Task ConsumeLoopAsync(INatsChannelSubscription sub)
 476        {
 477            try
 478            {
 2008479                await foreach (var message in sub.ReadAsync(CancellationToken.None).ConfigureAwait(false))
 480                {
 481                    // Ack first so the publisher's request resolves quickly (delivery/liveness confirmed)
 482                    // even if processing the payload is slow. A failed ack must not abort the wait.
 483                    try
 484                    {
 553485                        await message.ReplyAsync().ConfigureAwait(false);
 551486                    }
 2487                    catch (Exception replyEx)
 488                    {
 2489                        _logger.LogDebug(replyEx, "Failed to acknowledge response on subject {Subject}.", subject);
 2490                    }
 491
 553492                    if (message.IsProbe)
 493                        continue;
 494
 548495                    await ProcessUnderCapturedContextAsync(message.Payload).ConfigureAwait(false);
 548496                }
 441497            }
 8498            catch (Exception ex)
 499            {
 8500                _logger.LogError(ex, "Response subscription loop failed for subject {Subject}.", subject);
 8501                AsyncResponseDiagnostics.SetError(activity, ex);
 502                // The settlement itself carries its source: a loop death is a TRANSPORT failure,
 503                // not a delivered response, and the registration path must tell the two kinds of
 504                // faulted task apart ATOMICALLY — a side-band flag raced the settlement in both
 505                // directions (set-then-lose aborted a registration whose response had already
 506                // been delivered; set-after-win left a window that returned a dead waiter). Only
 507                // a loop fault that actually WINS the settlement marks the task; a fault that
 508                // loses to a terminal payload changes nothing. The wrapper never escapes: the
 509                // public ResponseTask unwraps it back to the original exception.
 8510                if (!tcs.TrySetException(new NatsConsumeLoopException(ex)))
 4511                    _logger.LogWarning(ex, "TaskCompletionSource already completed for correlationId {CorrelationId}.", 
 8512                await CleanupOnceAsync().ConfigureAwait(false);
 513            }
 514        }
 515
 451516        timeoutRegistration = cancellationTokenSource.Token.Register(() =>
 451517        {
 10518            _ = Task.Run(async () =>
 10519            {
 10520                try
 10521                {
 10522                    _logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", correlationI
 8523                    AsyncResponseDiagnostics.SetError(activity, "timeout", $"Timed out waiting for response for correlat
 8524                    AsyncResponseDiagnostics.RecordWaiterTimeout("nats");
 8525                    await DrainThenCleanupAsync(
 8526                        new TimeoutException($"Timed out waiting for response for correlationId {correlationId}."))
 8527                        .ConfigureAwait(false);
 8528                }
 2529                catch (Exception ex)
 10530                {
 10531                    // Fire-and-forget: nothing awaits this task, so an escaped fault would vanish.
 2532                    _logger.LogError(ex, "Error handling waiter timeout for correlationId {CorrelationId}.", correlation
 2533                }
 20534            });
 461535        });
 536
 537        try
 538        {
 451539            subscription = await _client.SubscribeAsync(subject, cancellationTokenSource.Token).ConfigureAwait(false);
 902540            consumeLoop = Task.Run(() => ConsumeLoopAsync(subscription));
 541
 542            // Round-trip to the server so the subscription is guaranteed registered BEFORE the
 543            // recovery state is saved (and before the caller's trigger publishes the remote
 544            // request — closing the subscribe/trigger race). The order is the DB channels'
 545            // invariant: "recovery state visible ⇒ subscription visible". Saved first, a publish
 546            // landing in the window found the registration, probed the not-yet-visible
 547            // subscription, and consumed a live waiter's recovery arm — the waiter then resumed
 548            // twice (recovery callback now, live delivery to its timeout). Skipped once cleanup
 549            // started: the wait already settled terminally, so there is no trigger race left to
 550            // close — and cleanup's teardown disposes the lifetime source this flush reads its
 551            // token from, so attempting it would throw for nothing.
 451552            if (Volatile.Read(ref cleanupStarted) == 0)
 451553                await _client.FlushAsync(cancellationTokenSource.Token).ConfigureAwait(false);
 554
 451555            var recoveryState = new RecoveryState
 451556            {
 451557                RegistrationId = registrationId,
 451558                ResumeCallback = resumeCallback,
 451559                FailureCallback = failureCallback,
 451560                CorrelationId = correlationId,
 451561                PayloadTypeFullName = typeof(T).FullName,
 451562                // The engine's clock, not the ambient one. The watchdog judges staleness as
 451563                // "utcNow - RegisteredAtUtc" from whichever host scans, so an unsubstitutable
 451564                // app-clock stamp made a skewed host's registrations either never age (skew ahead:
 451565                // a genuinely stuck flow stays invisible and the health check stays green) or age
 451566                // instantly (skew behind: healthy waits page the operator every scan). The DB
 451567                // channels stamp the SERVER clock for exactly this reason; this at least puts the
 451568                // stamp and the watchdog's "now" on one substitutable clock, and matches the
 451569                // ExpiresAtUtc the recovery store writes for the same registration.
 451570                RegisteredAtUtc = _timeProvider.GetUtcNow().UtcDateTime,
 451571                Context = _propagation.Capture()
 451572            };
 451573            await _recoveryStateStore.SaveAsync(correlationId, recoveryState, _options.RecoveryStateExpiry).ConfigureAwa
 445574            if (Volatile.Read(ref cleanupStarted) != 0)
 575            {
 576                // A terminal delivery on the already-running consume loop started cleanup while
 577                // this registration was still being written: cleanup's delete ran before the save
 578                // committed, so the save just orphaned a callback-armed registration that would
 579                // resurrect recovery for a wait that already reached a terminal state. Compensate
 580                // with a second delete (mirrors the in-memory channel's post-save check).
 581                // Best-effort: TTL and the watchdog back a failed delete.
 582                try
 583                {
 6584                    await _recoveryStateStore.TryDeleteAsync(correlationId, registrationId).ConfigureAwait(false);
 6585                }
 0586                catch (Exception ex)
 587                {
 0588                    _logger.LogError(ex, "Post-save recovery-state compensation delete failed for correlationId {Correla
 0589                }
 590            }
 591
 445592            _logger.LogDebug("Subscribed to subject {Subject} for correlationId {CorrelationId}.", subject, correlationI
 445593        }
 6594        catch (Exception ex) when (tcs.Task.IsCompletedSuccessfully
 6595                                   || (tcs.Task.IsFaulted && tcs.Task.Exception!.InnerException is not NatsConsumeLoopEx
 596        {
 597            // The wait already settled: a delivery on the consume loop completed the waiter while
 598            // this registration step was still in flight (cleanup marks cleanupStarted just after
 599            // setting the task, so the task is the race-free signal), and the step then failed
 600            // against the torn-down registration state (a failed save, a flush aborted by the
 601            // disposed lifetime source). The response in hand outranks the builder's
 602            // "throw so the trigger never fires" contract — rethrowing would discard a delivered
 603            // response, the exact loss this library exists to prevent, and the success path for
 604            // this same interleaving already returns the completed waiter. Cleanup runs on the
 605            // delivery path, so nothing is leaked; a save that still committed is compensated
 606            // above or expires via TTL, with the recovery watchdog behind it. The filter demands
 607            // an actual settlement (result or fault): a canceled task means NO response was
 608            // delivered — e.g. a future channel-wide teardown canceling in-flight registrations —
 609            // and takes the rethrow path below.
 2610            _logger.LogWarning(ex,
 2611                "Registration step failed after a delivery settled correlationId {CorrelationId}; returning the complete
 2612                correlationId);
 2613        }
 4614        catch (Exception ex)
 615        {
 4616            _logger.LogError(ex, "Failed to subscribe to subject {Subject} for correlationId {CorrelationId}.", subject,
 4617            AsyncResponseDiagnostics.SetError(activity, "subscribe_failure", ex.Message);
 4618            await DrainThenCleanupAsync().ConfigureAwait(false);
 619
 620            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 621            // the trigger runs only once the subscription AND recovery state exist. A returned
 622            // waiter would still let the trigger fire the remote operation with no registration
 623            // left to receive (or recover) its response. Cleanup leaves nothing behind and cancels
 624            // the response task rather than faulting it, so no unobserved fault lingers.
 4625            throw;
 626        }
 627
 447628        if (tcs.Task.IsFaulted && tcs.Task.Exception!.InnerException is NatsConsumeLoopException loopFault)
 629        {
 630            // The consume loop died AND won the settlement while this registration was in
 631            // flight: the fault is a TRANSPORT error, not a delivered response; no subscription
 632            // is live; and the loop's cleanup already ran (deleting any saved recovery state,
 633            // backed by the post-save compensation). Returning the waiter would let the builder
 634            // fire the trigger with nothing registered to receive — or recover — its response,
 635            // so the builder contract applies: throw, and the remote operation never starts. A
 636            // loop fault that LOST the settlement leaves no mark, so a wait a terminal payload
 637            // already settled is returned normally.
 2638            throw new InvalidOperationException(
 2639                $"The NATS response subscription for correlationId {correlationId} failed before registration completed.
 2640                loopFault.InnerException);
 641        }
 642
 643        try
 644        {
 445645            if (Volatile.Read(ref cleanupStarted) == 0)
 439646                cancellationTokenSource.CancelAfter(timeout.Value);
 445647        }
 0648        catch (ObjectDisposedException)
 649        {
 650            // A response completed and cleaned up between the check and CancelAfter.
 0651        }
 652
 890653        return new NatsAsyncResponseWaiter<T>(UnwrapConsumeLoopFaults(tcs.Task), () => DrainThenCleanupAsync());
 2350654    }
 655
 656    // ---------------------------------------------------------------------------------------
 657    // IAsyncResponsePublisher
 658
 659    /// <inheritdoc/>
 660    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 502661        => SetResponseCore(response, correlationId, cancellationToken);
 662
 663    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 2664        => SetResponseCore(response, correlationId, cancellationToken);
 665
 666    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 38667        => SetRawResponseJsonCore(responseJson, correlationId, cancellationToken);
 668
 669    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 670    {
 504671        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 504672        activity?.SetTag("asyncresponse.channel", "nats");
 504673        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 674
 504675        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 676
 504677        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the response"))
 3678            return;
 679
 498680        var subject = _subjects.ResponseSubject(correlationId);
 681        try
 682        {
 498683            var envelope = new AsyncResponseEnvelope<T> { Success = true, Payload = response };
 498684            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 498685            var outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeout, 
 496686            activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 687
 496688            if (outcome == NatsDeliveryOutcome.NoResponders)
 689            {
 690                // Nobody was listening (the waiter died, e.g. with a redeploy): hand the response over
 691                // to the lost-subscriber dispatcher, which asks the payload whether to resume or fail.
 13692                var dispatchResult = await _lostSubscriberDispatcher
 13693                    .DispatchLostResponses(
 13694                        _recoveryStateStore,
 13695                        correlationId,
 13696                        response,
 13697                        subject,
 13698                        cancellationToken,
 13699                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 13700                    .ConfigureAwait(false);
 13701                if (dispatchResult.RetryLive)
 702                {
 703                    // A waiter subscribed between the request and the recovery-state read —
 704                    // re-attempt the live publish instead of consuming its registration; only a
 705                    // second no-responders consumes it.
 6706                    outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeo
 6707                    activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 6708                    if (outcome != NatsDeliveryOutcome.NoResponders)
 2709                        return;
 710
 4711                    dispatchResult = await _lostSubscriberDispatcher
 4712                        .DispatchLostResponses(
 4713                            _recoveryStateStore,
 4714                            correlationId,
 4715                            response,
 4716                            subject,
 4717                            cancellationToken,
 4718                            hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 4719                        .ConfigureAwait(false);
 4720                    if (dispatchResult.RetryLive)
 721                    {
 722                        // Second contradiction: delivery keeps reporting no responders while the
 723                        // probe keeps reporting a live subscriber (interest not yet visible
 724                        // server-side, or a stale heartbeat). Consuming registrations on this
 725                        // evidence would strip a live waiter of its recovery arm — leave all state
 726                        // intact and surface the non-delivery to the caller, whose retry/redelivery
 727                        // machinery re-attempts once the subscription is visible (bounded by the
 728                        // heartbeat's liveness expiry, after which normal recovery takes over).
 729                        // Returning here instead would silently drop the payload: the caller
 730                        // reports success, the broker message is acked, and the response then
 731                        // exists nowhere.
 2732                        _logger.LogWarning(
 2733                            "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr
 2734                            correlationId);
 2735                        activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 2736                        throw new InvalidOperationException(
 2737                            $"NATS delivery for correlationId '{correlationId}' found no responders twice while the live
 2738                            "reporting a live subscriber; the payload was not delivered and recovery registrations were 
 2739                            "the publish once the waiter's subscription is visible to the publishing endpoint.");
 740                    }
 741                }
 742
 9743                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMix
 9744                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.Callback
 9745                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 746            }
 483747            else if (_logger.IsEnabled(LogLevel.Debug))
 748            {
 16749                _logger.LogDebug("Published response for correlationId {CorrelationId} on subject {Subject}. PayloadType
 750            }
 492751        }
 4752        catch (Exception ex)
 753        {
 4754            _logger.LogError(ex, "Failed to publish response for correlationId {CorrelationId} on subject {Subject}.", c
 4755            AsyncResponseDiagnostics.SetError(activity, ex);
 4756            throw;
 757        }
 497758    }
 759
 760    private async Task SetRawResponseJsonCore(string responseJson, string correlationId, CancellationToken cancellationT
 761    {
 38762        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 38763        activity?.SetTag("asyncresponse.channel", "nats");
 764
 38765        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 766
 38767        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the raw response", dropContractViolati
 4768            return;
 769
 34770        var subject = _subjects.ResponseSubject(correlationId);
 771        try
 772        {
 34773            var json = SerializeRawSuccessEnvelope(responseJson);
 34774            var outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeout, 
 32775            activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 776
 32777            if (outcome == NatsDeliveryOutcome.NoResponders)
 778            {
 23779                var response = new RawJsonResponse(responseJson).DeserializeUntyped();
 780
 23781                var dispatchResult = await _lostSubscriberDispatcher
 23782                    .DispatchLostResponses(
 23783                        _recoveryStateStore,
 23784                        correlationId,
 23785                        response,
 23786                        subject,
 23787                        cancellationToken,
 23788                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 23789                    .ConfigureAwait(false);
 21790                if (dispatchResult.RetryLive)
 791                {
 792                    // A waiter subscribed between the request and the recovery-state read —
 793                    // re-attempt the live publish instead of consuming its registration; only a
 794                    // second no-responders consumes it.
 6795                    outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeo
 6796                    activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 6797                    if (outcome != NatsDeliveryOutcome.NoResponders)
 2798                        return;
 799
 4800                    dispatchResult = await _lostSubscriberDispatcher
 4801                        .DispatchLostResponses(
 4802                            _recoveryStateStore,
 4803                            correlationId,
 4804                            response,
 4805                            subject,
 4806                            cancellationToken,
 4807                            hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 4808                        .ConfigureAwait(false);
 4809                    if (dispatchResult.RetryLive)
 810                    {
 811                        // Second contradiction: delivery keeps reporting no responders while the
 812                        // probe keeps reporting a live subscriber (interest not yet visible
 813                        // server-side, or a stale heartbeat). Consuming registrations on this
 814                        // evidence would strip a live waiter of its recovery arm — leave all state
 815                        // intact and surface the non-delivery to the caller, whose retry/redelivery
 816                        // machinery re-attempts once the subscription is visible (bounded by the
 817                        // heartbeat's liveness expiry, after which normal recovery takes over).
 818                        // Returning here instead would silently drop the payload: the caller
 819                        // reports success, the broker message is acked, and the response then
 820                        // exists nowhere.
 2821                        _logger.LogWarning(
 2822                            "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr
 2823                            correlationId);
 2824                        activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 2825                        throw new InvalidOperationException(
 2826                            $"NATS delivery for correlationId '{correlationId}' found no responders twice while the live
 2827                            "reporting a live subscriber; the payload was not delivered and recovery registrations were 
 2828                            "the publish once the waiter's subscription is visible to the publishing endpoint.");
 829                    }
 830                }
 831
 17832                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMix
 17833                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.Callback
 17834                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 17835            }
 9836            else if (_logger.IsEnabled(LogLevel.Debug))
 837            {
 4838                _logger.LogDebug("Published raw response for correlationId {CorrelationId} on subject {Subject}. Outcome
 839            }
 26840        }
 6841        catch (Exception ex)
 842        {
 6843            _logger.LogError(ex, "Failed to publish raw response for correlationId {CorrelationId} on subject {Subject}.
 6844            AsyncResponseDiagnostics.SetError(activity, ex);
 6845            throw;
 846        }
 32847    }
 848
 849    /// <inheritdoc/>
 850    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 851    {
 24852        ArgumentNullException.ThrowIfNull(exception);
 853
 24854        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 24855        activity?.SetTag("asyncresponse.channel", "nats");
 24856        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 857
 24858        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 859
 24860        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the exception", exception))
 3861            return;
 862
 20863        var subject = _subjects.ResponseSubject(correlationId);
 864        try
 865        {
 20866            var envelope = new AsyncResponseEnvelope<object>
 20867            {
 20868                Success = false,
 20869                ExceptionMessage = exception.Message,
 20870                ExceptionStackTrace = RemoteStackTrace.ForWire(exception.StackTrace, _options.IncludeRemoteStackTrace, _
 20871                Payload = null
 20872            };
 20873            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 20874            var outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeout, 
 18875            activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 876
 18877            if (outcome == NatsDeliveryOutcome.NoResponders)
 878            {
 879                // Nobody was listening: exception envelopes always go to the failure callback.
 11880                var dispatchResult = await _lostSubscriberDispatcher
 11881                    .DispatchLostExceptions(
 11882                        _recoveryStateStore,
 11883                        correlationId,
 11884                        exception,
 11885                        subject,
 11886                        cancellationToken,
 11887                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 11888                    .ConfigureAwait(false);
 11889                if (dispatchResult.RetryLive)
 890                {
 891                    // A waiter subscribed between the request and the recovery-state read —
 892                    // re-attempt the live publish instead of consuming its registration; only a
 893                    // second no-responders consumes it.
 6894                    outcome = await _client.RequestAsync(subject, json, probe: false, _options.DeliveryConfirmationTimeo
 6895                    activity?.SetTag("asyncresponse.delivery", outcome.ToString());
 6896                    if (outcome != NatsDeliveryOutcome.NoResponders)
 2897                        return;
 898
 4899                    dispatchResult = await _lostSubscriberDispatcher
 4900                        .DispatchLostExceptions(
 4901                            _recoveryStateStore,
 4902                            correlationId,
 4903                            exception,
 4904                            subject,
 4905                            cancellationToken,
 4906                            hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 4907                        .ConfigureAwait(false);
 4908                    if (dispatchResult.RetryLive)
 909                    {
 910                        // Second contradiction: delivery keeps reporting no responders while the
 911                        // probe keeps reporting a live subscriber (interest not yet visible
 912                        // server-side, or a stale heartbeat). Consuming registrations on this
 913                        // evidence would strip a live waiter of its recovery arm — leave all state
 914                        // intact and surface the non-delivery to the caller, whose retry/redelivery
 915                        // machinery re-attempts once the subscription is visible (bounded by the
 916                        // heartbeat's liveness expiry, after which normal recovery takes over).
 917                        // Returning here instead would silently drop the payload: the caller
 918                        // reports success, the broker message is acked, and the response then
 919                        // exists nowhere.
 2920                        _logger.LogWarning(
 2921                            "Delivery for correlationId {CorrelationId} found no subscribers twice while the liveness pr
 2922                            correlationId);
 2923                        activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 2924                        throw new InvalidOperationException(
 2925                            $"NATS delivery for correlationId '{correlationId}' found no responders twice while the live
 2926                            "reporting a live subscriber; the payload was not delivered and recovery registrations were 
 2927                            "the publish once the waiter's subscription is visible to the publishing endpoint.");
 928                    }
 929                }
 930
 7931                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 7932                AsyncResponseDiagnostics.RecordLostSubscriber("exception", action: RecoveryAction.Fail, dispatchResult.C
 933            }
 7934            else if (_logger.IsEnabled(LogLevel.Debug))
 935            {
 6936                _logger.LogDebug("Published exception response for correlationId {CorrelationId} on subject {Subject}. O
 937            }
 14938        }
 4939        catch (Exception ex)
 940        {
 4941            _logger.LogError(ex, "Failed to publish exception response for correlationId {CorrelationId} on subject {Sub
 4942            AsyncResponseDiagnostics.SetError(activity, ex);
 4943            throw;
 944        }
 19945    }
 946
 947    // ---------------------------------------------------------------------------------------
 948    // IActiveSubscriberProbe
 949
 950    /// <inheritdoc/>
 951    public async ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken =
 952    {
 78953        if (string.IsNullOrWhiteSpace(correlationId))
 2954            return 0L;
 955
 76956        var subject = _subjects.ResponseSubject(correlationId);
 957        try
 958        {
 959            // NATS Core does not expose exact subscriber counts to clients, so the probe reports
 960            // presence. Only NoResponders is a definitive zero — the server told us nothing is
 961            // subscribed. NoReply means the OPPOSITE: interest existed and the ping was delivered,
 962            // it just was not acked inside PresenceProbeTimeout. That is routine for a LIVE waiter,
 963            // because the consume loop acks a probe only when it reads it, serially, after the
 964            // previous message's user Until predicate returns — so any predicate slower than the
 965            // 2s default made a healthy waiter look dead, which flagged it stale in the watchdog
 966            // and (worse) let the lost-subscriber dispatcher consume its recovery registration.
 76967            var outcome = await _client.RequestAsync(subject, payload: null, probe: true, _options.PresenceProbeTimeout,
 70968            return outcome switch
 70969            {
 29970                NatsDeliveryOutcome.Replied => 1L,
 39971                NatsDeliveryOutcome.NoResponders => 0L,
 2972                _ => -1L
 70973            };
 974        }
 2975        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 976        {
 2977            throw;
 978        }
 4979        catch (Exception ex)
 980        {
 4981            _logger.LogDebug(ex, "Failed to probe active subscribers for subject {Subject}.", subject);
 982            // Negative = "could not be probed" (the watchdog's unknown-liveness contract): 0 would
 983            // assert there is definitively no live waiter and flag every over-threshold
 984            // registration stale during a transient probe outage.
 4985            return -1L;
 986        }
 76987    }
 988
 989    /// <summary>
 990    /// Re-probes waiter liveness for the lost-subscriber dispatcher's snapshot-race re-check,
 991    /// using the same presence probe the watchdog uses. An unprobeable result THROWS instead of
 992    /// reading as "no live waiter", so the failure propagates to the publisher's catch and the
 993    /// publish retries rather than consuming a live waiter's recovery registration (parity with
 994    /// the DB channels, whose re-check calls the store directly).
 995    /// </summary>
 996    private async ValueTask<bool> HasLiveSubscriberAsync(string correlationId, CancellationToken cancellationToken)
 997    {
 59998        var subscribers = await CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false);
 59999        if (subscribers < 0)
 1000        {
 21001            throw new InvalidOperationException(
 21002                $"NATS subscriber liveness for correlationId '{correlationId}' could not be probed.");
 1003        }
 1004
 571005        return subscribers > 0;
 571006    }
 1007
 1008    private static string SerializeRawSuccessEnvelope(string payloadJson)
 1009    {
 341010        JsonSafety.ThrowIfClearlyNotJson(payloadJson);
 1011
 341012        var buffer = new ArrayBufferWriter<byte>();
 341013        using (var writer = new Utf8JsonWriter(buffer))
 1014        {
 341015            writer.WriteStartObject();
 341016            writer.WriteNumber("SchemaVersion", AsyncResponseEnvelopeSchema.Current);
 341017            writer.WriteBoolean("Success", true);
 341018            writer.WritePropertyName("Payload");
 341019            writer.WriteRawValue(payloadJson);
 341020            writer.WriteNull("ExceptionMessage");
 341021            writer.WriteNull("ExceptionStackTrace");
 341022            writer.WriteEndObject();
 341023        }
 1024
 341025        return Encoding.UTF8.GetString(buffer.WrittenSpan);
 1026    }
 1027}
 1028
 1029/// <summary>
 1030/// Internal settlement marker: the consume loop faulted the wait (a transport failure — nothing
 1031/// was delivered). Never escapes the channel: registration converts a marked settlement into a
 1032/// thrown registration failure, and the public ResponseTask unwraps it to the original exception.
 1033/// </summary>
 1034internal sealed class NatsConsumeLoopException(Exception inner)
 1035    : Exception("The NATS response subscription loop failed.", inner);

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory,AsyncResponse.Channels.NATS.INatsResponseChannelClient,AsyncResponse.IRecoveryStateStore,Microsoft.Extensions.Options.IOptions`1<AsyncResponse.Channels.NATS.NatsAsyncResponseChannelOptions>,AsyncResponse.AsyncResponseContextPropagation,Microsoft.Extensions.Logging.ILogger`1<AsyncResponse.Channels.NATS.NatsAsyncResponseChannel>,System.TimeProvider)
CreateResponseWaiter(System.String,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Nullable`1<System.TimeSpan>)
CreateRecoverableResponseWaiter(System.String,AsyncResponse.ReflectionCallDto,AsyncResponse.ReflectionCallDto,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Nullable`1<System.TimeSpan>)
UnwrapConsumeLoopFaults()
CreateResponseWaiterCore()
EndStreamOnce()
EndStreamCoreAsync()
CleanupOnceAsync()
DrainThenCleanupAsync()
DisarmThenCancelSubscriptionTokenAsync()
CleanupCoreAsync()
ProcessResponseAsync()
Process()
ProcessUnderCapturedContextAsync()
ConsumeLoopAsync()
SetResponse(T,System.String,System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(System.Object,System.String,System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(System.String,System.String,System.Threading.CancellationToken)
SetResponseCore()
SetRawResponseJsonCore()
SetException()
CountActiveSubscribersAsync()
HasLiveSubscriberAsync()
SerializeRawSuccessEnvelope(System.String)