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

Information
Class: AsyncResponse.Channels.DbAsyncResponseChannelBase
Assembly: AsyncResponse.Channels.SqlServer
File(s): /_/src/Channels/Shared/DbChannelShared.cs
Line coverage
93%
Covered lines: 765
Uncovered lines: 52
Coverable lines: 817
Total lines: 2049
Line coverage: 93.6%
Branch coverage
86%
Covered branches: 315
Total branches: 366
Branch coverage: 86%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
StartWakeListener(...)100%11100%
CreateResponseWaiter(...)100%11100%
CreateRecoverableResponseWaiter(...)100%11100%
CreateResponseWaiterCore()77.27%222290.41%
Process()100%11100%
ProcessUnderCapturedContextAsync()50%2280%
SetResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(...)100%11100%
SetResponseCore()75%44100%
SetRawResponseJsonCore()75%44100%
PublishResponseWithRecoveryAsync()75%1212100%
DispatchToRecoveryAsync()75%44100%
SetException()65%2020100%
CountActiveSubscribersAsync()100%22100%
DropLocalSubscriptionsAsync()100%66100%
HasLiveSubscriberAsync()100%11100%
AddSubscription(...)50%4471.42%
RemoveSubscription(...)75%44100%
UnlinkIfEmpty(...)40%261046.15%
TrackRetirement(...)100%11100%
CurrentFullSweepInterval()100%11100%
ThrowIfDisposed()100%22100%
EnsureListenerStarted()75%4491.66%
HeartbeatLoopAsync()100%4473.07%
SnapshotActiveRegistrations()100%66100%
DeleteRegistrationsDroppedDuringHeartbeatAsync()100%4481.25%
IsRegistrationLive(...)100%44100%
DispatchLoopAsync()100%22100%
CollectDispatchScopeAsync()100%3030100%
.cctor()100%11100%
DispatchPendingMessagesAsync()100%88100%
Advance(...)100%11100%
.ctor()100%11100%
DispatchPendingCorrelationAsync()85.71%434291.66%
DispatchPageAsync()90.9%282276.92%
EnqueueEligibleAsync()100%1616100%
WouldDeliverToAnySubscription(...)100%88100%
PublishMessageAsync()100%11100%
DispatchMessageToSubscribersAsync()86.36%452264%
IsWithinWatermark(...)78.57%141490.9%
TryDispatchLocalSubscribersAsync()87.5%8892.3%
BeginConfirmation(...)100%11100%
TryConfirmDeliveryAsync()100%22100%
SignalDispatcher(...)100%11100%
ScheduleBackpressureRescan(...)50%6450%
RescanAfterDelayAsync()50%2275%
WaitForAcknowledgementAsync()100%88100%
Remaining()100%11100%
SerializeRawSuccessEnvelope(...)100%11100%
HandleWaiterTimeoutAsync()100%11100%
DisposeAsync()100%88100%
get_MessageId()100%11100%
get_Delivered()100%11100%
Dispose()100%11100%
.ctor(...)100%11100%
get_CleanupStarted()100%11100%
get_Id()100%11100%
get_StartedAtUtc()100%11100%
get_StartedSeq()100%11100%
get_Dropped()100%11100%
get_TimeoutRegistration()100%11100%
get_TimeoutCancellation()100%11100%
get_ProcessUnderContextAsync()100%11100%
HasSeen(...)100%11100%
MarkSeen(...)100%22100%
PruneSeen(...)100%44100%
ProcessAsync()88.88%1818100%
CleanupOnceAsync(...)100%44100%
DrainThenCleanupAsync()87.5%8895.83%
CleanupCoreAsync()75%121292.59%
<CleanupCoreAsync()100%1150%
DropLocalAsync()100%11100%

File(s)

/_/src/Channels/Shared/DbChannelShared.cs

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using System.Buffers;
 4using System.Collections.Concurrent;
 5using System.Diagnostics;
 6using System.Runtime.CompilerServices;
 7using System.Text;
 8using System.Text.Json;
 9using System.Threading.Channels;
 10
 11namespace AsyncResponse.Channels;
 12
 13// Shared source for the database-backed response channels (PostgreSQL, SQL Server, MongoDB),
 14// mirroring the DurableFlows shared-store pattern: each channel csproj pulls this file in via
 15// <Compile Include="..\Shared\DbChannelShared.cs" />, so the base class compiles INTO each
 16// provider assembly against that provider's concrete seam types. The seam is bound per project
 17// with three global using aliases (declared at the top of the provider's channel file):
 18//
 19//   DbChannelStore   -> the provider's store/SQL adapter (e.g. PostgreSqlChannelSql)
 20//   DbChannelMessage -> the provider's channel-message record (e.g. PostgreSqlChannelMessage)
 21//   DbChannelOptions -> the provider's options class (e.g. PostgreSqlAsyncResponseChannelOptions)
 22//
 23// Because the aliases resolve to concrete sealed types at compile time, store calls on the
 24// per-message paths stay direct (no interface dispatch, no delegate indirection) — see the
 25// benchmark note in RedisAsyncResponseChannel.SetResponseCore for why that matters. The only
 26// virtual seams are the four hooks below, which cover exactly what the three providers genuinely
 27// do differently: the channel-name format, the sweep cadence, the optional wake listener, and the
 28// provider waiter type.
 29
 30/// <summary>
 31/// Provider-agnostic machinery for the database-backed response channels: waiter registration and
 32/// recovery-state bookkeeping, publish with delivery confirmation, the signal-driven dispatch
 33/// sweep, the subscriber heartbeat, and subscription lifecycle/cleanup. Derived channels supply
 34/// the wake mechanism (LISTEN/NOTIFY, adaptive polling, change streams), the channel-name format,
 35/// and the provider waiter type via the protected hooks.
 36/// </summary>
 37internal abstract class DbAsyncResponseChannelBase :
 38    IAsyncResponsePublisher,
 39    IRawAsyncResponsePublisher,
 40    IRecoverableAsyncResponseSubscriber,
 41    IActiveSubscriberProbe,
 42    IAsyncDisposable
 43{
 41944    private protected readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, IDbSubscription>> _subscriptions 
 45
 46    // A signal carries the correlation id to scan (targeted), or null to scan every subscribed
 47    // correlation id (the periodic sweep that is the missed-wake safety net).
 41948    private readonly Channel<string?> _signals = Channel.CreateBounded<string?>(new BoundedChannelOptions(1024)
 41949    {
 41950        SingleReader = true,
 41951        SingleWriter = false,
 41952        FullMode = BoundedChannelFullMode.DropOldest
 41953    });
 54
 55    // Maps a just-published message id to a completion the local dispatch loop trips the instant it
 56    // delivers the message to a live waiter. Same-process delivery (the overwhelmingly common case)
 57    // is confirmed without polling the database; cross-process delivery falls back to polling acked_at.
 41958    private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _pendingConfirmations = new();
 59
 60    private protected readonly DbChannelStore _store;
 61    private readonly IRecoveryStateStore _recoveryStateStore;
 62    private readonly AsyncResponseContextPropagation _propagation;
 63    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 64    private protected readonly DbChannelOptions _options;
 65    private protected readonly ILogger _logger;
 66    private readonly SerialExecutorRegistry _executors;
 41967    private readonly string _instanceId = $"{Environment.MachineName}-{Environment.ProcessId}-{Guid.NewGuid():N}";
 68
 69    // Provider text used in diagnostics. The emitted strings must stay byte-identical to the
 70    // pre-consolidation per-provider channels — tests and dashboards match on them.
 71    private readonly string _channelTypeName;
 72    private readonly string _providerName;
 73    private readonly string _activityTag;
 74    private readonly string _subscriberRecordNoun;
 75    private readonly string _localDispatchRetryHint;
 76
 41977    private readonly object _listenerGate = new();
 78    private protected CancellationTokenSource? _listenerCts;
 79    private protected Task? _listenTask;
 80    private protected Task? _dispatchTask;
 81    private protected Task? _heartbeatTask;
 82    private bool _disposed;
 83
 84    /// <summary>Creates the shared machinery for a database-backed async-response channel.</summary>
 41985    protected DbAsyncResponseChannelBase(
 41986        IServiceScopeFactory scopeFactory,
 41987        DbChannelStore store,
 41988        IRecoveryStateStore recoveryStateStore,
 41989        DbChannelOptions options,
 41990        AsyncResponseContextPropagation propagation,
 41991        ILogger logger,
 41992        string channelTypeName,
 41993        string providerName,
 41994        string activityTag,
 41995        string subscriberRecordNoun,
 41996        string localDispatchRetryHint,
 41997        TimeProvider? timeProvider = null)
 98    {
 41999        _store = store;
 419100        _recoveryStateStore = recoveryStateStore;
 419101        _propagation = propagation;
 419102        _options = options;
 419103        _options.Validate();
 419104        _logger = logger;
 419105        _channelTypeName = channelTypeName;
 419106        _providerName = providerName;
 419107        _activityTag = activityTag;
 419108        _subscriberRecordNoun = subscriberRecordNoun;
 419109        _localDispatchRetryHint = localDispatchRetryHint;
 419110        _timeProvider = timeProvider ?? TimeProvider.System;
 419111        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger, _timeProvide
 419112        _executors = new SerialExecutorRegistry(logger, timeProvider: _timeProvider);
 419113    }
 114
 115    /// <summary>
 116    /// The engine's clock. Waiter timeouts and the delivery-confirmation wait arm on it rather
 117    /// than on the wall clock, so AsyncResponse.Testing's virtual clock can fire production-sized
 118    /// timeouts instantly here exactly as it already does on the in-memory channel — previously
 119    /// these were the only channels whose timeout paths a virtual-clock test could not reach.
 120    /// </summary>
 121    private protected readonly TimeProvider _timeProvider;
 122
 123    /// <summary>
 124    /// The per-correlation channel name used as the serial-executor key and the lost-subscriber
 125    /// channel label. Formats differ per provider (notification channel, schema.table, collection).
 126    /// </summary>
 127    protected abstract string ChannelName(string correlationId);
 128
 129    /// <summary>
 130    /// The dispatch sweep cadence. Fixed (<c>ListenerPollInterval</c>) for the providers with a push
 131    /// wake; adaptive (active/idle) for SQL Server where the sweep IS the cross-process wake.
 132    /// </summary>
 133    protected abstract TimeSpan CurrentPollInterval();
 134
 135    /// <summary>
 136    /// Starts the provider's wake listener loop (LISTEN/NOTIFY, change stream), or returns
 137    /// <c>null</c> when the provider has none and relies on the dispatch sweep alone.
 138    /// </summary>
 364139    protected virtual Task? StartWakeListener(CancellationToken cancellationToken) => null;
 140
 141    /// <summary>Wraps the response task in the provider's waiter type.</summary>
 142    protected abstract IAsyncResponseWaiter<T> CreateWaiter<T>(Task<T> responseTask, Func<ValueTask> cleanupAsync)
 143        where T : IAsyncResponsePayload;
 144
 145    /// <inheritdoc />
 146    public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 147        string correlationId,
 148        Func<T, ValueTask<bool>>? completionPredicate = null,
 149        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 242150        => CreateResponseWaiterCore(correlationId, null, null, completionPredicate, timeout);
 151
 152    /// <inheritdoc />
 153    public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>(
 154        string correlationId,
 155        ReflectionCallDto? resumeCallback = null,
 156        ReflectionCallDto? failureCallback = null,
 157        Func<T, ValueTask<bool>>? completionPredicate = null,
 158        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 228159        => CreateResponseWaiterCore(correlationId, resumeCallback, failureCallback, completionPredicate, timeout);
 160
 161    private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>(
 162        string correlationId,
 163        ReflectionCallDto? resumeCallback,
 164        ReflectionCallDto? failureCallback,
 165        Func<T, ValueTask<bool>>? completionPredicate,
 166        TimeSpan? timeout) where T : IAsyncResponsePayload
 167    {
 470168        CorrelationIdGuard.ThrowIfUnusable(correlationId);
 169
 465170        if ((resumeCallback is not null || failureCallback is not null)
 465171            && !AsyncResponsePayloadReflection.OverridesOnRecovery(typeof(T)))
 172        {
 1173            throw new InvalidOperationException(
 1174                $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the {_providerName} channel
 1175                $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.OnRecovery)}(). " 
 1176                "Override it to declare what each response does to the flow — RecoveryAction.Resume, " +
 1177                "RecoveryAction.Fail, or RecoveryAction.KeepWaiting for non-terminal checkpoints; the durable " +
 1178                "channel needs this to route a response that arrives after the waiter was lost.");
 179        }
 180
 674181        completionPredicate ??= _ => new ValueTask<bool>(true);
 464182        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 183        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 184        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 185        // subscription and recovery state existed, leaking both — and zero used to slip through
 186        // on some channels entirely, insta-timing-out a fully registered waiter.
 464187        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 188
 189        // Refuse BEFORE any store round trip: EnsureCreatedAsync now validates manually managed
 190        // schemas over the network, and a disposed channel must fail with ObjectDisposedException,
 191        // not with whatever that connection attempt throws. EnsureListenerStarted below re-checks
 192        // under the gate, so a dispose racing this early check still cannot start listeners.
 461193        ThrowIfDisposed();
 458194        await _store.EnsureCreatedAsync().ConfigureAwait(false);
 456195        EnsureListenerStarted();
 196
 197        // Watermark from the database server's clock, not the app clock: the dispatch loop filters
 198        // pending messages with created_at >= started, and mixing an app-side timestamp with the
 199        // server-stamped created_at would silently drop live deliveries under clock skew. The
 200        // same round trip draws this subscription's position in the store's monotonic ack
 201        // sequence — the exact ordering IsWithinWatermark uses to separate "acked before this
 202        // waiter existed" (history) from "acked to a group including this waiter" (fan-out),
 203        // which no pair of same-tick timestamps can distinguish.
 456204        var (startedAtUtc, startedSeq) = await _store.GetSubscriptionStartAsync(CancellationToken.None).ConfigureAwait(f
 205
 456206        var storedCorrelationId = correlationId;
 456207        var capturedContext = ExecutionContext.Capture();
 208
 456209        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 456210        activity?.SetTag("asyncresponse.channel", _activityTag);
 456211        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 456212        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 213
 456214        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
 456215        var registrationId = Guid.NewGuid();
 456216        var subscription = new DbSubscription<T>(
 456217            this,
 456218            correlationId,
 456219            registrationId,
 456220            startedAtUtc,
 456221            startedSeq,
 456222            completionPredicate,
 456223            tcs,
 456224            activity);
 225
 226        // Clock-injected: CancelAfter on a default CTS is bound to the system clock, so a virtual
 227        // clock could never fire a production-sized waiter timeout on this channel.
 456228        var timeoutCts = new CancellationTokenSource(Timeout.InfiniteTimeSpan, _timeProvider);
 456229        CancellationTokenRegistration timeoutRegistration = default;
 912230        subscription.TimeoutRegistration = () => timeoutRegistration.DisposeAsync();
 456231        subscription.TimeoutCancellation = timeoutCts;
 232
 456233        timeoutRegistration = timeoutCts.Token.Register(
 456234            OnWaiterTimeout,
 456235            new WaiterTimeoutState<T>(this, subscription, activity, correlationId));
 236
 237        // Wire the captured-context delegate before the subscription becomes discoverable, so a
 238        // response already stored for this correlation id is processed with the caller's context.
 239        Task ProcessUnderCapturedContextAsync(DbChannelMessage message)
 240        {
 241            async Task Process()
 242            {
 557243                using var correlationScope = AsyncResponseContext.PushCorrelationId(storedCorrelationId);
 557244                await subscription.ProcessAsync(message).ConfigureAwait(false);
 557245            }
 246
 557247            if (capturedContext is null)
 0248                return Process();
 249
 557250            Task? task = null;
 1114251            ExecutionContext.Run(capturedContext, _ => task = Process(), null);
 557252            return task!;
 253        }
 254
 456255        subscription.ProcessUnderContextAsync = ProcessUnderCapturedContextAsync;
 256
 257        try
 258        {
 456259            var recoveryState = new RecoveryState
 456260            {
 456261                RegistrationId = registrationId,
 456262                ResumeCallback = resumeCallback,
 456263                FailureCallback = failureCallback,
 456264                CorrelationId = correlationId,
 456265                PayloadTypeFullName = typeof(T).FullName,
 456266                // The SERVER-stamped subscription start, not the app clock. The watchdog judges
 456267                // staleness as "utcNow - RegisteredAtUtc" from whichever host scans, so an
 456268                // app-clock stamp made a skewed host's registrations either never age (skew
 456269                // ahead: a genuinely stuck flow stays invisible) or age instantly (skew behind:
 456270                // healthy waits page the operator). This is the same clock the delivery watermark
 456271                // above is drawn from, and for the same reason.
 456272                RegisteredAtUtc = startedAtUtc.UtcDateTime,
 456273                Context = _propagation.Capture()
 456274            };
 275            // Subscriber record BEFORE recovery state: "recovery state visible ⇒ subscription
 276            // visible" is the invariant the lost-subscriber dispatcher's live re-check relies on.
 277            // In the reverse order a publisher could see the state, see no subscriber, and consume
 278            // the registration while this waiter is milliseconds from being live.
 456279            await _store.UpsertSubscriberAsync(correlationId, registrationId, _instanceId, _options.SubscriberHeartbeatT
 456280            await _recoveryStateStore.SaveAsync(correlationId, recoveryState, _options.RecoveryStateExpiry).ConfigureAwa
 281
 456282            if (_logger.IsEnabled(LogLevel.Debug))
 90283                _logger.LogDebug("Waiting for {Provider} response on correlationId {CorrelationId} with timeout {Timeout
 456284        }
 0285        catch (Exception ex)
 286        {
 0287            _logger.LogError(ex, "Failed to create {Provider} waiter for correlationId {CorrelationId}.", _providerName,
 0288            AsyncResponseDiagnostics.SetError(activity, "subscribe_failure", ex.Message);
 0289            await subscription.DrainThenCleanupAsync(deleteRecoveryState: true).ConfigureAwait(false);
 290
 291            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 292            // the trigger runs only once the subscription AND recovery state exist. A returned
 293            // waiter would still let the trigger fire the remote operation with no registration
 294            // left to receive (or recover) its response. Cleanup cancels the response task, so no
 295            // pending task is left behind.
 0296            throw;
 297        }
 298
 299        // Publish the subscription only once it is fully armed (heartbeat + context delegate),
 300        // then signal a scan targeted at this correlation id so any already-stored response is
 301        // delivered promptly without a full sweep.
 456302        AddSubscription(correlationId, subscription);
 456303        SignalDispatcher(correlationId);
 304
 305        // Arm the waiter timeout only AFTER the subscription is discoverable (Redis/NATS parity):
 306        // a timer that fired before AddSubscription would run cleanup against a map that does not
 307        // hold the entry yet, and the insert above would then pin a permanently-dropped
 308        // subscription (plus its executor registration) that nothing can ever remove again.
 309        try
 310        {
 456311            if (!subscription.CleanupStarted)
 456312                timeoutCts.CancelAfter(timeout.Value);
 456313        }
 0314        catch (ObjectDisposedException)
 315        {
 316            // A response completed and cleaned up between the check and CancelAfter.
 0317        }
 318
 915319        return CreateWaiter<T>(tcs.Task, () => subscription.DrainThenCleanupAsync(deleteRecoveryState: true));
 456320    }
 321
 322    /// <inheritdoc />
 323    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 560324        => SetResponseCore(response, correlationId, cancellationToken);
 325
 326    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 1327        => SetResponseCore(response, correlationId, cancellationToken);
 328
 329    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 23330        => SetRawResponseJsonCore(responseJson, correlationId, cancellationToken);
 331
 332    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 333    {
 561334        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 561335        activity?.SetTag("asyncresponse.channel", _activityTag);
 561336        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 561337        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 338
 561339        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the response"))
 2340            return;
 341
 342        try
 343        {
 556344            var envelope = new AsyncResponseEnvelope<T> { Success = true, Payload = response };
 556345            await PublishResponseWithRecoveryAsync(
 556346                activity,
 556347                correlationId,
 556348                AsyncResponseEnvelopeJson.Serialize(envelope),
 556349                typedResponse: response,
 556350                rawResponseJson: null,
 556351                cancellationToken).ConfigureAwait(false);
 553352        }
 3353        catch (Exception ex)
 354        {
 3355            _logger.LogError(ex, "Failed to publish {Provider} response for correlationId {CorrelationId}.", _providerNa
 3356            AsyncResponseDiagnostics.SetError(activity, ex);
 3357            throw;
 358        }
 555359    }
 360
 361    private async Task SetRawResponseJsonCore(string responseJson, string correlationId, CancellationToken cancellationT
 362    {
 23363        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 23364        activity?.SetTag("asyncresponse.channel", _activityTag);
 23365        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 366
 23367        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the raw response", dropContractViolati
 3368            return;
 369
 370        try
 371        {
 20372            await PublishResponseWithRecoveryAsync<object>(
 20373                activity,
 20374                correlationId,
 20375                SerializeRawSuccessEnvelope(responseJson),
 20376                typedResponse: null,
 20377                rawResponseJson: responseJson,
 20378                cancellationToken).ConfigureAwait(false);
 17379        }
 3380        catch (Exception ex)
 381        {
 3382            _logger.LogError(ex, "Failed to publish {Provider} raw response for correlationId {CorrelationId}.", _provid
 3383            AsyncResponseDiagnostics.SetError(activity, ex);
 3384            throw;
 385        }
 20386    }
 387
 388    /// <summary>
 389    /// The publish-with-recovery protocol shared by <see cref="SetResponseCore{T}"/> and
 390    /// <see cref="SetRawResponseJsonCore"/>, which carried lockstep copies of it (and the raw
 391    /// copy parsed its body twice when the RetryLive branch fell through to a failed delivery
 392    /// confirmation). The recovery payload is <paramref name="typedResponse"/> when
 393    /// <paramref name="rawResponseJson"/> is null; otherwise the raw body is deserialized
 394    /// lazily — once — on the cold branches that dispatch to recovery, so the delivered-live
 395    /// path never parses it. Generic so the typed path hands the dispatcher the publisher's
 396    /// DECLARED <typeparamref name="TPayload"/> — erasing to <c>object</c> made the recovery
 397    /// wire form the RUNTIME-type serialization, diverging from the declared-type envelope this
 398    /// method just wrote (and from Redis/NATS/in-memory) whenever the runtime type carries
 399    /// members the declared contract does not.
 400    /// </summary>
 401    private async Task PublishResponseWithRecoveryAsync<TPayload>(
 402        Activity? activity,
 403        string correlationId,
 404        string envelopeJson,
 405        TPayload? typedResponse,
 406        string? rawResponseJson,
 407        CancellationToken cancellationToken)
 408    {
 576409        object? rawRecoveryPayload = null;
 576410        var rawRecoveryPayloadMaterialized = false;
 411
 412        // One dispatch shape per payload source, so generic inference binds the declared type on
 413        // the typed path and object on the raw path — never object for both.
 414        Task<LostSubscriberDispatchResult> DispatchToRecoveryAsync(Func<ValueTask<bool>>? hasLiveSubscriber)
 415        {
 21416            if (rawResponseJson is null)
 417            {
 6418                return _lostSubscriberDispatcher.DispatchLostResponses(
 6419                    _recoveryStateStore, correlationId, typedResponse, ChannelName(correlationId), cancellationToken, ha
 420            }
 421
 15422            if (!rawRecoveryPayloadMaterialized)
 423            {
 15424                rawRecoveryPayload = new RawJsonResponse(rawResponseJson).DeserializeUntyped();
 15425                rawRecoveryPayloadMaterialized = true;
 426            }
 427
 15428            return _lostSubscriberDispatcher.DispatchLostResponses(
 15429                _recoveryStateStore, correlationId, rawRecoveryPayload, ChannelName(correlationId), cancellationToken, h
 430        }
 431
 576432        var subscribers = await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(fals
 570433        activity?.SetTag("asyncresponse.subscribers", subscribers);
 570434        if (subscribers <= 0)
 435        {
 19436            var dispatchResult = await DispatchToRecoveryAsync(
 19437                    hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 19438                .ConfigureAwait(false);
 19439            if (!dispatchResult.RetryLive)
 440            {
 19441                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMix
 19442                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.Callback
 19443                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 19444                return;
 445            }
 446
 447            // A waiter registered between the count and the recovery-state read — publish live
 448            // instead of consuming its registration.
 449        }
 450
 551451        var messageId = Guid.NewGuid();
 551452        using var confirmation = BeginConfirmation(messageId);
 551453        await PublishMessageAsync(messageId, correlationId, envelopeJson, cancellationToken).ConfigureAwait(false);
 454
 551455        if (!await TryConfirmDeliveryAsync(confirmation, cancellationToken).ConfigureAwait(false))
 456        {
 2457            var dispatchResult = await DispatchToRecoveryAsync(hasLiveSubscriber: null).ConfigureAwait(false);
 2458            AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.Action, dispatchResult.RouteMixed);
 2459            AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.Action, dispatchResult.CallbackInvo
 2460            activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 461        }
 570462    }
 463
 464    /// <inheritdoc />
 465    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 466    {
 12467        ArgumentNullException.ThrowIfNull(exception);
 468
 12469        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 12470        activity?.SetTag("asyncresponse.channel", _activityTag);
 12471        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 12472        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 473
 12474        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the exception", exception))
 2475            return;
 476
 477        try
 478        {
 9479            var subscribers = await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(
 6480            activity?.SetTag("asyncresponse.subscribers", subscribers);
 6481            if (subscribers <= 0)
 482            {
 2483                var dispatchResult = await _lostSubscriberDispatcher
 2484                    .DispatchLostExceptions(
 2485                        _recoveryStateStore,
 2486                        correlationId,
 2487                        exception,
 2488                        ChannelName(correlationId),
 2489                        cancellationToken,
 2490                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 2491                    .ConfigureAwait(false);
 2492                if (!dispatchResult.RetryLive)
 493                {
 2494                    activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 2495                    AsyncResponseDiagnostics.RecordLostSubscriber("exception", action: RecoveryAction.Fail, dispatchResu
 2496                    return;
 497                }
 498
 499                // A waiter registered between the count and the recovery-state read — publish live
 500                // instead of consuming its registration.
 501            }
 502
 4503            var envelope = new AsyncResponseEnvelope<object>
 4504            {
 4505                Success = false,
 4506                ExceptionMessage = exception.Message,
 4507                ExceptionStackTrace = RemoteStackTrace.ForWire(exception.StackTrace, _options.IncludeRemoteStackTrace, _
 4508                Payload = null
 4509            };
 4510            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 4511            var messageId = Guid.NewGuid();
 4512            using var confirmation = BeginConfirmation(messageId);
 4513            await PublishMessageAsync(messageId, correlationId, json, cancellationToken).ConfigureAwait(false);
 514
 4515            if (!await TryConfirmDeliveryAsync(confirmation, cancellationToken).ConfigureAwait(false))
 516            {
 517                // No live re-check here: TryClaimForRecoveryAsync already won the message for the
 518                // recovery path, so live delivery of it is no longer possible.
 1519                var dispatchResult = await _lostSubscriberDispatcher
 1520                    .DispatchLostExceptions(_recoveryStateStore, correlationId, exception, ChannelName(correlationId), c
 1521                    .ConfigureAwait(false);
 1522                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 1523                AsyncResponseDiagnostics.RecordLostSubscriber("exception", action: RecoveryAction.Fail, dispatchResult.C
 524            }
 4525        }
 3526        catch (Exception ex)
 527        {
 3528            _logger.LogError(ex, "Failed to publish {Provider} exception response for correlationId {CorrelationId}.", _
 3529            AsyncResponseDiagnostics.SetError(activity, ex);
 3530            throw;
 531        }
 8532    }
 533
 534    /// <inheritdoc />
 535    public async ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken =
 536    {
 9537        if (string.IsNullOrWhiteSpace(correlationId))
 2538            return 0L;
 539
 540        try
 541        {
 7542            return await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false);
 543        }
 1544        catch (Exception ex) when (ex is not OperationCanceledException)
 545        {
 1546            _logger.LogDebug(ex, "Failed to count {Provider} subscribers for correlationId {CorrelationId}.", _providerN
 547            // Negative = "could not be probed" (the watchdog's documented unknown-liveness
 548            // contract): returning 0 would assert there is definitively no live waiter, flagging
 549            // every over-threshold registration stale during a transient probe outage.
 1550            return -1L;
 551        }
 9552    }
 553
 554    /// <summary>
 555    /// Drops local subscriptions while leaving recovery state intact. Used by the sample app to
 556    /// simulate a redeploy for lost-subscriber integration tests.
 557    /// </summary>
 558    internal async Task DropLocalSubscriptionsAsync(CancellationToken cancellationToken = default)
 559    {
 16560        foreach (var (correlationId, group) in _subscriptions.ToArray())
 561        {
 16562            foreach (var subscription in group.Values.ToArray())
 563            {
 4564                await subscription.DropLocalAsync(cancellationToken).ConfigureAwait(false);
 565                // Retire the registry registration too (as RemoveSubscription does): a leftover
 566                // refcount would defeat the tombstone set by the RemoveAsync below, letting a
 567                // later delivery recreate an executor nothing ever retires.
 4568                if (group.TryRemove(subscription.Id, out _))
 4569                    _executors.OnSubscriptionRetired(ChannelName(correlationId));
 4570            }
 571
 4572            UnlinkIfEmpty(correlationId, group);
 573
 4574            await _executors.RemoveAsync(ChannelName(correlationId)).ConfigureAwait(false);
 4575        }
 4576    }
 577
 578    /// <summary>
 579    /// Re-probes waiter liveness for the lost-subscriber dispatcher's snapshot-race re-check,
 580    /// using the same active-subscriber count the publish path consulted.
 581    /// </summary>
 582    private async ValueTask<bool> HasLiveSubscriberAsync(string correlationId, CancellationToken cancellationToken)
 21583        => await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false) > 0;
 584
 585    private protected void AddSubscription(string correlationId, IDbSubscription subscription)
 586    {
 587        // Register with the executor registry BEFORE publishing into the subscription map: every
 588        // dispatch path consults the map and then enqueues, so a delivery racing a visible-but-
 589        // unregistered subscription on a correlation id reused within the tombstone lifetime would
 590        // be silently dropped. In the reversed window (registered, not yet visible) the delivery
 591        // just waits for the next sweep or falls back to lost-subscriber recovery.
 481592        _executors.OnSubscriptionRegistered(ChannelName(correlationId));
 0593        while (true)
 594        {
 957595            var group = _subscriptions.GetOrAdd(correlationId, _ => new ConcurrentDictionary<Guid, IDbSubscription>());
 481596            group[subscription.Id] = subscription;
 597
 598            // A concurrent RemoveSubscription may have unlinked this group between the GetOrAdd
 599            // and the insert above (its emptiness check cannot see the in-flight insert). If the
 600            // group this subscription landed in is no longer the mapped one, move it to the live
 601            // group so it stays reachable to every dispatch path.
 481602            if (_subscriptions.TryGetValue(correlationId, out var current) && ReferenceEquals(current, group))
 481603                return;
 604
 0605            group.TryRemove(subscription.Id, out _);
 606        }
 607    }
 608
 609    private void RemoveSubscription(string correlationId, Guid registrationId)
 610    {
 462611        if (!_subscriptions.TryGetValue(correlationId, out var group))
 4612            return;
 613
 458614        if (group.TryRemove(registrationId, out _))
 458615            _executors.OnSubscriptionRetired(ChannelName(correlationId));
 458616        UnlinkIfEmpty(correlationId, group);
 458617    }
 618
 619    // Unlinks an emptied subscription group from the map without orphaning a concurrent
 620    // registration: the emptiness read and the map removal cannot be one atomic step, so a
 621    // waiter registered for a reused correlation id in that window would land in an unreachable
 622    // group and time out despite its response being published. Unlink only our exact group,
 623    // then re-link (or merge) anything a racing AddSubscription slipped into it.
 624    private void UnlinkIfEmpty(string correlationId, ConcurrentDictionary<Guid, IDbSubscription> group)
 625    {
 462626        if (!group.IsEmpty)
 3627            return;
 628
 459629        if (!((ICollection<KeyValuePair<string, ConcurrentDictionary<Guid, IDbSubscription>>>)_subscriptions)
 459630                .Remove(new KeyValuePair<string, ConcurrentDictionary<Guid, IDbSubscription>>(correlationId, group)))
 0631            return;
 632
 459633        if (group.IsEmpty)
 459634            return;
 635
 0636        var merged = _subscriptions.GetOrAdd(correlationId, group);
 0637        if (ReferenceEquals(merged, group))
 0638            return;
 639
 0640        foreach (var entry in group)
 0641            merged[entry.Key] = entry.Value;
 0642    }
 643
 644    // Executor retirements started off the cleanup path (see CleanupCoreAsync). Keyed by the task
 645    // itself and self-evicting, so a long-lived channel never accumulates completed entries.
 419646    private readonly ConcurrentDictionary<Task, byte> _pendingRetirements = new();
 647
 648    private void TrackRetirement(Task retirement)
 649    {
 462650        _pendingRetirements[retirement] = 0;
 462651        _ = retirement.ContinueWith(
 462652            static (completed, state) => ((ConcurrentDictionary<Task, byte>)state!).TryRemove(completed, out _),
 462653            _pendingRetirements,
 462654            CancellationToken.None,
 462655            TaskContinuationOptions.ExecuteSynchronously,
 462656            TaskScheduler.Default);
 462657    }
 658
 659    /// <summary>
 660    /// The effective minimum interval between full safety-net sweeps, <c>null</c> to sweep on
 661    /// every poll tick. Defaults to the configured <c>FullSweepInterval</c>; a provider overrides
 662    /// it when its push wake is not carrying delivery, because the throttled sweep is then the
 663    /// ONLY cross-process wake and a throttle equal to the delivery-confirmation timeout routed
 664    /// live waiters' responses into lost-subscriber recovery.
 665    /// </summary>
 5909666    protected virtual TimeSpan? CurrentFullSweepInterval() => _options.FullSweepInterval;
 667
 668    private void ThrowIfDisposed()
 669    {
 461670        lock (_listenerGate)
 671        {
 461672            if (_disposed)
 3673                throw new ObjectDisposedException(_channelTypeName);
 458674        }
 458675    }
 676
 677    private protected void EnsureListenerStarted()
 678    {
 461679        lock (_listenerGate)
 680        {
 681            // Checked under the same gate DisposeAsync sets it under: a racing registration must
 682            // never recreate the CTS and loops after disposal tore them down.
 461683            if (_disposed)
 0684                throw new ObjectDisposedException(_channelTypeName);
 685
 461686            if (_listenerCts is not null)
 97687                return;
 688
 364689            var listenerCts = new CancellationTokenSource();
 364690            _listenerCts = listenerCts;
 364691            _listenTask = StartWakeListener(listenerCts.Token);
 728692            _dispatchTask = Task.Run(() => DispatchLoopAsync(listenerCts.Token));
 728693            _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(listenerCts.Token));
 364694        }
 461695    }
 696
 697    // The REAL clock, deliberately — here and in the dispatch loop's poll and rescan delays —
 698    // although waiter timeouts and the delivery-confirmation wait arm on _timeProvider. These
 699    // loops keep pace with state that lives in the database and moves in real time whatever clock
 700    // the process was handed: subscriber rows expire on the SERVER's clock, and another process's
 701    // response becomes visible when ITS transaction commits. A heartbeat parked on a virtual clock
 702    // that a test never advances lets the rows of live waiters expire server-side (their responses
 703    // then route to lost-subscriber recovery), and a parked poll never delivers a cross-process
 704    // response at all. What the injected clock owns is time the process itself defines.
 705    private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
 706    {
 4646707        while (!cancellationToken.IsCancellationRequested)
 708        {
 709            try
 710            {
 4645711                await Task.Delay(_options.SubscriberHeartbeatInterval, cancellationToken).ConfigureAwait(false);
 4286712                var registrations = SnapshotActiveRegistrations();
 4286713                if (registrations.Count > 0)
 714                {
 715                    try
 716                    {
 1905717                        await _store.HeartbeatSubscribersAsync(
 1905718                            _instanceId,
 1905719                            registrations,
 1905720                            _options.SubscriberHeartbeatTimeout,
 1905721                            cancellationToken).ConfigureAwait(false);
 1895722                    }
 1723                    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 724                    {
 1725                        return;
 726                    }
 9727                    catch (Exception ex)
 728                    {
 729                        // The round still compensates below: SQL Server commits per-batch,
 730                        // MongoDB bulk-writes unordered, and any provider can fail after some
 731                        // upserts landed — a registration dropped mid-round may already be
 732                        // resurrected even though the round as a whole threw. Skipping the
 733                        // re-check on failure left exactly those rows phantom until TTL.
 9734                        _logger.LogWarning(ex, "{Provider} subscriber heartbeat failed; retrying for all local waiters."
 9735                    }
 736
 1904737                    await DeleteRegistrationsDroppedDuringHeartbeatAsync(registrations, cancellationToken).ConfigureAwai
 738                }
 4282739            }
 362740            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 741            {
 362742                return;
 743            }
 0744            catch (Exception ex)
 745            {
 746                // Distinct from the inner catch's message, which reports a failed store upsert
 747                // that the drop compensation below it still runs. Reaching HERE means the round's
 748                // own bookkeeping broke — the snapshot, or the compensating deletes — so subscriber
 749                // rows dropped mid-round stay resurrected and suppress lost-subscriber recovery for
 750                // their correlation ids until the heartbeat timeout. Different cause, different
 751                // operator response, so it must not read identically.
 0752                _logger.LogWarning(
 0753                    ex,
 0754                    "{Provider} subscriber heartbeat round failed outside the store upsert (snapshot or drop compensatio
 0755                    _providerName);
 0756            }
 757        }
 364758    }
 759
 760    private List<(string CorrelationId, Guid RegistrationId)> SnapshotActiveRegistrations()
 761    {
 762        // Full (correlation id, registration id) pairs: the heartbeat UPSERTs the subscriber
 763        // records, so it needs everything required to re-create one the store's expiry pruning
 764        // (relational pruner / TTL reaper) has already deleted.
 4286765        var registrations = new List<(string CorrelationId, Guid RegistrationId)>();
 13774766        foreach (var (correlationId, group) in _subscriptions)
 767        {
 10406768            foreach (var subscription in group.Values)
 769            {
 2602770                if (!subscription.Dropped)
 2545771                    registrations.Add((correlationId, subscription.Id));
 772            }
 773        }
 774
 4286775        return registrations;
 776    }
 777
 778    /// <summary>
 779    /// Closes the heartbeat/cleanup race: a subscription can be dropped (and its subscriber row
 780    /// deleted) AFTER the snapshot above was taken but BEFORE the round's upsert landed — the
 781    /// upsert then resurrects the deleted row, and until it ages out past the heartbeat timeout
 782    /// every publisher counts a live waiter that no longer exists, suppressing lost-subscriber
 783    /// recovery for the correlation id. Both cleanup paths set <c>Dropped</c> BEFORE issuing
 784    /// their delete, which makes this post-round re-check airtight: either the drop is visible
 785    /// here and the compensating delete below lands after the resurrecting upsert, or the drop
 786    /// happened after this check — and then the cleanup's own delete is ordered after the upsert
 787    /// and removes the row itself. Best-effort like the cleanup delete: a failed compensation
 788    /// ages out via the heartbeat timeout.
 789    /// </summary>
 790    private async Task DeleteRegistrationsDroppedDuringHeartbeatAsync(
 791        List<(string CorrelationId, Guid RegistrationId)> heartbeaten,
 792        CancellationToken cancellationToken)
 793    {
 8853794        foreach (var (correlationId, registrationId) in heartbeaten)
 795        {
 2522796            if (IsRegistrationLive(correlationId, registrationId))
 797                continue;
 798
 37799            _logger.LogDebug(
 37800                "Deleting {Provider} subscriber {RegistrationId} for correlationId {CorrelationId}: it was dropped while
 37801                _providerName, registrationId, correlationId);
 802            try
 803            {
 37804                await _store.DeleteSubscriberAsync(correlationId, registrationId, cancellationToken).ConfigureAwait(fals
 30805            }
 3806            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 807            {
 3808                throw;
 809            }
 4810            catch (Exception ex)
 811            {
 4812                _logger.LogError(ex,
 4813                    "Failed to delete {Provider} subscriber {SubscriberRecord} for correlationId {CorrelationId} after i
 4814                    _providerName, _subscriberRecordNoun, correlationId);
 4815            }
 34816        }
 1903817    }
 818
 819    private bool IsRegistrationLive(string correlationId, Guid registrationId)
 2522820        => _subscriptions.TryGetValue(correlationId, out var group)
 2522821           && group.TryGetValue(registrationId, out var subscription)
 2522822           && !subscription.Dropped;
 823
 824    private async Task DispatchLoopAsync(CancellationToken cancellationToken)
 825    {
 7110826        while (!cancellationToken.IsCancellationRequested)
 827        {
 828            try
 829            {
 6874830                var scope = await CollectDispatchScopeAsync(cancellationToken).ConfigureAwait(false);
 6780831                await DispatchPendingMessagesAsync(scope, cancellationToken).ConfigureAwait(false);
 6744832            }
 109833            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 834            {
 109835                return;
 836            }
 21837            catch (Exception ex)
 838            {
 21839                _logger.LogWarning(ex, "{Provider} response dispatch loop failed; retrying after poll delay.", _provider
 21840                await Task.Delay(CurrentPollInterval(), cancellationToken).ConfigureAwait(false);
 841            }
 842        }
 345843    }
 844
 845    /// <summary>
 846    /// Waits for the next dispatch trigger and returns its scope. <c>null</c> means scan every
 847    /// subscribed correlation id — a full sweep requested explicitly (a null signal) or by the
 848    /// periodic poll that is the missed-wake / cross-process-delivery safety net. A non-null set
 849    /// scans only the signaled correlation ids, so a flood of wake signals never forces a scan of
 850    /// every waiter.
 851    /// <para>
 852    /// The poll deadline is ABSOLUTE and judged after either wake source. It used to be a fresh
 853    /// <c>Task.Delay</c> per pass that only counted when it won the race, so a steady stream of
 854    /// targeted signals cancelled every delay and the full sweep never ran: a response published
 855    /// from another process with no local signal — every cross-process response on SQL Server,
 856    /// any missed or dropped notification elsewhere (the signal channel itself drops its oldest
 857    /// entry when full) — sat undelivered for as long as unrelated local traffic continued.
 858    /// </para>
 859    /// </summary>
 860    private protected async Task<HashSet<string>?> CollectDispatchScopeAsync(CancellationToken cancellationToken)
 861    {
 862        // Armed on the first pass rather than at construction: the loop starts lazily, and a
 863        // deadline measured from the constructor would already be overdue by then.
 7512864        _pollArmedAt ??= Stopwatch.GetTimestamp();
 865
 7512866        var signalled = false;
 7512867        var untilPoll = CurrentPollInterval() - Stopwatch.GetElapsedTime(_pollArmedAt.Value);
 7512868        var pollDue = untilPoll <= TimeSpan.Zero;
 7512869        if (!pollDue)
 870        {
 871            // The WhenAny loser is cancelled via the per-iteration linked source: an abandoned
 872            // WaitToReadAsync would otherwise stay parked in the channel's blocked-reader list until
 873            // the next signal — one per poll interval, accumulating without bound on an idle channel.
 7321874            using var iteration = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 7321875            var delay = Task.Delay(untilPoll, iteration.Token);
 7321876            var signal = _signals.Reader.WaitToReadAsync(iteration.Token).AsTask();
 7321877            var completed = await Task.WhenAny(delay, signal).ConfigureAwait(false);
 7321878            iteration.Cancel();
 7321879            if (completed == signal)
 880            {
 1649881                await signal.ConfigureAwait(false);
 1555882                signalled = true;
 883            }
 884            else
 885            {
 886                // The timer is the authority for its own tick: it may fire a hair before the
 887                // stopwatch agrees, and that tick must not degrade into an empty pass.
 5672888                pollDue = true;
 889            }
 7227890        }
 891
 892        // A signal does not excuse the poll. Re-read the interval while judging it: a signal from
 893        // a new waiter is what re-arms SQL Server's tight active cadence, and that waiter's first
 894        // poll must not wait out the idle interval.
 7418895        if (pollDue || Stopwatch.GetElapsedTime(_pollArmedAt.Value) >= CurrentPollInterval())
 896        {
 5909897            _pollArmedAt = Stopwatch.GetTimestamp();
 898
 899            // The timer sweep costs one store query per subscribed correlation id, so with W
 900            // waiters an idle channel pays W queries per poll tick. FullSweepInterval bounds that:
 901            // a tick whose sweep is not yet due scans only what was signalled (possibly nothing).
 902            // Provider-resolved: a provider whose push wake is off or unavailable has no other
 903            // cross-process delivery path and must sweep every tick.
 5909904            if (CurrentFullSweepInterval() is not { } fullSweepInterval
 5909905                || _lastFullSweepAt is not { } lastFullSweepAt
 5909906                || Stopwatch.GetElapsedTime(lastFullSweepAt) >= fullSweepInterval)
 907            {
 908                // Queued signals stay queued: the sweep covers their correlation ids, and the
 909                // next pass re-scans them as a cheap targeted scope instead of this pass having
 910                // to reason about signals written while the sweep was running.
 5883911                _lastFullSweepAt = Stopwatch.GetTimestamp();
 5883912                return null;
 913            }
 914        }
 915
 1535916        var scope = new HashSet<string>(StringComparer.Ordinal);
 1535917        var fullSweep = false;
 6352918        for (var read = 0; read < MaxSignalsPerPass && _signals.Reader.TryRead(out var correlationId); read++)
 919        {
 1641920            if (string.IsNullOrEmpty(correlationId))
 2921                fullSweep = true;
 922            else
 1639923                scope.Add(correlationId);
 924        }
 925
 1535926        if (fullSweep || (signalled && scope.Count == 0))
 927        {
 928            // A signal-driven full sweep does the timer sweep's work; stamping it defers the next
 929            // timer sweep by a full interval instead of re-scanning everything twice in a row.
 2930            _lastFullSweepAt = Stopwatch.GetTimestamp();
 2931            return null;
 932        }
 933
 1533934        return scope.Count == 0 ? EmptyDispatchScope : scope;
 7418935    }
 936
 937    /// <summary>Returned for a poll tick whose full sweep is not yet due: scan nothing. Never mutated.</summary>
 12938    private static readonly HashSet<string> EmptyDispatchScope = [];
 939
 940    /// <summary>
 941    /// Most signals one pass folds into its scope. The channel holds this many, so a pass still
 942    /// takes everything that was queued when it started; the bound only stops it from chasing
 943    /// writers that refill the channel as fast as it drains, which would keep the dispatch — and
 944    /// the poll deadline behind it — waiting on the drain.
 945    /// </summary>
 946    private const int MaxSignalsPerPass = 1024;
 947
 948    // Stopwatch timestamps, not wall-clock stamps: both are interval deadlines, and a system
 949    // clock stepping backwards must not postpone a sweep. Touched only by the dispatch loop.
 950    private long? _pollArmedAt;
 951    private long? _lastFullSweepAt;
 952
 953    private protected async Task DispatchPendingMessagesAsync(HashSet<string>? scope, CancellationToken cancellationToke
 954    {
 6791955        if (scope is not null)
 956        {
 957            // A publish signals exactly one correlation id, so a targeted scan must cost
 958            // O(scope), not O(live waiters): enumerating the whole registry made every publish
 959            // quadratic under load, and the not-yet-due poll tick (an empty scope) paid the same
 960            // walk to match nothing.
 3918961            foreach (var correlationId in scope)
 962            {
 1008963                if (_subscriptions.TryGetValue(correlationId, out var group))
 966964                    await DispatchPendingCorrelationAsync(correlationId, group, cancellationToken).ConfigureAwait(false)
 965            }
 966
 936967            return;
 968        }
 969
 22840970        foreach (var (correlationId, group) in _subscriptions)
 5600971            await DispatchPendingCorrelationAsync(correlationId, group, cancellationToken).ConfigureAwait(false);
 6751972    }
 973
 974    // The group owns its scan progress: removing the last subscription also makes the cursor
 975    // collectible, without another per-correlation registry or a cleanup race on reused ids.
 419976    private readonly ConditionalWeakTable<ConcurrentDictionary<Guid, IDbSubscription>, DispatchScan> _dispatchScans = ne
 977    private const int MaxForwardPagesPerPass = 16;
 12978    private static readonly Guid LastMessageId = new("ffffffff-ffff-ffff-ffff-ffffffffffff");
 979
 980    private sealed class MessageCursor
 981    {
 982        public DateTimeOffset? CreatedAtUtc;
 983        public Guid? Id;
 1905984        public void Advance(DbChannelMessage message) { CreatedAtUtc = message.CreatedAtUtc; Id = message.Id; }
 985    }
 986
 987    private sealed class DispatchScan
 988    {
 456989        public HashSet<Guid> Registrations = [];
 456990        public MessageCursor Forward = new();
 991        public bool ForwardCaughtUp;
 992        public MessageCursor? Reconciliation;
 993        public DateTimeOffset? ReconciliationEndUtc;
 994        public Guid? ReconciliationEndId;
 995        public DateTimeOffset ReconcileAfter;
 996        public int RewindRequested;
 997    }
 998
 999    private async Task DispatchPendingCorrelationAsync(
 1000        string correlationId,
 1001        ConcurrentDictionary<Guid, IDbSubscription> group,
 1002        CancellationToken cancellationToken)
 1003    {
 1004        // The oldest watermark is folded into the pass that builds the list: this runs per
 1005        // correlation id on every sweep tick AND on every publish's targeted scan, so a separate
 1006        // LINQ Min() was one enumerator allocation and one delegate call per element on the
 1007        // dispatch hot path, over a list this loop has in hand anyway.
 65661008        var subscriptions = new List<IDbSubscription>(group.Count);
 65661009        var oldestStartedAtUtc = DateTimeOffset.MaxValue;
 262761010        foreach (var subscription in group.Values)
 1011        {
 65721012            if (subscription.Dropped)
 1013                continue;
 1014
 63461015            subscriptions.Add(subscription);
 63461016            if (subscription.StartedAtUtc < oldestStartedAtUtc)
 63421017                oldestStartedAtUtc = subscription.StartedAtUtc;
 1018        }
 65661019        if (subscriptions.Count == 0)
 2261020            return;
 1021
 63401022        var since = oldestStartedAtUtc.AddSeconds(-1);
 63401023        var seenCutoff = _timeProvider.GetUtcNow() - _options.MessageRetention - TimeSpan.FromMinutes(1);
 253721024        foreach (var subscription in subscriptions)
 63461025            subscription.PruneSeen(seenCutoff);
 1026
 63401027        var scan = _dispatchScans.GetOrCreateValue(group);
 126861028        var registrations = subscriptions.Select(subscription => subscription.Id).ToHashSet();
 63401029        var now = _timeProvider.GetUtcNow();
 63401030        if (!scan.Registrations.SetEquals(registrations) || Interlocked.Exchange(ref scan.RewindRequested, 0) != 0)
 1031        {
 4591032            scan.Registrations = registrations;
 4591033            scan.Forward = new MessageCursor();
 4591034            scan.ForwardCaughtUp = false;
 4591035            scan.Reconciliation = null;
 4591036            scan.ReconcileAfter = now + _options.HistoryReconciliationInterval;
 1037        }
 1038
 1039        // Normal polls and targeted signals continue after the last admitted page. A new waiter
 1040        // resets progress so its own watermark, not another waiter's seen set, decides fan-out.
 63401041        var previousForward = scan.Forward;
 63401042        if (scan.ForwardCaughtUp && scan.Forward.CreatedAtUtc is { } lastTick && lastTick > DateTimeOffset.MinValue)
 1043        {
 1044            // A database clock tick can contain several random ids. A newly committed message
 1045            // in the LAST tick must not wait for historical reconciliation merely because its
 1046            // id sorts before the previous message. Revisit that tick, not the entire history.
 1047            // The provider may truncate the sub-tick timestamp to milliseconds/microseconds;
 1048            // the maximum id excludes rows at that preceding, truncated timestamp.
 2611049            scan.Forward = new MessageCursor { CreatedAtUtc = lastTick.AddTicks(-1), Id = LastMessageId };
 1050        }
 63401051        scan.ForwardCaughtUp = false;
 63401052        var forwardReadAny = false;
 126801053        for (var page = 0; page < MaxForwardPagesPerPass; page++)
 1054        {
 63401055            var (more, admitted, _) = await DispatchPageAsync(scan.Forward).ConfigureAwait(false);
 63001056            if (!admitted)
 11057                return;
 62991058            if (!more)
 1059            {
 62991060                scan.ForwardCaughtUp = true;
 62991061                break;
 1062            }
 01063            if (page == MaxForwardPagesPerPass - 1)
 01064                ScheduleBackpressureRescan(correlationId, cancellationToken);
 1065        }
 62991066        if (!forwardReadAny)
 56651067            scan.Forward = previousForward; // Expired/pruned tail: do not walk backward on idle polls.
 1068
 1069        // Creation keys are NOT commit order: a transaction can become visible behind the
 1070        // cursor, even with the same timestamp and a lower id, and another process may already
 1071        // have acknowledged it. Reconcile retained history periodically, one page per pass.
 1072        // Both unacked and acked rows participate; filtering acked rows would break fan-out.
 62991073        if (scan.Reconciliation is null && now >= scan.ReconcileAfter && scan.Forward.Id is not null)
 1074        {
 11075            scan.Reconciliation = new MessageCursor();
 11076            scan.ReconciliationEndUtc = scan.Forward.CreatedAtUtc;
 11077            scan.ReconciliationEndId = scan.Forward.Id;
 1078        }
 62991079        if (scan.Reconciliation is { } reconciliation)
 1080        {
 11081            var (more, admitted, reachedEnd) = await DispatchPageAsync(reconciliation, reconcile: true).ConfigureAwait(f
 11082            if (admitted && (!more || reachedEnd))
 1083            {
 11084                scan.Reconciliation = null;
 11085                scan.ReconcileAfter = _timeProvider.GetUtcNow() + _options.HistoryReconciliationInterval;
 1086            }
 1087            else
 01088                ScheduleBackpressureRescan(correlationId, cancellationToken);
 1089        }
 1090
 1091        async Task<(bool More, bool Admitted, bool ReachedEnd)> DispatchPageAsync(MessageCursor cursor, bool reconcile =
 1092        {
 63411093            var messages = await _store.LoadMessagesAsync(
 63411094                correlationId, since, _options.PendingMessageBatchSize,
 63411095                cursor.CreatedAtUtc, cursor.Id, cancellationToken).ConfigureAwait(false);
 1096
 1097            // Acknowledged rows stay eligible for fan-out, but travel header-only. Hydrate
 1098            // only those a live subscription still needs, then enqueue in page order.
 63021099            List<DbChannelMessage>? eligible = null;
 63021100            List<Guid>? headerOnly = null;
 141141101            foreach (var message in messages)
 1102            {
 1103                // The store was asked for ONE exact correlation id, but "exact" is the
 1104                // database's opinion: a case-insensitive (or accent-insensitive) column
 1105                // collation — the SQL Server default in most deployments — answers a query for
 1106                // "FOO" with the rows of "foo". Delivering those would hand one waiter another
 1107                // waiter's response, so the id is re-checked ordinally here, where the
 1108                // library's own comparison rules apply. This also covers pre-existing tables
 1109                // created before the collation was pinned in the DDL.
 7551110                if (!string.Equals(message.CorrelationId, correlationId, StringComparison.Ordinal))
 1111                {
 01112                    _logger.LogError(
 01113                        "The {Provider} channel store returned a message for correlationId '{ReturnedCorrelationId}' whe
 01114                        "The correlation-id column is not using a case-sensitive/binary collation, so distinct correlati
 01115                        "The message was NOT delivered to the wrong waiter. Re-create the AsyncResponse tables (or ALTER
 01116                        _providerName, message.CorrelationId, correlationId);
 01117                    continue;
 1118                }
 1119
 1120                // Reconciliation and last-tick overlap revisit seen headers; keep those out of
 1121                // the executor queue. The work item re-checks after admission as well.
 7551122                if (!WouldDeliverToAnySubscription(message, subscriptions))
 1123                    continue;
 1124
 861125                (eligible ??= []).Add(message);
 861126                if (message.EnvelopeJson is null)
 171127                    (headerOnly ??= []).Add(message.Id);
 1128            }
 1129
 63021130            if (eligible is not null && !await EnqueueEligibleAsync(correlationId, eligible, headerOnly, subscriptions, 
 11131                return (false, false, false); // Retry this page: never advance past refused work.
 1132
 63001133            if (messages.Count > 0)
 1134            {
 6351135                cursor.Advance(messages[^1]);
 6351136                if (!reconcile)
 6341137                    forwardReadAny = true;
 1138            }
 63001139            var reachedEnd = reconcile && messages.Any(message =>
 63011140                message.Id == scan.ReconciliationEndId || message.CreatedAtUtc > scan.ReconciliationEndUtc);
 63001141            return (messages.Count == _options.PendingMessageBatchSize, true, reachedEnd);
 63011142        }
 65261143    }
 1144
 1145    /// <summary>
 1146    /// Second pass of one sweep page: hydrates the header-only rows among <paramref name="eligible"/>
 1147    /// and admits every row to the correlation id's executor in page order. Returns <c>false</c>
 1148    /// when the executor is full (the page's remaining rows are left in the store, in order, and
 1149    /// a rescan is scheduled), which ends the correlation id's scan for this sweep.
 1150    /// </summary>
 1151    private async Task<bool> EnqueueEligibleAsync(
 1152        string correlationId,
 1153        List<DbChannelMessage> eligible,
 1154        List<Guid>? headerOnly,
 1155        List<IDbSubscription> subscriptions,
 1156        CancellationToken cancellationToken)
 1157    {
 861158        Dictionary<Guid, DbChannelMessage>? hydrated = null;
 861159        if (headerOnly is not null)
 1160        {
 171161            var loaded = await _store.LoadMessagesByIdAsync(correlationId, headerOnly, cancellationToken).ConfigureAwait
 161162            hydrated = new Dictionary<Guid, DbChannelMessage>(loaded.Count);
 641163            foreach (var message in loaded)
 1164            {
 1165                // The by-id read is exact on the id (unique), but a hydrated row must carry its
 1166                // envelope: a store that answered header-only here would hand the waiter nothing.
 161167                if (message.EnvelopeJson is not null)
 161168                    hydrated[message.Id] = message;
 1169            }
 1170        }
 1171
 3391172        foreach (var message in eligible)
 1173        {
 851174            var deliverable = message;
 851175            if (message.EnvelopeJson is null)
 1176            {
 1177                // Pruned or expired between the page read and the hydration: nothing to deliver
 1178                // now; a row that is still there is re-evaluated by the next sweep.
 161179                if (hydrated is null || !hydrated.TryGetValue(message.Id, out deliverable))
 1180                    continue;
 1181            }
 1182
 1183            // Work-item class, not a lambda: a queued closure would chain display classes
 1184            // pinning this paging frame (batch list, cursors, watermark) for as long as the
 1185            // item sits in the executor's bounded queue.
 1186            //
 1187            // NON-BLOCKING admission. This loop is the process-wide dispatch sweep and walks
 1188            // correlation ids sequentially, so waiting for ONE correlation id's executor
 1189            // capacity here (the old EnqueueAsync) parked delivery for every other waiter in
 1190            // the process: a waiter wedged in a slow Until predicate, fed a backlog of NEW
 1191            // progress messages (the pre-filter above only screens consumed history), filled
 1192            // its 1024-slot executor and the sweep then blocked on slot 1025 without ever
 1193            // querying the next correlation id. Its per-correlation backpressure became shared
 1194            // delivery blockage — unrelated remote/polled responses timed out behind it. At
 1195            // capacity the rest of this correlation id's messages are left unclaimed in the
 1196            // store, in order (nothing later is enqueued ahead of them), and a rescan of just
 1197            // this id is scheduled for when the executor has had a poll interval to drain.
 851198            var outcome = _executors.TryEnqueue(
 851199                ChannelName(correlationId),
 851200                new LocalDispatchWorkItem(this, deliverable, subscriptions, cancellationToken).InvokeAsync);
 851201            if (outcome == SerialExecutorRegistry.TryEnqueueOutcome.Full)
 1202            {
 11203                ScheduleBackpressureRescan(correlationId, cancellationToken);
 11204                return false;
 1205            }
 1206        }
 1207
 841208        return true;
 851209    }
 1210
 1211    /// <summary>
 1212    /// Would any live subscription actually take this message? Used both as the sweep's
 1213    /// pre-enqueue filter and as the dispatch work item's own guard, so the two can never drift.
 1214    /// </summary>
 1215    private static bool WouldDeliverToAnySubscription(DbChannelMessage message, IReadOnlyList<IDbSubscription> subscript
 1216    {
 49721217        foreach (var subscription in subscriptions)
 1218        {
 14071219            if (!subscription.Dropped && IsWithinWatermark(subscription, message) && !subscription.HasSeen(message.Id))
 6481220                return true;
 1221        }
 1222
 7551223        return false;
 6481224    }
 1225
 1226    private async Task PublishMessageAsync(
 1227        Guid messageId,
 1228        string correlationId,
 1229        string envelopeJson,
 1230        CancellationToken cancellationToken)
 1231    {
 1232        // The insert itself carries the remote wake where the provider has one (a NOTIFY rides the
 1233        // PostgreSQL insert; MongoDB change streams observe it) and the SQL Server sweep polls it
 1234        // up. Only the local fast path and a targeted local signal are needed on top. The store
 1235        // returns the fast-path message with the SERVER-stamped created_at: subscription
 1236        // watermarks are server-clock, and an app-clock timestamp here silently disabled the fast
 1237        // path whenever the app clock ran more than the 1s tolerance behind the database — delivery
 1238        // then quietly degraded to sweep latency on every publish. On an idempotent duplicate (a
 1239        // publish retry) it is the ORIGINAL row, settlement columns included: fabricating
 1240        // AckedAtUtc = null here bypassed IsWithinWatermark's acked-history exclusion, and a retry
 1241        // landing after another process had claimed and acked the first attempt replayed that
 1242        // consumed response to a waiter registered since — the sweep path never had the problem
 1243        // because LoadMessagesAsync reads acked_at.
 5561244        var message = await _store.InsertMessageAsync(messageId, correlationId, envelopeJson, _options.MessageRetention,
 5561245            .ConfigureAwait(false);
 5561246        await TryDispatchLocalSubscribersAsync(message, cancellationToken).ConfigureAwait(false);
 5561247        SignalDispatcher(correlationId);
 5561248    }
 1249
 1250    private protected async Task DispatchMessageToSubscribersAsync(
 1251        DbChannelMessage message,
 1252        IReadOnlyList<IDbSubscription> subscriptions,
 1253        CancellationToken cancellationToken)
 1254    {
 1255        // Only subscriptions that are still live, inside their delivery watermark, and have not
 1256        // already processed this message. Skipping when there is nothing to deliver also avoids a
 1257        // redundant claim on every re-sweep.
 6481258        if (!WouldDeliverToAnySubscription(message, subscriptions))
 861259            return;
 1260
 1261        // Take the message for live delivery. The claim sets acked_at unless the publisher already
 1262        // routed it to recovery (recovery_claimed); losing the claim means recovery owns it, so it is
 1263        // not delivered to the waiter and handled a second time.
 1264        //
 1265        // Claim-then-dispatch is deliberate — keep this ordering. The in-process handoff is
 1266        // at-most-once by design: a crash between the claim and the waiter's continuation can only
 1267        // lose delivery to waiters in THIS dying process, which no ordering could save (their
 1268        // continuations die with it), while pre-registered fan-out waiters in other processes
 1269        // still receive the acked message (IsWithinWatermark admits acked_at > started_at).
 1270        // Dispatch-then-ack behind an expiring claim would re-open the stale-redelivery wrong-data
 1271        // bug the strict acked exclusion in IsWithinWatermark closes. Durability across process
 1272        // death belongs to the layer above: flow re-execution, publish-time recovery routing, and
 1273        // the step timeout.
 5621274        if (!await _store.TryClaimForDeliveryAsync(message.Id, cancellationToken).ConfigureAwait(false))
 1275        {
 41276            foreach (var subscription in subscriptions)
 1277            {
 11278                if (!subscription.Dropped)
 11279                    subscription.MarkSeen(message.Id);
 1280            }
 11281            return;
 1282        }
 1283
 1284        // Wake the publisher immediately if it is waiting in this process — no acked_at polling needed.
 5551285        if (_pendingConfirmations.TryGetValue(message.Id, out var confirmation))
 5491286            confirmation.TrySetResult(true);
 1287
 1288        // Per-subscription isolation is load-bearing, not defensive tidiness. The claim above
 1289        // already stamped acked_at and tripped the publisher's confirmation, so this message is
 1290        // consumed: IsWithinWatermark excludes it from every later sweep and the lost-subscriber
 1291        // path will never see it. If one subscription's dispatch throws OUTSIDE ProcessAsync's own
 1292        // catch — a fault in the captured-context wrapper, or CleanupOnceAsync throwing from
 1293        // CleanupCoreAsync's uncaught finally, which replaces the swallowed exception — letting it
 1294        // propagate would strand every subscription after it in this fan-out until its step
 1295        // timeout, with the response gone. Record the first fault and rethrow only after every
 1296        // sibling has had the message, so the dispatch is still reported as failed.
 5551297        List<Exception>? failures = null;
 22301298        foreach (var subscription in subscriptions)
 1299        {
 5601300            if (subscription.Dropped || !IsWithinWatermark(subscription, message) || !subscription.MarkSeen(message.Id))
 1301                continue;
 1302
 1303            try
 1304            {
 5581305                await subscription.ProcessUnderContextAsync(message).ConfigureAwait(false);
 5581306            }
 01307            catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequeste
 1308            {
 01309                _logger.LogError(
 01310                    ex,
 01311                    "Delivering the {Provider} response for correlationId {CorrelationId} to one waiter failed; the rema
 01312                    _providerName,
 01313                    message.CorrelationId);
 01314                (failures ??= []).Add(ex);
 01315            }
 1316        }
 1317
 1318        // Always aggregated, never a bare rethrow of failures[0]: a rethrow would reset that
 1319        // exception's stack trace, and AggregateException carries every inner one intact.
 5551320        if (failures is not null)
 01321            throw new AggregateException($"Delivering the response for correlationId {message.CorrelationId} failed for 
 6421322    }
 1323
 1324    /// <summary>
 1325    /// Per-subscription delivery watermark. The sweep queries with the OLDEST waiter's watermark on
 1326    /// a shared correlation id, so without this filter a late-joining waiter would receive retained
 1327    /// messages created before it registered. Same 1s tolerance as the query watermark.
 1328    /// <para>
 1329    /// The creation-time tolerance alone re-admits history: a message created inside the 1s skew
 1330    /// window may have already been delivered and acked for a PREVIOUS waiter that reused the
 1331    /// correlation id, and per-subscription seen-tracking cannot dedupe what a different
 1332    /// subscription processed. A message acked before this subscription existed is history, not
 1333    /// delivery — waiters that legitimately participate in a delivery (including cross-process
 1334    /// fan-out) were registered before its claim stamped <c>acked_at</c>. The acked comparison is
 1335    /// deliberately strict, with no skew tolerance: under skew, strictness can only make a waiter
 1336    /// whose registration raced another process's in-flight ack keep waiting for its own response,
 1337    /// whereas a tolerance would re-open the stale-redelivery window this check closes.
 1338    /// </para>
 1339    /// <para>
 1340    /// The comparison must be STRICTLY greater, and that is load-bearing rather than stylistic: a
 1341    /// server clock's resolution is far coarser than its column precision, so equal timestamps are
 1342    /// routine, not a measure-zero tie. SQL Server stamps <c>datetime2(7)</c> from
 1343    /// <c>SYSUTCDATETIME()</c> — 100ns precision, but the clock behind it advances in ~5ms ticks
 1344    /// (measured: 30,344 samples over 300ms yielded 61 distinct values, mean gap 4.9ms), and
 1345    /// MongoDB's <c>$$NOW</c> is millisecond-resolution. A waiter that reuses a correlation id
 1346    /// within one tick of the previous waiter's ack therefore registers at exactly
 1347    /// <c>acked_at</c>, and a non-strict comparison hands it the response its predecessor already
 1348    /// consumed. Registration is ordered strictly after that ack in real time and the clock is
 1349    /// non-decreasing, so <c>acked_at &lt;= started_at</c> always holds for history and the strict
 1350    /// form excludes it deterministically — not probabilistically.
 1351    /// </para>
 1352    /// <para>
 1353    /// The same-tick equality is symmetric — it can also be a genuine cross-process fan-out
 1354    /// delivery (this waiter registered and another process's claim stamped <c>acked_at</c>
 1355    /// inside one clock tick) — and no timestamp can separate the two cases. The store's
 1356    /// monotonic ack sequence arbitrates that tie (and only that tie): every delivery claim
 1357    /// stamps <c>acked_seq</c> drawn from the same monotonic source the subscription drew
 1358    /// <c>StartedSeq</c> from at registration. The arbitration is conservative-exact — exact
 1359    /// whenever the claim's draw was not stalled across ticks; the stalled-draw residual below
 1360    /// resolves as history.
 1361    /// </para>
 1362    /// <para>
 1363    /// The sequence deliberately does NOT outrank truthful (unequal) timestamps. A claim's
 1364    /// sequence value is drawn BEFORE the claim becomes visible — MongoDB draws from a separate
 1365    /// counter document, SQL Server in a <c>DECLARE</c> ahead of an <c>UPDATE</c> that may block
 1366    /// on a row lock, and even PostgreSQL's <c>nextval</c> evaluates before the commit — so a
 1367    /// claim can draw <c>41</c>, stall, and land AFTER a waiter registered at <c>42</c>. Ranking
 1368    /// the sequence above timestamps would exclude that delivery as history even though
 1369    /// <c>acked_at</c> truthfully post-dates the registration tick. With timestamps primary, the
 1370    /// stalled claim lands in a LATER tick and is delivered by the timestamp rule; the sequence
 1371    /// is consulted only when the tick is identical.
 1372    /// </para>
 1373    /// <para>
 1374    /// Inside the tie the sequence can never replay history: a claim visible before a same-tick
 1375    /// registration drew its value before that visibility, hence before the registration's own
 1376    /// draw — <c>acked_seq &lt; StartedSeq</c> — and is excluded. The only residual conservatism
 1377    /// is a claim whose draw-to-execution stall ends exactly in the registration's tick: it
 1378    /// resolves as history, which is the same verdict the timestamp-only rule gave every tie —
 1379    /// never worse, and exact whenever draws are not stalled (the overwhelmingly common case).
 1380    /// Rows acked by a pre-sequence build carry no <c>acked_seq</c> and keep the old at-most-once
 1381    /// tie resolution (excluded fan-out recovers through its step timeout and the
 1382    /// idempotent-restart contract).
 1383    /// </para>
 1384    /// </summary>
 1385    private static bool IsWithinWatermark(IDbSubscription subscription, DbChannelMessage message)
 1386    {
 15101387        if (message.CreatedAtUtc < subscription.StartedAtUtc.AddSeconds(-1))
 21388            return false;
 1389
 15081390        if (message.AckedAtUtc is null)
 12301391            return true;
 1392
 1393        // Timestamps are primary: strictly later tick = delivered, strictly earlier = history.
 2781394        if (message.AckedAtUtc > subscription.StartedAtUtc)
 2621395            return true;
 161396        if (message.AckedAtUtc < subscription.StartedAtUtc)
 91397            return false;
 1398
 1399        // Same-tick tie: the monotonic ack sequence arbitrates when the claim carries one.
 71400        if (message.AckedSeq is { } ackedSeq)
 51401            return ackedSeq > subscription.StartedSeq;
 1402
 1403        // Legacy tie (row acked by a pre-sequence build): the old conservative resolution.
 21404        return false;
 1405    }
 1406
 1407    private async Task TryDispatchLocalSubscribersAsync(DbChannelMessage message, CancellationToken cancellationToken)
 1408    {
 5601409        if (!_subscriptions.TryGetValue(message.CorrelationId, out var group))
 61410            return;
 1411
 5541412        var subscriptions = new List<IDbSubscription>(group.Count);
 22221413        foreach (var subscription in group.Values)
 1414        {
 5571415            if (!subscription.Dropped)
 5551416                subscriptions.Add(subscription);
 1417        }
 5541418        if (subscriptions.Count == 0)
 21419            return;
 1420
 1421        // Same-process fast path: skips the wake round trip / sweep latency but still runs on the
 1422        // per-correlation serial executor — completion predicates are guaranteed serial, in-order
 1423        // invocation on every channel, and a direct dispatch here could otherwise run concurrently
 1424        // with a sweep-enqueued dispatch of a different message for the same subscription. MarkSeen
 1425        // keeps the sweep from double-processing this message.
 5521426        await _executors.EnqueueAsync(
 5521427            ChannelName(message.CorrelationId),
 5521428            new LocalDispatchWorkItem(this, message, subscriptions, cancellationToken).InvokeAsync,
 5521429            cancellationToken).ConfigureAwait(false);
 5601430    }
 1431
 1432    /// <summary>
 1433    /// Registers an in-process delivery completion for a message id. Disposing it removes the entry,
 1434    /// so a publish that throws or completes never leaks the registration.
 1435    /// </summary>
 1436    private protected PendingConfirmation BeginConfirmation(Guid messageId)
 1437    {
 5601438        var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
 5601439        _pendingConfirmations[messageId] = tcs;
 5601440        return new PendingConfirmation(this, messageId, tcs);
 1441    }
 1442
 1443    /// <summary>
 1444    /// Confirms a published response reached a live waiter. Returns <c>true</c> once a waiter has
 1445    /// acknowledged it; on confirmation timeout, atomically claims the message for the lost-subscriber
 1446    /// path and returns <c>false</c> only if that claim wins — so the recovery callback and a
 1447    /// slow-but-live waiter are mutually exclusive.
 1448    /// </summary>
 1449    private protected async Task<bool> TryConfirmDeliveryAsync(PendingConfirmation confirmation, CancellationToken cance
 1450    {
 5581451        if (await WaitForAcknowledgementAsync(confirmation, cancellationToken).ConfigureAwait(false))
 5521452            return true;
 1453
 41454        return !await _store.TryClaimForRecoveryAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false);
 5561455    }
 1456
 16461457    private protected void SignalDispatcher(string? correlationId = null) => _signals.Writer.TryWrite(correlationId);
 1458
 1459    /// <summary>
 1460    /// Correlation ids whose executor was at capacity during a sweep and that have a rescan
 1461    /// pending. One pending rescan per id: a saturated id is re-signalled once per poll interval,
 1462    /// not once per sweep that found it full.
 1463    /// </summary>
 4191464    private readonly ConcurrentDictionary<string, byte> _backpressureRescans = new(StringComparer.Ordinal);
 1465
 1466    /// <summary>
 1467    /// Re-signals a targeted scan of <paramref name="correlationId"/> after one poll interval —
 1468    /// the time the sweep would otherwise have waited for the saturated executor, spent letting
 1469    /// every other correlation id deliver instead. The messages themselves stay in the store
 1470    /// (unclaimed, unseen) until that scan enqueues them, in their original order.
 1471    /// </summary>
 1472    private void ScheduleBackpressureRescan(string correlationId, CancellationToken cancellationToken)
 1473    {
 11474        if (!_backpressureRescans.TryAdd(correlationId, 0))
 01475            return;
 1476
 11477        if (_logger.IsEnabled(LogLevel.Debug))
 1478        {
 01479            _logger.LogDebug(
 01480                "{Provider} dispatch for correlationId {CorrelationId} is at executor capacity; the remaining messages a
 01481                _providerName, correlationId);
 1482        }
 1483
 11484        _ = RescanAfterDelayAsync(correlationId, cancellationToken);
 11485    }
 1486
 1487    private async Task RescanAfterDelayAsync(string correlationId, CancellationToken cancellationToken)
 1488    {
 1489        try
 1490        {
 11491            await Task.Delay(CurrentPollInterval(), cancellationToken).ConfigureAwait(false);
 01492        }
 11493        catch (OperationCanceledException)
 1494        {
 1495            // Listener stopping: nothing to rescan for.
 11496        }
 1497        finally
 1498        {
 11499            _backpressureRescans.TryRemove(correlationId, out _);
 1500        }
 1501
 11502        if (!cancellationToken.IsCancellationRequested)
 01503            SignalDispatcher(correlationId);
 11504    }
 1505
 1506    private async Task<bool> WaitForAcknowledgementAsync(PendingConfirmation confirmation, CancellationToken cancellatio
 1507    {
 1508        // MONOTONIC, on the injected clock: the confirmation budget is a pure interval, and it used
 1509        // to be a wall-clock deadline (GetUtcNow() + timeout). A system clock stepped forward while
 1510        // a publish waited here — an NTP correction, a VM resumed or migrated — made `remaining`
 1511        // non-positive at once, the loop body never ran, and TryConfirmDeliveryAsync went straight
 1512        // to TryClaimForRecoveryAsync: the message was claimed for lost-subscriber recovery under
 1513        // a live waiter the dispatch loop was about to deliver it to. This is the same rule the
 1514        // poll deadlines below already follow (_pollArmedAt); TimeProvider's timestamp keeps the
 1515        // wait drivable by a virtual clock, which a raw Stopwatch would not.
 5601516        var startedAt = _timeProvider.GetTimestamp();
 6531517        TimeSpan Remaining() => _options.DeliveryConfirmationTimeout - _timeProvider.GetElapsedTime(startedAt);
 1518
 1519        // One `remaining` computation drives both the loop condition and the poll delay: the old
 1520        // shape tested the deadline twice, one line apart, so the code read as if two different
 1521        // conditions mattered when the second could only ever agree with the first.
 1522        //
 1523        // The fast-path wait is a WaitAsync on the confirmation rather than a fresh Task.Delay
 1524        // raced by WhenAny. WhenAny abandoned its loser every iteration, so the overwhelmingly
 1525        // common same-process delivery left a live timer entry and a registration on the caller's
 1526        // token behind on every publish; WaitAsync tears its timer down when the confirmation
 1527        // wins. A lapsed poll interval surfaces as TimeoutException, which is the loop condition,
 1528        // not a failure.
 5601529        for (var remaining = Remaining();
 6531530             remaining > TimeSpan.Zero;
 931531             remaining = Remaining())
 1532        {
 6491533            var pollDelay = remaining < _options.DeliveryConfirmationPollInterval
 6491534                ? remaining
 6491535                : _options.DeliveryConfirmationPollInterval;
 1536
 1537            try
 1538            {
 1539                // Fast path: an in-process delivery trips the completion and we return without a query.
 6491540                return await confirmation.Delivered.WaitAsync(pollDelay, _timeProvider, cancellationToken).ConfigureAwai
 1541            }
 1301542            catch (TimeoutException)
 1543            {
 1544                // Nothing local within this poll interval; fall through to the store check.
 1301545            }
 1546
 1547            // Slow path: a delivery in another process only set acked_at, so poll for it.
 1301548            if (await _store.IsMessageAcknowledgedAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false)
 351549                return true;
 1550        }
 1551
 41552        return confirmation.Delivered.IsCompletedSuccessfully
 41553            || await _store.IsMessageAcknowledgedAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false);
 5581554    }
 1555
 1556    private static string SerializeRawSuccessEnvelope(string payloadJson)
 1557    {
 201558        JsonSafety.ThrowIfClearlyNotJson(payloadJson);
 1559
 201560        var buffer = new ArrayBufferWriter<byte>();
 201561        using (var writer = new Utf8JsonWriter(buffer))
 1562        {
 201563            writer.WriteStartObject();
 201564            writer.WriteNumber("SchemaVersion", AsyncResponseEnvelopeSchema.Current);
 201565            writer.WriteBoolean("Success", true);
 201566            writer.WritePropertyName("Payload");
 201567            writer.WriteRawValue(payloadJson);
 201568            writer.WriteNull("ExceptionMessage");
 201569            writer.WriteNull("ExceptionStackTrace");
 201570            writer.WriteEndObject();
 201571        }
 1572
 201573        return Encoding.UTF8.GetString(buffer.WrittenSpan);
 1574    }
 1575
 1576    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 1577    private static void OnWaiterTimeout(object? state)
 1578        => ((IWaiterTimeoutState)state!).Schedule();
 1579
 1580    private async Task HandleWaiterTimeoutAsync<T>(
 1581        DbSubscription<T> subscription,
 1582        Activity? activity,
 1583        string correlationId) where T : IAsyncResponsePayload
 1584    {
 51585        _logger.LogWarning("Timed out waiting for {Provider} response for correlationId {CorrelationId}.", _providerName
 51586        AsyncResponseDiagnostics.SetError(activity, "timeout", $"Timed out waiting for response for correlationId {corre
 51587        AsyncResponseDiagnostics.RecordWaiterTimeout(_activityTag);
 51588        await subscription.DrainThenCleanupAsync(
 51589            deleteRecoveryState: true,
 51590            new TimeoutException($"Timed out waiting for response for correlationId {correlationId}.")).ConfigureAwait(f
 51591    }
 1592
 1593    private interface IWaiterTimeoutState
 1594    {
 1595        void Schedule();
 1596    }
 1597
 1598    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 1599    private sealed class WaiterTimeoutState<T>(
 1600        DbAsyncResponseChannelBase owner,
 1601        DbSubscription<T> subscription,
 1602        Activity? activity,
 1603        string correlationId) : IWaiterTimeoutState where T : IAsyncResponsePayload
 1604    {
 1605        public void Schedule()
 1606            => _ = Task.Run(async () =>
 1607            {
 1608                try
 1609                {
 1610                    await owner.HandleWaiterTimeoutAsync(subscription, activity, correlationId).ConfigureAwait(false);
 1611                }
 1612                catch (Exception ex)
 1613                {
 1614                    // Fire-and-forget: nothing awaits this task, so an escaped fault would vanish.
 1615                    owner._logger.LogError(ex, "Error handling {Provider} waiter timeout for correlationId {CorrelationI
 1616                }
 1617            });
 1618    }
 1619
 1620    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 1621    private sealed class LocalDispatchWorkItem(
 1622        DbAsyncResponseChannelBase owner,
 1623        DbChannelMessage message,
 1624        IReadOnlyList<IDbSubscription> subscriptions,
 1625        CancellationToken cancellationToken)
 1626    {
 1627        public async Task InvokeAsync()
 1628        {
 1629            try
 1630            {
 1631                await owner.DispatchMessageToSubscribersAsync(message, subscriptions, cancellationToken).ConfigureAwait(
 1632            }
 1633            catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequeste
 1634            {
 1635                if (owner._subscriptions.TryGetValue(message.CorrelationId, out var group)
 1636                    && owner._dispatchScans.TryGetValue(group, out var scan))
 1637                {
 1638                    Interlocked.Exchange(ref scan.RewindRequested, 1);
 1639                    owner.ScheduleBackpressureRescan(message.CorrelationId, cancellationToken);
 1640                }
 1641                owner._logger.LogDebug(
 1642                    ex,
 1643                    "Local {Provider} response dispatch failed for correlationId {CorrelationId}; {RetryHint}.",
 1644                    owner._providerName,
 1645                    message.CorrelationId,
 1646                    owner._localDispatchRetryHint);
 1647            }
 1648        }
 1649    }
 1650
 1651    /// <inheritdoc />
 1652    public async ValueTask DisposeAsync()
 1653    {
 1654        CancellationTokenSource? cts;
 1655        Task? listenTask;
 1656        Task? dispatchTask;
 1657        Task? heartbeatTask;
 21281658        lock (_listenerGate)
 1659        {
 1660            // Set under the gate so EnsureListenerStarted can never observe "not disposed" and
 1661            // then recreate the CTS/loops this teardown is about to stop.
 21281662            _disposed = true;
 21281663            cts = _listenerCts;
 21281664            listenTask = _listenTask;
 21281665            dispatchTask = _dispatchTask;
 21281666            heartbeatTask = _heartbeatTask;
 21281667            _listenerCts = null;
 21281668            _listenTask = null;
 21281669            _dispatchTask = null;
 21281670            _heartbeatTask = null;
 21281671        }
 1672
 21281673        if (cts is not null)
 1674        {
 3641675            await cts.CancelAsync().ConfigureAwait(false);
 1676            try
 1677            {
 3641678                await Task.WhenAll(new[] { listenTask, dispatchTask, heartbeatTask }.OfType<Task>()).ConfigureAwait(fals
 3451679            }
 191680            catch (OperationCanceledException)
 1681            {
 191682            }
 3641683            cts.Dispose();
 1684        }
 1685
 43001686        foreach (var (correlationId, group) in _subscriptions.ToArray())
 1687        {
 881688            foreach (var subscription in group.Values.ToArray())
 221689                await subscription.DrainThenCleanupAsync(deleteRecoveryState: false).ConfigureAwait(false);
 221690            await _executors.RemoveAsync(ChannelName(correlationId)).ConfigureAwait(false);
 221691        }
 1692
 1693        // Retirements for subscriptions that cleaned themselves up (a response landing during
 1694        // shutdown) unlink their correlation id before scheduling, so the loop above never sees
 1695        // them. Awaiting them here is what makes disposal mean "every executor is retired" rather
 1696        // than "every executor still in the map is retired". Their bodies swallow, so this cannot
 1697        // throw; the drain budgets inside RemoveAsync bound how long it can take.
 21281698        var retirements = _pendingRetirements.Keys.ToArray();
 21281699        if (retirements.Length > 0)
 71700            await Task.WhenAll(retirements).ConfigureAwait(false);
 21281701    }
 1702
 1703    /// <summary>Scopes an in-process delivery completion; <see cref="Dispose"/> unregisters it.</summary>
 1704    private protected readonly struct PendingConfirmation(
 1705        DbAsyncResponseChannelBase owner,
 1706        Guid messageId,
 1707        TaskCompletionSource<bool> tcs) : IDisposable
 1708    {
 1381709        public Guid MessageId => messageId;
 6531710        public Task<bool> Delivered => tcs.Task;
 5571711        public void Dispose() => owner._pendingConfirmations.TryRemove(messageId, out _);
 1712    }
 1713
 1714    private protected interface IDbSubscription
 1715    {
 1716        Guid Id { get; }
 1717        DateTimeOffset StartedAtUtc { get; }
 1718        long StartedSeq { get; }
 1719        bool Dropped { get; }
 1720        Func<DbChannelMessage, Task> ProcessUnderContextAsync { get; set; }
 1721        bool HasSeen(Guid messageId);
 1722        bool MarkSeen(Guid messageId);
 1723        void PruneSeen(DateTimeOffset cutoffUtc);
 1724        Task ProcessAsync(DbChannelMessage message);
 1725        ValueTask CleanupOnceAsync(bool deleteRecoveryState);
 1726        ValueTask DrainThenCleanupAsync(bool deleteRecoveryState, Exception? terminalIfUndelivered = null);
 1727        ValueTask DropLocalAsync(CancellationToken cancellationToken);
 1728    }
 1729
 1730    private sealed class DbSubscription<T> : IDbSubscription where T : IAsyncResponsePayload
 1731    {
 1732        private readonly DbAsyncResponseChannelBase _owner;
 1733        private readonly string _correlationId;
 1734        private readonly Func<T, ValueTask<bool>> _completionPredicate;
 1735        private readonly TaskCompletionSource<T> _tcs;
 1736        private readonly Activity? _activity;
 4981737        private readonly HashSet<Guid> _seen = [];
 4981738        private readonly Queue<(Guid Id, DateTimeOffset SeenAtUtc)> _seenOrder = [];
 4981739        private readonly object _seenGate = new();
 1740        private int _cleanupStarted;
 1741        private volatile bool _dropped;
 1742
 1743        /// <summary>True once cleanup began — the arm-last waiter-timeout guard reads this.</summary>
 4561744        internal bool CleanupStarted => Volatile.Read(ref _cleanupStarted) != 0;
 4981745        private readonly object _cleanupGate = new();
 1746        private Task? _cleanupTask;
 1747
 4981748        public DbSubscription(
 4981749            DbAsyncResponseChannelBase owner,
 4981750            string correlationId,
 4981751            Guid registrationId,
 4981752            DateTimeOffset startedAtUtc,
 4981753            long startedSeq,
 4981754            Func<T, ValueTask<bool>> completionPredicate,
 4981755            TaskCompletionSource<T> tcs,
 4981756            Activity? activity)
 1757        {
 4981758            _owner = owner;
 4981759            _correlationId = correlationId;
 4981760            Id = registrationId;
 4981761            StartedAtUtc = startedAtUtc;
 4981762            StartedSeq = startedSeq;
 4981763            _completionPredicate = completionPredicate;
 4981764            _tcs = tcs;
 4981765            _activity = activity;
 4981766            ProcessUnderContextAsync = ProcessAsync;
 4981767        }
 1768
 107681769        public Guid Id { get; }
 144921770        public DateTimeOffset StartedAtUtc { get; }
 51771        public long StartedSeq { get; }
 142051772        public bool Dropped => _dropped;
 13741773        public Func<ValueTask>? TimeoutRegistration { get; set; }
 9181774        public CancellationTokenSource? TimeoutCancellation { get; set; }
 15121775        public Func<DbChannelMessage, Task> ProcessUnderContextAsync { get; set; }
 1776
 1777        public bool HasSeen(Guid messageId)
 1778        {
 9391779            lock (_seenGate)
 1780            {
 9391781                return _seen.Contains(messageId);
 1782            }
 9391783        }
 1784
 1785        public bool MarkSeen(Guid messageId)
 1786        {
 5651787            lock (_seenGate)
 1788            {
 5651789                if (!_seen.Add(messageId))
 31790                    return false;
 1791
 1792                // Use the local observation time, not the database creation time. This keeps the
 1793                // pruning queue monotonic and avoids immediate eviction when app and DB clocks differ.
 1794                // It MUST come from the same clock PruneSeen's cutoff is computed on: mixing a
 1795                // wall-clock stamp with a TimeProvider cutoff makes every entry look either
 1796                // permanently fresh or permanently expired under a virtual clock.
 5621797                _seenOrder.Enqueue((messageId, _owner._timeProvider.GetUtcNow()));
 5621798                return true;
 1799            }
 5651800        }
 1801
 1802        public void PruneSeen(DateTimeOffset cutoffUtc)
 1803        {
 63481804            lock (_seenGate)
 1805            {
 63501806                while (_seenOrder.TryPeek(out var entry) && entry.SeenAtUtc < cutoffUtc)
 1807                {
 21808                    _seenOrder.Dequeue();
 21809                    _seen.Remove(entry.Id);
 21810                }
 63481811            }
 63481812        }
 1813
 1814        public async Task ProcessAsync(DbChannelMessage message)
 1815        {
 5741816            if (_dropped)
 21817                return;
 1818
 5721819            var finished = false;
 1820            try
 1821            {
 1822                // JsonSafety, not the raw reader: a parse failure is logged below and handed to the
 1823                // waiter, and the reader's own message quotes inbound property names and dictionary
 1824                // keys (docs/security.md, "never logs a message body"). Size and position only.
 1825                // A header-only sweep row never reaches delivery: the sweep hydrates it first.
 5721826                var envelopeJson = message.EnvelopeJson
 5721827                    ?? throw new InvalidOperationException($"The {_owner._providerName} channel message {message.Id} rea
 5721828                var envelope = JsonSafety.SafeDeserialize(envelopeJson, AsyncResponseEnvelopeJson.TypeInfo<T>());
 5671829                if (envelope is null)
 1830                {
 31831                    finished = true;
 31832                    var error = new JsonException($"Failed to deserialize envelope for correlationId {_correlationId}.")
 31833                    AsyncResponseDiagnostics.SetError(_activity, "deserialize_failure", error.Message);
 31834                    _tcs.TrySetException(error);
 1835                }
 5641836                else if (!AsyncResponseEnvelopeSchema.IsReadable(envelope.SchemaVersion))
 1837                {
 31838                    finished = true;
 31839                    var error = new InvalidOperationException(
 31840                        $"Response envelope for correlationId {_correlationId} has schema version {envelope.SchemaVersio
 31841                        $"which this build does not support (current: {AsyncResponseEnvelopeSchema.Current}).");
 31842                    AsyncResponseDiagnostics.SetError(_activity, "schema_mismatch", error.Message);
 31843                    _tcs.TrySetException(error);
 1844                }
 5611845                else if (!envelope.Success)
 1846                {
 51847                    finished = true;
 51848                    var remoteFailure = new Exception(envelope.ExceptionMessage ?? "Unknown error during asynchronous pr
 51849                    if (!string.IsNullOrEmpty(envelope.ExceptionStackTrace))
 31850                        remoteFailure.Data["RemoteStackTrace"] = RemoteStackTrace.Cap(envelope.ExceptionStackTrace, _own
 51851                    AsyncResponseDiagnostics.SetError(_activity, "remote_failure", remoteFailure.Message);
 51852                    _tcs.TrySetException(remoteFailure);
 1853                }
 1854                else
 1855                {
 5561856                    finished = await _completionPredicate(envelope.Payload!).ConfigureAwait(false);
 5551857                    if (finished)
 4371858                        _tcs.TrySetResult(envelope.Payload!);
 1859                }
 5661860            }
 61861            catch (Exception ex)
 1862            {
 61863                finished = true;
 61864                _owner._logger.LogError(ex, "Error processing {Provider} response for correlationId {CorrelationId}.", _
 61865                AsyncResponseDiagnostics.SetError(_activity, ex);
 61866                _tcs.TrySetException(ex);
 61867            }
 1868            finally
 1869            {
 5721870                if (finished)
 4541871                    await CleanupOnceAsync(deleteRecoveryState: true).ConfigureAwait(false);
 1872            }
 5741873        }
 1874
 1875        /// <summary>
 1876        /// Task-latched so EVERY caller completes only when the one real cleanup has finished —
 1877        /// a fire-once flag alone would let a disposing waiter racing the timeout return before
 1878        /// the response task was settled.
 1879        /// </summary>
 1880        public ValueTask CleanupOnceAsync(bool deleteRecoveryState)
 1881        {
 1882            Task task;
 9461883            lock (_cleanupGate)
 1884            {
 9461885                task = _cleanupTask ??= CleanupCoreAsync(deleteRecoveryState);
 9461886            }
 1887
 9461888            return task.IsCompletedSuccessfully ? ValueTask.CompletedTask : new ValueTask(task);
 1889        }
 1890
 1891        /// <summary>
 1892        /// Dispose-path cleanup: DRAINS the per-correlation serial executor before settling. A
 1893        /// delivery may be mid <c>Until</c>-predicate holding a message the claim already acked;
 1894        /// the marker work item completes only after that in-flight item finished, so by the time
 1895        /// cleanup cancels, the task is either settled by the delivery or genuinely undelivered —
 1896        /// never a cancellation stealing a consumed response. Must NOT be called from dispatch
 1897        /// code (which runs ON the executor): the dispatch-triggered cleanup calls
 1898        /// <see cref="CleanupOnceAsync"/> directly, its task already settled.
 1899        /// <para>
 1900        /// The drain is bounded by <c>DisposalDrainTimeout</c> — a single budget covering marker
 1901        /// ADMISSION too, since a full bounded queue behind a wedged item blocks the enqueue
 1902        /// itself. A lapsed budget must not fall back to the cleanup's cancel: the wedged delivery
 1903        /// holds a message the claim already consumed, and "canceled" would tell a re-attaching
 1904        /// caller nothing was delivered. It faults the task with the explicit indeterminate
 1905        /// contract instead, routing durable flows to a fresh idempotent restart. An enqueue
 1906        /// suppressed by the registry's tombstone is the opposite case — the retired executor
 1907        /// finished everything it ever admitted, so nothing is in flight and the plain cancel
 1908        /// below is truthful.
 1909        /// </para>
 1910        /// </summary>
 1911        public async ValueTask DrainThenCleanupAsync(bool deleteRecoveryState, Exception? terminalIfUndelivered = null)
 1912        {
 4901913            if (Volatile.Read(ref _cleanupStarted) == 0)
 1914            {
 241915                var drainTimeout = _owner._options.DisposalDrainTimeout;
 241916                var drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 1917                try
 1918                {
 241919                    using var budget = new CancellationTokenSource(drainTimeout);
 241920                    var accepted = await _owner._executors.EnqueueAsync(_owner.ChannelName(_correlationId), () =>
 241921                    {
 241922                        drained.TrySetResult();
 241923                        return Task.CompletedTask;
 241924                    }, budget.Token).ConfigureAwait(false);
 241925                    if (accepted)
 241926                        await drained.Task.WaitAsync(budget.Token).ConfigureAwait(false);
 231927                }
 11928                catch (Exception drainEx)
 1929                {
 1930                    // Budget lapse — or an unforeseen drain failure: either way the marker never
 1931                    // ran, so an in-flight delivery cannot be ruled out (only accepted=false
 1932                    // proves the executor finished everything). Settlement unproven means the
 1933                    // cleanup's cancel below would be a false "nothing was delivered" — fault
 1934                    // with the explicit indeterminate contract instead. A TrySetResult from the
 1935                    // late-finishing dispatch loses against this and is dropped; its cleanup
 1936                    // call is a no-op behind the latch.
 11937                    _owner._logger.LogWarning(
 11938                        "Disposal drain for {Provider} correlationId {CorrelationId} did not prove settlement within {Dr
 11939                        _owner._providerName, _correlationId, drainTimeout);
 11940                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain did not prove
 11941                    if (drainEx is not OperationCanceledException)
 01942                        _owner._logger.LogDebug(drainEx, "Dispatch drain failed for correlationId {CorrelationId}.", _co
 11943                    _tcs.TrySetException(new AsyncResponseIndeterminateDeliveryException(_correlationId, drainTimeout));
 11944                }
 241945            }
 1946
 1947            // Settle AFTER the drain, never before it. A delivery already inside the per-correlation
 1948            // executor may hold a message the claim acked — the publisher was told "delivered" and
 1949            // the watermark excludes it from every later sweep, so it exists nowhere else. Faulting
 1950            // first let a timeout beat that in-flight delivery and report a consumed response as a
 1951            // timeout; TrySet loses here if the delivery won, which is the whole point. (A lapsed
 1952            // drain budget has already faulted the task as indeterminate above, and TrySet is a
 1953            // no-op behind it.)
 4901954            if (terminalIfUndelivered is not null)
 91955                _tcs.TrySetException(terminalIfUndelivered);
 1956
 4901957            await CleanupOnceAsync(deleteRecoveryState).ConfigureAwait(false);
 4901958        }
 1959
 1960        private async Task CleanupCoreAsync(bool deleteRecoveryState)
 1961        {
 1962            // The flag is kept alongside the task latch: dispatch cores and white-box tests gate
 1963            // on it, and a pre-set flag (test isolation) must keep skipping the network cleanup.
 4911964            if (Interlocked.Exchange(ref _cleanupStarted, 1) != 0)
 291965                return;
 1966
 1967            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 1968            // forever for callers that hold it directly — the timeout dies with this cleanup, so
 1969            // nothing else could ever complete the task. This also covers channel DisposeAsync at
 1970            // host shutdown, which runs this cleanup over every in-flight subscription and would
 1971            // otherwise hang still-awaiting WaitAsync callers. Cancellation is a no-op after a
 1972            // normal completion, timeout, fault, or a delivery drained by DrainThenCleanupAsync.
 4621973            _tcs.TrySetCanceled();
 1974
 1975            try
 1976            {
 4621977                _dropped = true;
 1978
 1979                try
 1980                {
 1981                    // Delete the recovery state BEFORE removing the subscription (locally and in the
 1982                    // subscriber store). In the reverse order a publish landing in the window sees
 1983                    // "no subscriber, state present" and fires a spurious recovery callback for a wait
 1984                    // that already reached a terminal state. In this order the window shows a
 1985                    // subscriber that drops the message — a late or duplicate terminal message is
 1986                    // droppable; a resurrected recovery callback is not. (Shutdown/redeploy paths pass
 1987                    // deleteRecoveryState: false and keep the state for lost-subscriber recovery.)
 4621988                    if (deleteRecoveryState)
 4601989                        await _owner._recoveryStateStore.TryDeleteAsync(_correlationId, Id).ConfigureAwait(false);
 4601990                }
 21991                catch (Exception ex)
 1992                {
 1993                    // Best-effort: the state expires on its own, and a transient store failure must
 1994                    // not skip the local teardown below.
 21995                    _owner._logger.LogError(ex, "Failed to delete {Provider} recovery state for correlationId {Correlati
 21996                }
 1997
 1998                try
 1999                {
 4622000                    await _owner._store.DeleteSubscriberAsync(_correlationId, Id, CancellationToken.None).ConfigureAwait
 4562001                }
 62002                catch (Exception ex)
 2003                {
 2004                    // Best-effort: an orphaned subscriber record ages out via the heartbeat timeout.
 62005                    _owner._logger.LogError(ex, "Failed to delete {Provider} subscriber {SubscriberRecord} for correlati
 62006                }
 2007            }
 2008            finally
 2009            {
 2010                // Purely local teardown runs no matter which network call above failed — the
 2011                // cleanup latch is already set, so a skipped removal would leak the subscription
 2012                // map entry and the executor until process exit.
 4622013                _owner.RemoveSubscription(_correlationId, Id);
 2014
 2015                // Schedule the executor retirement on the thread pool; do not await directly —
 2016                // dispatch-loop deliveries run this cleanup ON the executor, and RemoveAsync waits
 2017                // for the executor's drain loop to finish, which would be a circular await.
 2018                // TRACKED, though: RemoveSubscription above already unlinked this correlation id,
 2019                // so DisposeAsync's own retirement loop will not see it, and an untracked
 2020                // retirement could still be inside its 30-second drain budget when the host tears
 2021                // down the logger and exits — logging into a disposed logger, or being killed
 2022                // mid-drain. DisposeAsync awaits whatever is still outstanding here.
 4622023                var channelName = _owner.ChannelName(_correlationId);
 4622024                _owner.TrackRetirement(Task.Run(async () =>
 4622025                {
 4622026                    try
 4622027                    {
 4622028                        await _owner._executors.RemoveAsync(channelName).ConfigureAwait(false);
 4622029                    }
 02030                    catch (Exception ex)
 4622031                    {
 02032                        _owner._logger.LogError(ex, "Failed to retire the executor for channel {Channel}.", channelName)
 02033                    }
 9242034                }));
 2035
 4622036                if (TimeoutRegistration is not null)
 4562037                    await TimeoutRegistration().ConfigureAwait(false);
 4622038                TimeoutCancellation?.Dispose();
 4622039                _activity?.Dispose();
 2040            }
 4912041        }
 2042
 2043        public async ValueTask DropLocalAsync(CancellationToken cancellationToken)
 2044        {
 42045            _dropped = true;
 42046            await _owner._store.DeleteSubscriberAsync(_correlationId, Id, cancellationToken).ConfigureAwait(false);
 42047        }
 2048    }
 2049}

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory,AsyncResponse.Channels.SqlServer.SqlServerChannelSql,AsyncResponse.IRecoveryStateStore,AsyncResponse.Channels.SqlServer.SqlServerAsyncResponseChannelOptions,AsyncResponse.AsyncResponseContextPropagation,Microsoft.Extensions.Logging.ILogger,System.String,System.String,System.String,System.String,System.String,System.TimeProvider)
StartWakeListener(System.Threading.CancellationToken)
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>)
CreateResponseWaiterCore()
Process()
ProcessUnderCapturedContextAsync()
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()
PublishResponseWithRecoveryAsync()
DispatchToRecoveryAsync()
SetException()
CountActiveSubscribersAsync()
DropLocalSubscriptionsAsync()
HasLiveSubscriberAsync()
AddSubscription(System.String,AsyncResponse.Channels.DbAsyncResponseChannelBase/IDbSubscription)
RemoveSubscription(System.String,System.Guid)
UnlinkIfEmpty(System.String,System.Collections.Concurrent.ConcurrentDictionary`2<System.Guid,AsyncResponse.Channels.DbAsyncResponseChannelBase/IDbSubscription>)
TrackRetirement(System.Threading.Tasks.Task)
CurrentFullSweepInterval()
ThrowIfDisposed()
EnsureListenerStarted()
HeartbeatLoopAsync()
SnapshotActiveRegistrations()
DeleteRegistrationsDroppedDuringHeartbeatAsync()
IsRegistrationLive(System.String,System.Guid)
DispatchLoopAsync()
CollectDispatchScopeAsync()
.cctor()
DispatchPendingMessagesAsync()
Advance(AsyncResponse.Channels.SqlServer.SqlServerChannelMessage)
.ctor()
DispatchPendingCorrelationAsync()
DispatchPageAsync()
EnqueueEligibleAsync()
WouldDeliverToAnySubscription(AsyncResponse.Channels.SqlServer.SqlServerChannelMessage,System.Collections.Generic.IReadOnlyList`1<AsyncResponse.Channels.DbAsyncResponseChannelBase/IDbSubscription>)
PublishMessageAsync()
DispatchMessageToSubscribersAsync()
IsWithinWatermark(AsyncResponse.Channels.DbAsyncResponseChannelBase/IDbSubscription,AsyncResponse.Channels.SqlServer.SqlServerChannelMessage)
TryDispatchLocalSubscribersAsync()
BeginConfirmation(System.Guid)
TryConfirmDeliveryAsync()
SignalDispatcher(System.String)
ScheduleBackpressureRescan(System.String,System.Threading.CancellationToken)
RescanAfterDelayAsync()
WaitForAcknowledgementAsync()
Remaining()
SerializeRawSuccessEnvelope(System.String)
HandleWaiterTimeoutAsync()
DisposeAsync()
get_MessageId()
get_Delivered()
Dispose()
.ctor(AsyncResponse.Channels.DbAsyncResponseChannelBase,System.String,System.Guid,System.DateTimeOffset,System.Int64,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Threading.Tasks.TaskCompletionSource`1<T>,System.Diagnostics.Activity)
get_CleanupStarted()
get_Id()
get_StartedAtUtc()
get_StartedSeq()
get_Dropped()
get_TimeoutRegistration()
get_TimeoutCancellation()
get_ProcessUnderContextAsync()
HasSeen(System.Guid)
MarkSeen(System.Guid)
PruneSeen(System.DateTimeOffset)
ProcessAsync()
CleanupOnceAsync(System.Boolean)
DrainThenCleanupAsync()
CleanupCoreAsync()
<CleanupCoreAsync()
DropLocalAsync()