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

Information
Class: AsyncResponse.Channels.DbAsyncResponseChannelBase
Assembly: AsyncResponse.Channels.SqlServer
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Channels/Shared/DbChannelShared.cs
Line coverage
99%
Covered lines: 615
Uncovered lines: 2
Coverable lines: 617
Total lines: 1386
Line coverage: 99.6%
Branch coverage
99%
Covered branches: 228
Total branches: 230
Branch coverage: 99.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
StartWakeListener(...)100%11100%
CreateResponseWaiter<T>(...)100%11100%
CreateRecoverableResponseWaiter<T>(...)100%11100%
CreateResponseWaiterCore()95.83%2424100%
Process()100%11100%
ProcessUnderCapturedContextAsync()100%22100%
SetResponse<T>(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(...)100%11100%
SetResponseCore()100%1414100%
SetRawResponseJsonCore()100%1414100%
SetException()94.44%1818100%
CountActiveSubscribersAsync()100%22100%
DropLocalSubscriptionsAsync()100%66100%
HasLiveSubscriberAsync()100%11100%
AddSubscription(...)100%22100%
RemoveSubscription(...)100%66100%
EnsureListenerStarted()100%44100%
HeartbeatLoopAsync()100%44100%
SnapshotActiveRegistrations()100%66100%
DispatchLoopAsync()100%22100%
CollectDispatchScopeAsync()100%1010100%
DispatchPendingMessagesAsync()100%202093.75%
PublishMessageAsync()100%11100%
DispatchMessageToSubscribersAsync()100%2424100%
IsWithinWatermark(...)100%66100%
TryDispatchLocalSubscribersAsync()100%88100%
BeginConfirmation(...)100%11100%
TryConfirmDeliveryAsync()100%11100%
SignalDispatcher(...)100%11100%
WaitForAcknowledgementAsync()100%1010100%
SerializeRawSuccessEnvelope(...)100%11100%
HandleWaiterTimeoutAsync()100%11100%
DisposeAsync()100%66100%
get_MessageId()100%11100%
get_Delivered()100%11100%
Dispose()100%11100%
.ctor(...)100%11100%
get_Dropped()100%11100%
HasSeen(...)100%11100%
MarkSeen(...)100%22100%
PruneSeen(...)100%44100%
ProcessAsync()100%1616100%
CleanupOnceAsync(...)100%44100%
DrainThenCleanupAsync()100%66100%
CleanupCoreAsync()100%1010100%
<CleanupCoreAsync()100%11100%
DropLocalAsync()100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/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.Text;
 7using System.Text.Json;
 8using System.Threading.Channels;
 9
 10namespace AsyncResponse.Channels;
 11
 12// Shared source for the database-backed response channels (PostgreSQL, SQL Server, MongoDB),
 13// mirroring the DurableFlows shared-store pattern: each channel csproj pulls this file in via
 14// <Compile Include="..\Shared\DbChannelShared.cs" />, so the base class compiles INTO each
 15// provider assembly against that provider's concrete seam types. The seam is bound per project
 16// with three global using aliases (declared at the top of the provider's channel file):
 17//
 18//   DbChannelStore   -> the provider's store/SQL adapter (e.g. PostgreSqlChannelSql)
 19//   DbChannelMessage -> the provider's channel-message record (e.g. PostgreSqlChannelMessage)
 20//   DbChannelOptions -> the provider's options class (e.g. PostgreSqlAsyncResponseChannelOptions)
 21//
 22// Because the aliases resolve to concrete sealed types at compile time, store calls on the
 23// per-message paths stay direct (no interface dispatch, no delegate indirection) — see the
 24// benchmark note in RedisAsyncResponseChannel.SetResponseCore for why that matters. The only
 25// virtual seams are the four hooks below, which cover exactly what the three providers genuinely
 26// do differently: the channel-name format, the sweep cadence, the optional wake listener, and the
 27// provider waiter type.
 28
 29/// <summary>
 30/// Provider-agnostic machinery for the database-backed response channels: waiter registration and
 31/// recovery-state bookkeeping, publish with delivery confirmation, the signal-driven dispatch
 32/// sweep, the subscriber heartbeat, and subscription lifecycle/cleanup. Derived channels supply
 33/// the wake mechanism (LISTEN/NOTIFY, adaptive polling, change streams), the channel-name format,
 34/// and the provider waiter type via the protected hooks.
 35/// </summary>
 36internal abstract class DbAsyncResponseChannelBase :
 37    IAsyncResponsePublisher,
 38    IRawAsyncResponsePublisher,
 39    IRecoverableAsyncResponseSubscriber,
 40    IActiveSubscriberProbe,
 41    IAsyncDisposable
 42{
 343    private protected readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, IDbSubscription>> _subscriptions 
 44
 45    // A signal carries the correlation id to scan (targeted), or null to scan every subscribed
 46    // correlation id (the periodic sweep that is the missed-wake safety net).
 347    private readonly Channel<string?> _signals = Channel.CreateBounded<string?>(new BoundedChannelOptions(1024)
 348    {
 349        SingleReader = true,
 350        SingleWriter = false,
 351        FullMode = BoundedChannelFullMode.DropOldest
 352    });
 53
 54    // Maps a just-published message id to a completion the local dispatch loop trips the instant it
 55    // delivers the message to a live waiter. Same-process delivery (the overwhelmingly common case)
 56    // is confirmed without polling the database; cross-process delivery falls back to polling acked_at.
 357    private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _pendingConfirmations = new();
 58
 59    private protected readonly DbChannelStore _store;
 60    private readonly IRecoveryStateStore _recoveryStateStore;
 61    private readonly AsyncResponseContextPropagation _propagation;
 62    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 63    private protected readonly DbChannelOptions _options;
 64    private protected readonly ILogger _logger;
 65    private readonly SerialExecutorRegistry _executors;
 366    private readonly string _instanceId = $"{Environment.MachineName}-{Environment.ProcessId}-{Guid.NewGuid():N}";
 67
 68    // Provider text used in diagnostics. The emitted strings must stay byte-identical to the
 69    // pre-consolidation per-provider channels — tests and dashboards match on them.
 70    private readonly string _channelTypeName;
 71    private readonly string _providerName;
 72    private readonly string _activityTag;
 73    private readonly string _subscriberRecordNoun;
 74    private readonly string _localDispatchRetryHint;
 75
 376    private readonly object _listenerGate = new();
 77    private protected CancellationTokenSource? _listenerCts;
 78    private protected Task? _listenTask;
 79    private protected Task? _dispatchTask;
 80    private protected Task? _heartbeatTask;
 81    private bool _disposed;
 82
 83    /// <summary>Creates the shared machinery for a database-backed async-response channel.</summary>
 384    protected DbAsyncResponseChannelBase(
 385        IServiceScopeFactory scopeFactory,
 386        DbChannelStore store,
 387        IRecoveryStateStore recoveryStateStore,
 388        DbChannelOptions options,
 389        AsyncResponseContextPropagation propagation,
 390        ILogger logger,
 391        string channelTypeName,
 392        string providerName,
 393        string activityTag,
 394        string subscriberRecordNoun,
 395        string localDispatchRetryHint)
 96    {
 397        _store = store;
 398        _recoveryStateStore = recoveryStateStore;
 399        _propagation = propagation;
 3100        _options = options;
 3101        _options.Validate();
 3102        _logger = logger;
 3103        _channelTypeName = channelTypeName;
 3104        _providerName = providerName;
 3105        _activityTag = activityTag;
 3106        _subscriberRecordNoun = subscriberRecordNoun;
 3107        _localDispatchRetryHint = localDispatchRetryHint;
 3108        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger);
 3109        _executors = new SerialExecutorRegistry(logger);
 3110    }
 111
 112    /// <summary>
 113    /// The per-correlation channel name used as the serial-executor key and the lost-subscriber
 114    /// channel label. Formats differ per provider (notification channel, schema.table, collection).
 115    /// </summary>
 116    protected abstract string ChannelName(string correlationId);
 117
 118    /// <summary>
 119    /// The dispatch sweep cadence. Fixed (<c>ListenerPollInterval</c>) for the providers with a push
 120    /// wake; adaptive (active/idle) for SQL Server where the sweep IS the cross-process wake.
 121    /// </summary>
 122    protected abstract TimeSpan CurrentPollInterval();
 123
 124    /// <summary>
 125    /// Starts the provider's wake listener loop (LISTEN/NOTIFY, change stream), or returns
 126    /// <c>null</c> when the provider has none and relies on the dispatch sweep alone.
 127    /// </summary>
 3128    protected virtual Task? StartWakeListener(CancellationToken cancellationToken) => null;
 129
 130    /// <summary>Wraps the response task in the provider's waiter type.</summary>
 131    protected abstract IAsyncResponseWaiter<T> CreateWaiter<T>(Task<T> responseTask, Func<ValueTask> cleanupAsync)
 132        where T : IAsyncResponsePayload;
 133
 134    /// <inheritdoc />
 135    public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 136        string correlationId,
 137        Func<T, ValueTask<bool>>? completionPredicate = null,
 138        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 3139        => CreateResponseWaiterCore(correlationId, null, null, completionPredicate, timeout);
 140
 141    /// <inheritdoc />
 142    public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>(
 143        string correlationId,
 144        ReflectionCallDto? resumeCallback = null,
 145        ReflectionCallDto? failureCallback = null,
 146        Func<T, ValueTask<bool>>? completionPredicate = null,
 147        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 1148        => CreateResponseWaiterCore(correlationId, resumeCallback, failureCallback, completionPredicate, timeout);
 149
 150    private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>(
 151        string correlationId,
 152        ReflectionCallDto? resumeCallback,
 153        ReflectionCallDto? failureCallback,
 154        Func<T, ValueTask<bool>>? completionPredicate,
 155        TimeSpan? timeout) where T : IAsyncResponsePayload
 156    {
 3157        if (string.IsNullOrWhiteSpace(correlationId))
 1158            throw new ArgumentNullException(nameof(correlationId), "CorrelationId must not be empty or whitespace.");
 159
 3160        if ((resumeCallback is not null || failureCallback is not null)
 3161            && !AsyncResponsePayloadReflection.OverridesShouldResumeOnRecovery(typeof(T)))
 162        {
 1163            throw new InvalidOperationException(
 1164                $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the {_providerName} channel
 1165                $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.ShouldResumeOnReco
 1166                "Override it to declare which responses resume the flow (return true) versus fail it (return false); " +
 1167                "the durable channel needs this to route a response that arrives after the waiter was lost.");
 168        }
 169
 3170        completionPredicate ??= _ => new ValueTask<bool>(true);
 3171        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 172        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 173        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 174        // subscription and recovery state existed, leaking both — and zero used to slip through
 175        // on some channels entirely, insta-timing-out a fully registered waiter.
 3176        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 177
 3178        await _store.EnsureCreatedAsync().ConfigureAwait(false);
 3179        EnsureListenerStarted();
 180
 181        // Watermark from the database server's clock, not the app clock: the dispatch loop filters
 182        // pending messages with created_at >= started, and mixing an app-side timestamp with the
 183        // server-stamped created_at would silently drop live deliveries under clock skew.
 1184        var startedAtUtc = await _store.GetServerTimeUtcAsync(CancellationToken.None).ConfigureAwait(false);
 185
 1186        var storedCorrelationId = correlationId;
 1187        var capturedContext = ExecutionContext.Capture();
 188
 1189        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 1190        activity?.SetTag("asyncresponse.channel", _activityTag);
 1191        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 1192        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 193
 1194        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
 1195        var registrationId = Guid.NewGuid();
 1196        var subscription = new DbSubscription<T>(
 1197            this,
 1198            correlationId,
 1199            registrationId,
 1200            startedAtUtc,
 1201            completionPredicate,
 1202            tcs,
 1203            activity);
 204
 1205        var timeoutCts = new CancellationTokenSource();
 1206        CancellationTokenRegistration timeoutRegistration = default;
 1207        subscription.TimeoutRegistration = () => timeoutRegistration.DisposeAsync();
 1208        subscription.TimeoutCancellation = timeoutCts;
 209
 1210        timeoutRegistration = timeoutCts.Token.Register(
 1211            OnWaiterTimeout,
 1212            new WaiterTimeoutState<T>(this, subscription, activity, correlationId, tcs));
 213
 214        // Wire the captured-context delegate before the subscription becomes discoverable, so a
 215        // response already stored for this correlation id is processed with the caller's context.
 216        Task ProcessUnderCapturedContextAsync(DbChannelMessage message)
 217        {
 218            async Task Process()
 219            {
 1220                using var correlationScope = AsyncResponseContext.PushCorrelationId(storedCorrelationId);
 1221                await subscription.ProcessAsync(message).ConfigureAwait(false);
 1222            }
 223
 1224            if (capturedContext is null)
 1225                return Process();
 226
 1227            Task? task = null;
 1228            ExecutionContext.Run(capturedContext, _ => task = Process(), null);
 1229            return task!;
 230        }
 231
 1232        subscription.ProcessUnderContextAsync = ProcessUnderCapturedContextAsync;
 233
 234        try
 235        {
 1236            var recoveryState = new RecoveryState
 1237            {
 1238                RegistrationId = registrationId,
 1239                ResumeCallback = resumeCallback,
 1240                FailureCallback = failureCallback,
 1241                CorrelationId = correlationId,
 1242                PayloadTypeFullName = typeof(T).FullName,
 1243                RegisteredAtUtc = DateTime.UtcNow,
 1244                Context = _propagation.Capture()
 1245            };
 246            // Subscriber record BEFORE recovery state: "recovery state visible ⇒ subscription
 247            // visible" is the invariant the lost-subscriber dispatcher's live re-check relies on.
 248            // In the reverse order a publisher could see the state, see no subscriber, and consume
 249            // the registration while this waiter is milliseconds from being live.
 1250            await _store.UpsertSubscriberAsync(correlationId, registrationId, _instanceId, _options.SubscriberHeartbeatT
 1251            await _recoveryStateStore.SaveAsync(correlationId, recoveryState, _options.RecoveryStateExpiry).ConfigureAwa
 252
 1253            timeoutCts.CancelAfter(timeout.Value);
 254
 1255            if (_logger.IsEnabled(LogLevel.Debug))
 1256                _logger.LogDebug("Waiting for {Provider} response on correlationId {CorrelationId} with timeout {Timeout
 1257        }
 1258        catch (Exception ex)
 259        {
 1260            _logger.LogError(ex, "Failed to create {Provider} waiter for correlationId {CorrelationId}.", _providerName,
 1261            AsyncResponseDiagnostics.SetError(activity, "subscribe_failure", ex.Message);
 1262            await subscription.DrainThenCleanupAsync(deleteRecoveryState: true).ConfigureAwait(false);
 263
 264            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 265            // the trigger runs only once the subscription AND recovery state exist. A returned
 266            // waiter would still let the trigger fire the remote operation with no registration
 267            // left to receive (or recover) its response. Cleanup cancels the response task, so no
 268            // pending task is left behind.
 1269            throw;
 270        }
 271
 272        // Publish the subscription only once it is fully armed (heartbeat + timeout + context
 273        // delegate), then signal a scan targeted at this correlation id so any already-stored
 274        // response is delivered promptly without a full sweep.
 1275        AddSubscription(correlationId, subscription);
 1276        SignalDispatcher(correlationId);
 277
 1278        return CreateWaiter<T>(tcs.Task, () => subscription.DrainThenCleanupAsync(deleteRecoveryState: true));
 1279    }
 280
 281    /// <inheritdoc />
 282    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 3283        => SetResponseCore(response, correlationId, cancellationToken);
 284
 285    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 1286        => SetResponseCore(response, correlationId, cancellationToken);
 287
 288    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 3289        => SetRawResponseJsonCore(responseJson, correlationId, cancellationToken);
 290
 291    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 292    {
 3293        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 3294        activity?.SetTag("asyncresponse.channel", _activityTag);
 3295        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 3296        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 297
 3298        if (string.IsNullOrWhiteSpace(correlationId))
 299        {
 1300            _logger.LogWarning("CorrelationId is null; cannot publish the response.");
 1301            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 1302            return;
 303        }
 304
 305        try
 306        {
 3307            var subscribers = await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(
 1308            activity?.SetTag("asyncresponse.subscribers", subscribers);
 1309            if (subscribers <= 0)
 310            {
 1311                var dispatchResult = await _lostSubscriberDispatcher
 1312                    .DispatchLostResponses(
 1313                        _recoveryStateStore,
 1314                        correlationId,
 1315                        response,
 1316                        ChannelName(correlationId),
 1317                        cancellationToken,
 1318                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 1319                    .ConfigureAwait(false);
 1320                if (!dispatchResult.RetryLive)
 321                {
 1322                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 1323                    AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResul
 1324                    activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 1325                    return;
 326                }
 327
 328                // A waiter registered between the count and the recovery-state read — publish live
 329                // instead of consuming its registration.
 330            }
 331
 1332            var envelope = new AsyncResponseEnvelope<T> { Success = true, Payload = response };
 1333            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 1334            var messageId = Guid.NewGuid();
 1335            using var confirmation = BeginConfirmation(messageId);
 1336            await PublishMessageAsync(messageId, correlationId, json, cancellationToken).ConfigureAwait(false);
 337
 1338            if (!await TryConfirmDeliveryAsync(confirmation, cancellationToken).ConfigureAwait(false))
 339            {
 1340                var dispatchResult = await _lostSubscriberDispatcher
 1341                    .DispatchLostResponses(_recoveryStateStore, correlationId, response, ChannelName(correlationId), can
 1342                    .ConfigureAwait(false);
 1343                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 1344                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResult.Ca
 1345                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 346            }
 1347        }
 3348        catch (Exception ex)
 349        {
 3350            _logger.LogError(ex, "Failed to publish {Provider} response for correlationId {CorrelationId}.", _providerNa
 3351            AsyncResponseDiagnostics.SetError(activity, ex);
 3352            throw;
 353        }
 1354    }
 355
 356    private async Task SetRawResponseJsonCore(string responseJson, string correlationId, CancellationToken cancellationT
 357    {
 3358        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 3359        activity?.SetTag("asyncresponse.channel", _activityTag);
 3360        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 361
 3362        if (string.IsNullOrWhiteSpace(correlationId))
 363        {
 1364            _logger.LogWarning("CorrelationId is null; cannot publish the raw response.");
 1365            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 1366            return;
 367        }
 368
 369        try
 370        {
 3371            var subscribers = await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(
 1372            activity?.SetTag("asyncresponse.subscribers", subscribers);
 1373            if (subscribers <= 0)
 374            {
 1375                var response = new RawJsonResponse(responseJson).DeserializeUntyped();
 1376                var dispatchResult = await _lostSubscriberDispatcher
 1377                    .DispatchLostResponses(
 1378                        _recoveryStateStore,
 1379                        correlationId,
 1380                        response,
 1381                        ChannelName(correlationId),
 1382                        cancellationToken,
 1383                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 1384                    .ConfigureAwait(false);
 1385                if (!dispatchResult.RetryLive)
 386                {
 1387                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 1388                    AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResul
 1389                    activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 1390                    return;
 391                }
 392
 393                // A waiter registered between the count and the recovery-state read — publish live
 394                // instead of consuming its registration.
 395            }
 396
 1397            var messageId = Guid.NewGuid();
 1398            using var confirmation = BeginConfirmation(messageId);
 1399            await PublishMessageAsync(messageId, correlationId, SerializeRawSuccessEnvelope(responseJson), cancellationT
 400
 1401            if (!await TryConfirmDeliveryAsync(confirmation, cancellationToken).ConfigureAwait(false))
 402            {
 1403                var response = new RawJsonResponse(responseJson).DeserializeUntyped();
 1404                var dispatchResult = await _lostSubscriberDispatcher
 1405                    .DispatchLostResponses(_recoveryStateStore, correlationId, response, ChannelName(correlationId), can
 1406                    .ConfigureAwait(false);
 1407                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 1408                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResult.Ca
 1409                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 410            }
 1411        }
 3412        catch (Exception ex)
 413        {
 3414            _logger.LogError(ex, "Failed to publish {Provider} raw response for correlationId {CorrelationId}.", _provid
 3415            AsyncResponseDiagnostics.SetError(activity, ex);
 3416            throw;
 417        }
 1418    }
 419
 420    /// <inheritdoc />
 421    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 422    {
 3423        ArgumentNullException.ThrowIfNull(exception);
 424
 3425        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 3426        activity?.SetTag("asyncresponse.channel", _activityTag);
 3427        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 3428        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 429
 3430        if (string.IsNullOrWhiteSpace(correlationId))
 431        {
 1432            _logger.LogWarning("CorrelationId is null; cannot publish the exception. Exception: {ExceptionMessage}", exc
 1433            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 1434            return;
 435        }
 436
 437        try
 438        {
 3439            var subscribers = await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(
 1440            activity?.SetTag("asyncresponse.subscribers", subscribers);
 1441            if (subscribers <= 0)
 442            {
 1443                var dispatchResult = await _lostSubscriberDispatcher
 1444                    .DispatchLostExceptions(
 1445                        _recoveryStateStore,
 1446                        correlationId,
 1447                        exception,
 1448                        ChannelName(correlationId),
 1449                        cancellationToken,
 1450                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 1451                    .ConfigureAwait(false);
 1452                if (!dispatchResult.RetryLive)
 453                {
 1454                    activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 1455                    AsyncResponseDiagnostics.RecordLostSubscriber("exception", shouldResume: false, dispatchResult.Callb
 1456                    return;
 457                }
 458
 459                // A waiter registered between the count and the recovery-state read — publish live
 460                // instead of consuming its registration.
 461            }
 462
 1463            var envelope = new AsyncResponseEnvelope<object>
 1464            {
 1465                Success = false,
 1466                ExceptionMessage = exception.Message,
 1467                ExceptionStackTrace = RemoteStackTrace.ForWire(exception.StackTrace, _options.IncludeRemoteStackTrace, _
 1468                Payload = null
 1469            };
 1470            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 1471            var messageId = Guid.NewGuid();
 1472            using var confirmation = BeginConfirmation(messageId);
 1473            await PublishMessageAsync(messageId, correlationId, json, cancellationToken).ConfigureAwait(false);
 474
 1475            if (!await TryConfirmDeliveryAsync(confirmation, cancellationToken).ConfigureAwait(false))
 476            {
 477                // No live re-check here: TryClaimForRecoveryAsync already won the message for the
 478                // recovery path, so live delivery of it is no longer possible.
 1479                var dispatchResult = await _lostSubscriberDispatcher
 1480                    .DispatchLostExceptions(_recoveryStateStore, correlationId, exception, ChannelName(correlationId), c
 1481                    .ConfigureAwait(false);
 1482                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 1483                AsyncResponseDiagnostics.RecordLostSubscriber("exception", shouldResume: false, dispatchResult.CallbackI
 484            }
 1485        }
 3486        catch (Exception ex)
 487        {
 3488            _logger.LogError(ex, "Failed to publish {Provider} exception response for correlationId {CorrelationId}.", _
 3489            AsyncResponseDiagnostics.SetError(activity, ex);
 3490            throw;
 491        }
 1492    }
 493
 494    /// <inheritdoc />
 495    public async ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken =
 496    {
 1497        if (string.IsNullOrWhiteSpace(correlationId))
 1498            return 0L;
 499
 500        try
 501        {
 1502            return await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false);
 503        }
 1504        catch (Exception ex) when (ex is not OperationCanceledException)
 505        {
 1506            _logger.LogDebug(ex, "Failed to count {Provider} subscribers for correlationId {CorrelationId}.", _providerN
 1507            return 0L;
 508        }
 1509    }
 510
 511    /// <summary>
 512    /// Drops local subscriptions while leaving recovery state intact. Used by the sample app to
 513    /// simulate a redeploy for lost-subscriber integration tests.
 514    /// </summary>
 515    internal async Task DropLocalSubscriptionsAsync(CancellationToken cancellationToken = default)
 516    {
 1517        foreach (var (correlationId, group) in _subscriptions.ToArray())
 518        {
 1519            foreach (var subscription in group.Values.ToArray())
 520            {
 1521                await subscription.DropLocalAsync(cancellationToken).ConfigureAwait(false);
 1522                group.TryRemove(subscription.Id, out _);
 1523            }
 524
 1525            if (group.IsEmpty)
 1526                _subscriptions.TryRemove(correlationId, out _);
 527
 1528            await _executors.RemoveAsync(ChannelName(correlationId)).ConfigureAwait(false);
 1529        }
 1530    }
 531
 532    /// <summary>
 533    /// Re-probes waiter liveness for the lost-subscriber dispatcher's snapshot-race re-check,
 534    /// using the same active-subscriber count the publish path consulted.
 535    /// </summary>
 536    private async ValueTask<bool> HasLiveSubscriberAsync(string correlationId, CancellationToken cancellationToken)
 1537        => await _store.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false) > 0;
 538
 539    private protected void AddSubscription(string correlationId, IDbSubscription subscription)
 540    {
 541        // Register with the executor registry BEFORE publishing into the subscription map: every
 542        // dispatch path consults the map and then enqueues, so a delivery racing a visible-but-
 543        // unregistered subscription on a correlation id reused within the tombstone lifetime would
 544        // be silently dropped. In the reversed window (registered, not yet visible) the delivery
 545        // just waits for the next sweep or falls back to lost-subscriber recovery.
 3546        _executors.OnSubscriptionRegistered(ChannelName(correlationId));
 3547        var group = _subscriptions.GetOrAdd(correlationId, _ => new ConcurrentDictionary<Guid, IDbSubscription>());
 3548        group[subscription.Id] = subscription;
 3549    }
 550
 551    private void RemoveSubscription(string correlationId, Guid registrationId)
 552    {
 3553        if (!_subscriptions.TryGetValue(correlationId, out var group))
 1554            return;
 555
 3556        if (group.TryRemove(registrationId, out _))
 3557            _executors.OnSubscriptionRetired(ChannelName(correlationId));
 3558        if (group.IsEmpty)
 3559            _subscriptions.TryRemove(correlationId, out _);
 3560    }
 561
 562    private protected void EnsureListenerStarted()
 563    {
 3564        lock (_listenerGate)
 565        {
 566            // Checked under the same gate DisposeAsync sets it under: a racing registration must
 567            // never recreate the CTS and loops after disposal tore them down.
 3568            if (_disposed)
 3569                throw new ObjectDisposedException(_channelTypeName);
 570
 3571            if (_listenerCts is not null)
 1572                return;
 573
 3574            var listenerCts = new CancellationTokenSource();
 3575            _listenerCts = listenerCts;
 3576            _listenTask = StartWakeListener(listenerCts.Token);
 3577            _dispatchTask = Task.Run(() => DispatchLoopAsync(listenerCts.Token));
 3578            _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(listenerCts.Token));
 3579        }
 3580    }
 581
 582    private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
 583    {
 3584        while (!cancellationToken.IsCancellationRequested)
 585        {
 586            try
 587            {
 3588                await Task.Delay(_options.SubscriberHeartbeatInterval, cancellationToken).ConfigureAwait(false);
 3589                var registrations = SnapshotActiveRegistrations();
 3590                if (registrations.Count > 0)
 591                {
 3592                    await _store.HeartbeatSubscribersAsync(
 3593                        _instanceId,
 3594                        registrations,
 3595                        _options.SubscriberHeartbeatTimeout,
 3596                        cancellationToken).ConfigureAwait(false);
 597                }
 1598            }
 3599            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 600            {
 3601                return;
 602            }
 3603            catch (Exception ex)
 604            {
 3605                _logger.LogWarning(ex, "{Provider} subscriber heartbeat failed; retrying for all local waiters.", _provi
 3606            }
 607        }
 3608    }
 609
 610    private List<(string CorrelationId, Guid RegistrationId)> SnapshotActiveRegistrations()
 611    {
 612        // Full (correlation id, registration id) pairs: the heartbeat UPSERTs the subscriber
 613        // records, so it needs everything required to re-create one the store's expiry pruning
 614        // (relational pruner / TTL reaper) has already deleted.
 3615        var registrations = new List<(string CorrelationId, Guid RegistrationId)>();
 3616        foreach (var (correlationId, group) in _subscriptions)
 617        {
 3618            foreach (var subscription in group.Values)
 619            {
 3620                if (!subscription.Dropped)
 3621                    registrations.Add((correlationId, subscription.Id));
 622            }
 623        }
 624
 3625        return registrations;
 626    }
 627
 628    private async Task DispatchLoopAsync(CancellationToken cancellationToken)
 629    {
 3630        while (!cancellationToken.IsCancellationRequested)
 631        {
 632            try
 633            {
 3634                var scope = await CollectDispatchScopeAsync(cancellationToken).ConfigureAwait(false);
 3635                await DispatchPendingMessagesAsync(scope, cancellationToken).ConfigureAwait(false);
 1636            }
 3637            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 638            {
 1639                return;
 640            }
 3641            catch (Exception ex)
 642            {
 3643                _logger.LogWarning(ex, "{Provider} response dispatch loop failed; retrying after poll delay.", _provider
 3644                await Task.Delay(CurrentPollInterval(), cancellationToken).ConfigureAwait(false);
 645            }
 646        }
 1647    }
 648
 649    /// <summary>
 650    /// Waits for the next dispatch trigger and returns its scope. <c>null</c> means scan every
 651    /// subscribed correlation id — a full sweep requested explicitly (a null signal) or by the
 652    /// periodic poll that is the missed-wake / cross-process-delivery safety net. A non-null set
 653    /// scans only the signaled correlation ids, so a flood of wake signals never forces a scan of
 654    /// every waiter.
 655    /// </summary>
 656    private protected async Task<HashSet<string>?> CollectDispatchScopeAsync(CancellationToken cancellationToken)
 657    {
 658        // The WhenAny loser is cancelled via the per-iteration linked source: an abandoned
 659        // WaitToReadAsync would otherwise stay parked in the channel's blocked-reader list until
 660        // the next signal — one per poll interval, accumulating without bound on an idle channel.
 3661        using var iteration = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 3662        var delay = Task.Delay(CurrentPollInterval(), iteration.Token);
 3663        var signal = _signals.Reader.WaitToReadAsync(iteration.Token).AsTask();
 3664        var completed = await Task.WhenAny(delay, signal).ConfigureAwait(false);
 3665        iteration.Cancel();
 3666        if (completed == delay)
 3667            return null;
 668
 3669        await signal.ConfigureAwait(false);
 670
 3671        var scope = new HashSet<string>(StringComparer.Ordinal);
 3672        var fullSweep = false;
 3673        while (_signals.Reader.TryRead(out var correlationId))
 674        {
 3675            if (string.IsNullOrEmpty(correlationId))
 3676                fullSweep = true;
 677            else
 3678                scope.Add(correlationId);
 3679        }
 680
 3681        return fullSweep || scope.Count == 0 ? null : scope;
 3682    }
 683
 684    private protected async Task DispatchPendingMessagesAsync(HashSet<string>? scope, CancellationToken cancellationToke
 685    {
 3686        foreach (var (correlationId, group) in _subscriptions)
 687        {
 3688            if (scope is not null && !scope.Contains(correlationId))
 689                continue;
 690
 3691            var subscriptions = new List<IDbSubscription>(group.Count);
 3692            foreach (var subscription in group.Values)
 693            {
 3694                if (!subscription.Dropped)
 3695                    subscriptions.Add(subscription);
 696            }
 3697            if (subscriptions.Count == 0)
 698                continue;
 699
 3700            var since = subscriptions.Min(static s => s.StartedAtUtc).AddSeconds(-1);
 3701            var seenCutoff = DateTimeOffset.UtcNow - _options.MessageRetention - TimeSpan.FromMinutes(1);
 3702            foreach (var subscription in subscriptions)
 3703                subscription.PruneSeen(seenCutoff);
 704
 3705            DateTimeOffset? afterCreatedAtUtc = null;
 3706            Guid? afterId = null;
 1707            while (true)
 708            {
 3709                var messages = await _store.LoadMessagesAsync(
 3710                    correlationId,
 3711                    since,
 3712                    _options.PendingMessageBatchSize,
 3713                    afterCreatedAtUtc,
 3714                    afterId,
 3715                    cancellationToken).ConfigureAwait(false);
 1716                foreach (var message in messages)
 717                {
 1718                    await _executors.EnqueueAsync(
 1719                        ChannelName(correlationId),
 1720                        () => DispatchMessageToSubscribersAsync(message, subscriptions, cancellationToken),
 1721                        cancellationToken).ConfigureAwait(false);
 722                }
 723
 1724                if (messages.Count < _options.PendingMessageBatchSize)
 725                    break;
 726
 1727                var last = messages[^1];
 0728                afterCreatedAtUtc = last.CreatedAtUtc;
 0729                afterId = last.Id;
 1730            }
 1731        }
 3732    }
 733
 734    private async Task PublishMessageAsync(
 735        Guid messageId,
 736        string correlationId,
 737        string envelopeJson,
 738        CancellationToken cancellationToken)
 739    {
 740        // The insert itself carries the remote wake where the provider has one (a NOTIFY rides the
 741        // PostgreSQL insert; MongoDB change streams observe it) and the SQL Server sweep polls it
 742        // up. Only the local fast path and a targeted local signal are needed on top. The store
 743        // returns the SERVER-stamped created_at for the local fast-path message: subscription
 744        // watermarks are server-clock, and an app-clock timestamp here silently disabled the fast
 745        // path whenever the app clock ran more than the 1s tolerance behind the database — delivery
 746        // then quietly degraded to sweep latency on every publish.
 1747        var createdAtUtc = await _store.InsertMessageAsync(messageId, correlationId, envelopeJson, _options.MessageReten
 1748            .ConfigureAwait(false);
 1749        await TryDispatchLocalSubscribersAsync(
 1750            new DbChannelMessage(messageId, correlationId, envelopeJson, createdAtUtc),
 1751            cancellationToken).ConfigureAwait(false);
 1752        SignalDispatcher(correlationId);
 1753    }
 754
 755    private protected async Task DispatchMessageToSubscribersAsync(
 756        DbChannelMessage message,
 757        IReadOnlyList<IDbSubscription> subscriptions,
 758        CancellationToken cancellationToken)
 759    {
 760        // Only subscriptions that are still live, inside their delivery watermark, and have not
 761        // already processed this message. Skipping when there is nothing to deliver also avoids a
 762        // redundant claim on every re-sweep.
 3763        var hasTargets = false;
 3764        foreach (var subscription in subscriptions)
 765        {
 3766            if (!subscription.Dropped && IsWithinWatermark(subscription, message) && !subscription.HasSeen(message.Id))
 767            {
 3768                hasTargets = true;
 3769                break;
 770            }
 771        }
 3772        if (!hasTargets)
 3773            return;
 774
 775        // Take the message for live delivery. The claim sets acked_at unless the publisher already
 776        // routed it to recovery (recovery_claimed); losing the claim means recovery owns it, so it is
 777        // not delivered to the waiter and handled a second time.
 778        //
 779        // Claim-then-dispatch is deliberate — keep this ordering. The in-process handoff is
 780        // at-most-once by design: a crash between the claim and the waiter's continuation can only
 781        // lose delivery to waiters in THIS dying process, which no ordering could save (their
 782        // continuations die with it), while pre-registered fan-out waiters in other processes
 783        // still receive the acked message (IsWithinWatermark admits acked_at > started_at).
 784        // Dispatch-then-ack behind an expiring claim would re-open the stale-redelivery wrong-data
 785        // bug the strict acked exclusion in IsWithinWatermark closes. Durability across process
 786        // death belongs to the layer above: flow re-execution, publish-time recovery routing, and
 787        // the step timeout.
 3788        if (!await _store.TryClaimForDeliveryAsync(message.Id, cancellationToken).ConfigureAwait(false))
 789        {
 1790            foreach (var subscription in subscriptions)
 791            {
 1792                if (!subscription.Dropped)
 1793                    subscription.MarkSeen(message.Id);
 794            }
 1795            return;
 796        }
 797
 798        // Wake the publisher immediately if it is waiting in this process — no acked_at polling needed.
 1799        if (_pendingConfirmations.TryGetValue(message.Id, out var confirmation))
 1800            confirmation.TrySetResult(true);
 801
 1802        foreach (var subscription in subscriptions)
 803        {
 1804            if (subscription.Dropped || !IsWithinWatermark(subscription, message) || !subscription.MarkSeen(message.Id))
 805                continue;
 806
 1807            await subscription.ProcessUnderContextAsync(message).ConfigureAwait(false);
 808        }
 3809    }
 810
 811    /// <summary>
 812    /// Per-subscription delivery watermark. The sweep queries with the OLDEST waiter's watermark on
 813    /// a shared correlation id, so without this filter a late-joining waiter would receive retained
 814    /// messages created before it registered. Same 1s tolerance as the query watermark.
 815    /// <para>
 816    /// The creation-time tolerance alone re-admits history: a message created inside the 1s skew
 817    /// window may have already been delivered and acked for a PREVIOUS waiter that reused the
 818    /// correlation id, and per-subscription seen-tracking cannot dedupe what a different
 819    /// subscription processed. A message acked before this subscription existed is history, not
 820    /// delivery — waiters that legitimately participate in a delivery (including cross-process
 821    /// fan-out) were registered before its claim stamped <c>acked_at</c>. The acked comparison is
 822    /// deliberately strict, with no skew tolerance: under skew, strictness can only make a waiter
 823    /// whose registration raced another process's in-flight ack keep waiting for its own response,
 824    /// whereas a tolerance would re-open the stale-redelivery window this check closes.
 825    /// </para>
 826    /// <para>
 827    /// The comparison must be STRICTLY greater, and that is load-bearing rather than stylistic: a
 828    /// server clock's resolution is far coarser than its column precision, so equal timestamps are
 829    /// routine, not a measure-zero tie. SQL Server stamps <c>datetime2(7)</c> from
 830    /// <c>SYSUTCDATETIME()</c> — 100ns precision, but the clock behind it advances in ~5ms ticks
 831    /// (measured: 30,344 samples over 300ms yielded 61 distinct values, mean gap 4.9ms), and
 832    /// MongoDB's <c>$$NOW</c> is millisecond-resolution. A waiter that reuses a correlation id
 833    /// within one tick of the previous waiter's ack therefore registers at exactly
 834    /// <c>acked_at</c>, and a non-strict comparison hands it the response its predecessor already
 835    /// consumed. Registration is ordered strictly after that ack in real time and the clock is
 836    /// non-decreasing, so <c>acked_at &lt;= started_at</c> always holds for history and the strict
 837    /// form excludes it deterministically — not probabilistically.
 838    /// </para>
 839    /// <para>
 840    /// The tie is symmetric, and its resolution is deliberate: the same equality can also be a
 841    /// genuine cross-process fan-out delivery (this waiter registered and another process's claim
 842    /// stamped <c>acked_at</c> inside one clock tick), and the strict form then excludes the
 843    /// waiter from its own response — it recovers through its step timeout and the
 844    /// idempotent-restart contract. That at-most-once cost (a rare missed delivery that
 845    /// self-heals) is chosen over the wrong-data redelivery a tolerant comparison re-opens. No
 846    /// timestamp can separate the two same-tick cases; only an identity carried on the claim (the
 847    /// claiming registration id, or a monotonic sequence) could — a possible store-schema
 848    /// evolution if the trade ever bites in practice. The recovery asymmetry is worth naming for
 849    /// the eventual triager: excluded HISTORY re-subscribes and proceeds immediately, while an
 850    /// excluded fan-out waiter stalls for its full timeout first — a durable-flow step's default
 851    /// is 7 days, and a plain waiter surfaces a TimeoutException to its caller. "Bites in
 852    /// practice" looks like a long stall, not a quick retry.
 853    /// </para>
 854    /// </summary>
 855    private static bool IsWithinWatermark(IDbSubscription subscription, DbChannelMessage message)
 3856        => message.CreatedAtUtc >= subscription.StartedAtUtc.AddSeconds(-1)
 3857           && (message.AckedAtUtc is null || message.AckedAtUtc > subscription.StartedAtUtc);
 858
 859    private async Task TryDispatchLocalSubscribersAsync(DbChannelMessage message, CancellationToken cancellationToken)
 860    {
 3861        if (!_subscriptions.TryGetValue(message.CorrelationId, out var group))
 3862            return;
 863
 3864        var subscriptions = new List<IDbSubscription>(group.Count);
 3865        foreach (var subscription in group.Values)
 866        {
 3867            if (!subscription.Dropped)
 1868                subscriptions.Add(subscription);
 869        }
 3870        if (subscriptions.Count == 0)
 3871            return;
 872
 873        // Same-process fast path: skips the wake round trip / sweep latency but still runs on the
 874        // per-correlation serial executor — completion predicates are guaranteed serial, in-order
 875        // invocation on every channel, and a direct dispatch here could otherwise run concurrently
 876        // with a sweep-enqueued dispatch of a different message for the same subscription. MarkSeen
 877        // keeps the sweep from double-processing this message.
 1878        await _executors.EnqueueAsync(
 1879            ChannelName(message.CorrelationId),
 1880            new LocalDispatchWorkItem(this, message, subscriptions, cancellationToken).InvokeAsync,
 1881            cancellationToken).ConfigureAwait(false);
 3882    }
 883
 884    /// <summary>
 885    /// Registers an in-process delivery completion for a message id. Disposing it removes the entry,
 886    /// so a publish that throws or completes never leaks the registration.
 887    /// </summary>
 888    private protected PendingConfirmation BeginConfirmation(Guid messageId)
 889    {
 3890        var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
 3891        _pendingConfirmations[messageId] = tcs;
 3892        return new PendingConfirmation(this, messageId, tcs);
 893    }
 894
 895    /// <summary>
 896    /// Confirms a published response reached a live waiter. Returns <c>true</c> once a waiter has
 897    /// acknowledged it; on confirmation timeout, atomically claims the message for the lost-subscriber
 898    /// path and returns <c>false</c> only if that claim wins — so the recovery callback and a
 899    /// slow-but-live waiter are mutually exclusive.
 900    /// </summary>
 901    private protected async Task<bool> TryConfirmDeliveryAsync(PendingConfirmation confirmation, CancellationToken cance
 902    {
 3903        if (await WaitForAcknowledgementAsync(confirmation, cancellationToken).ConfigureAwait(false))
 1904            return true;
 905
 1906        return !await _store.TryClaimForRecoveryAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false);
 1907    }
 908
 3909    private protected void SignalDispatcher(string? correlationId = null) => _signals.Writer.TryWrite(correlationId);
 910
 911    private async Task<bool> WaitForAcknowledgementAsync(PendingConfirmation confirmation, CancellationToken cancellatio
 912    {
 3913        var deadline = DateTimeOffset.UtcNow + _options.DeliveryConfirmationTimeout;
 3914        while (DateTimeOffset.UtcNow < deadline)
 915        {
 3916            var remaining = deadline - DateTimeOffset.UtcNow;
 3917            if (remaining <= TimeSpan.Zero)
 918                break;
 919
 3920            var pollDelay = remaining < _options.DeliveryConfirmationPollInterval
 3921                ? remaining
 3922                : _options.DeliveryConfirmationPollInterval;
 923
 924            // Fast path: an in-process delivery trips the completion and we return without a query.
 3925            await Task.WhenAny(confirmation.Delivered, Task.Delay(pollDelay, cancellationToken)).ConfigureAwait(false);
 3926            if (confirmation.Delivered.IsCompletedSuccessfully)
 1927                return true;
 928
 929            // Slow path: a delivery in another process only set acked_at, so poll for it.
 3930            if (await _store.IsMessageAcknowledgedAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false)
 1931                return true;
 932        }
 933
 1934        return confirmation.Delivered.IsCompletedSuccessfully
 1935            || await _store.IsMessageAcknowledgedAsync(confirmation.MessageId, cancellationToken).ConfigureAwait(false);
 1936    }
 937
 938    private static string SerializeRawSuccessEnvelope(string payloadJson)
 939    {
 1940        JsonSafety.ThrowIfClearlyNotJson(payloadJson);
 941
 1942        var buffer = new ArrayBufferWriter<byte>();
 1943        using (var writer = new Utf8JsonWriter(buffer))
 944        {
 1945            writer.WriteStartObject();
 1946            writer.WriteNumber("SchemaVersion", AsyncResponseEnvelopeSchema.Current);
 1947            writer.WriteBoolean("Success", true);
 1948            writer.WritePropertyName("Payload");
 1949            writer.WriteRawValue(payloadJson);
 1950            writer.WriteNull("ExceptionMessage");
 1951            writer.WriteNull("ExceptionStackTrace");
 1952            writer.WriteEndObject();
 1953        }
 954
 1955        return Encoding.UTF8.GetString(buffer.WrittenSpan);
 956    }
 957
 958    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 959    private static void OnWaiterTimeout(object? state)
 960        => ((IWaiterTimeoutState)state!).Schedule();
 961
 962    private async Task HandleWaiterTimeoutAsync<T>(
 963        DbSubscription<T> subscription,
 964        Activity? activity,
 965        string correlationId,
 966        TaskCompletionSource<T> tcs) where T : IAsyncResponsePayload
 967    {
 1968        _logger.LogWarning("Timed out waiting for {Provider} response for correlationId {CorrelationId}.", _providerName
 1969        AsyncResponseDiagnostics.SetError(activity, "timeout", $"Timed out waiting for response for correlationId {corre
 1970        AsyncResponseDiagnostics.RecordWaiterTimeout(_activityTag);
 1971        tcs.TrySetException(new TimeoutException($"Timed out waiting for response for correlationId {correlationId}."));
 1972        await subscription.DrainThenCleanupAsync(deleteRecoveryState: true).ConfigureAwait(false);
 1973    }
 974
 975    private interface IWaiterTimeoutState
 976    {
 977        void Schedule();
 978    }
 979
 980    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 981    private sealed class WaiterTimeoutState<T>(
 982        DbAsyncResponseChannelBase owner,
 983        DbSubscription<T> subscription,
 984        Activity? activity,
 985        string correlationId,
 986        TaskCompletionSource<T> tcs) : IWaiterTimeoutState where T : IAsyncResponsePayload
 987    {
 988        public void Schedule()
 989            => _ = Task.Run(async () =>
 990            {
 991                try
 992                {
 993                    await owner.HandleWaiterTimeoutAsync(subscription, activity, correlationId, tcs).ConfigureAwait(fals
 994                }
 995                catch (Exception ex)
 996                {
 997                    // Fire-and-forget: nothing awaits this task, so an escaped fault would vanish.
 998                    owner._logger.LogError(ex, "Error handling {Provider} waiter timeout for correlationId {CorrelationI
 999                }
 1000            });
 1001    }
 1002
 1003    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 1004    private sealed class LocalDispatchWorkItem(
 1005        DbAsyncResponseChannelBase owner,
 1006        DbChannelMessage message,
 1007        IReadOnlyList<IDbSubscription> subscriptions,
 1008        CancellationToken cancellationToken)
 1009    {
 1010        public async Task InvokeAsync()
 1011        {
 1012            try
 1013            {
 1014                await owner.DispatchMessageToSubscribersAsync(message, subscriptions, cancellationToken).ConfigureAwait(
 1015            }
 1016            catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequeste
 1017            {
 1018                owner._logger.LogDebug(
 1019                    ex,
 1020                    "Local {Provider} response dispatch failed for correlationId {CorrelationId}; {RetryHint}.",
 1021                    owner._providerName,
 1022                    message.CorrelationId,
 1023                    owner._localDispatchRetryHint);
 1024            }
 1025        }
 1026    }
 1027
 1028    /// <inheritdoc />
 1029    public async ValueTask DisposeAsync()
 1030    {
 1031        CancellationTokenSource? cts;
 1032        Task? listenTask;
 1033        Task? dispatchTask;
 1034        Task? heartbeatTask;
 31035        lock (_listenerGate)
 1036        {
 1037            // Set under the gate so EnsureListenerStarted can never observe "not disposed" and
 1038            // then recreate the CTS/loops this teardown is about to stop.
 31039            _disposed = true;
 31040            cts = _listenerCts;
 31041            listenTask = _listenTask;
 31042            dispatchTask = _dispatchTask;
 31043            heartbeatTask = _heartbeatTask;
 31044            _listenerCts = null;
 31045            _listenTask = null;
 31046            _dispatchTask = null;
 31047            _heartbeatTask = null;
 31048        }
 1049
 31050        if (cts is not null)
 1051        {
 31052            await cts.CancelAsync().ConfigureAwait(false);
 1053            try
 1054            {
 31055                await Task.WhenAll(new[] { listenTask, dispatchTask, heartbeatTask }.OfType<Task>()).ConfigureAwait(fals
 11056            }
 31057            catch (OperationCanceledException)
 1058            {
 31059            }
 31060            cts.Dispose();
 1061        }
 1062
 31063        foreach (var (correlationId, group) in _subscriptions.ToArray())
 1064        {
 31065            foreach (var subscription in group.Values.ToArray())
 31066                await subscription.DrainThenCleanupAsync(deleteRecoveryState: false).ConfigureAwait(false);
 31067            await _executors.RemoveAsync(ChannelName(correlationId)).ConfigureAwait(false);
 31068        }
 31069    }
 1070
 1071    /// <summary>Scopes an in-process delivery completion; <see cref="Dispose"/> unregisters it.</summary>
 1072    private protected readonly struct PendingConfirmation(
 1073        DbAsyncResponseChannelBase owner,
 1074        Guid messageId,
 1075        TaskCompletionSource<bool> tcs) : IDisposable
 1076    {
 31077        public Guid MessageId => messageId;
 31078        public Task<bool> Delivered => tcs.Task;
 31079        public void Dispose() => owner._pendingConfirmations.TryRemove(messageId, out _);
 1080    }
 1081
 1082    private protected interface IDbSubscription
 1083    {
 1084        Guid Id { get; }
 1085        DateTimeOffset StartedAtUtc { get; }
 1086        bool Dropped { get; }
 1087        Func<DbChannelMessage, Task> ProcessUnderContextAsync { get; set; }
 1088        bool HasSeen(Guid messageId);
 1089        bool MarkSeen(Guid messageId);
 1090        void PruneSeen(DateTimeOffset cutoffUtc);
 1091        Task ProcessAsync(DbChannelMessage message);
 1092        ValueTask CleanupOnceAsync(bool deleteRecoveryState);
 1093        ValueTask DrainThenCleanupAsync(bool deleteRecoveryState);
 1094        ValueTask DropLocalAsync(CancellationToken cancellationToken);
 1095    }
 1096
 1097    private sealed class DbSubscription<T> : IDbSubscription where T : IAsyncResponsePayload
 1098    {
 1099        private readonly DbAsyncResponseChannelBase _owner;
 1100        private readonly string _correlationId;
 1101        private readonly Func<T, ValueTask<bool>> _completionPredicate;
 1102        private readonly TaskCompletionSource<T> _tcs;
 1103        private readonly Activity? _activity;
 31104        private readonly HashSet<Guid> _seen = [];
 31105        private readonly Queue<(Guid Id, DateTimeOffset SeenAtUtc)> _seenOrder = [];
 31106        private readonly object _seenGate = new();
 1107        private int _cleanupStarted;
 1108        private volatile bool _dropped;
 31109        private readonly object _cleanupGate = new();
 1110        private Task? _cleanupTask;
 1111
 31112        public DbSubscription(
 31113            DbAsyncResponseChannelBase owner,
 31114            string correlationId,
 31115            Guid registrationId,
 31116            DateTimeOffset startedAtUtc,
 31117            Func<T, ValueTask<bool>> completionPredicate,
 31118            TaskCompletionSource<T> tcs,
 31119            Activity? activity)
 1120        {
 31121            _owner = owner;
 31122            _correlationId = correlationId;
 31123            Id = registrationId;
 31124            StartedAtUtc = startedAtUtc;
 31125            _completionPredicate = completionPredicate;
 31126            _tcs = tcs;
 31127            _activity = activity;
 31128            ProcessUnderContextAsync = ProcessAsync;
 31129        }
 1130
 1131        public Guid Id { get; }
 1132        public DateTimeOffset StartedAtUtc { get; }
 31133        public bool Dropped => _dropped;
 1134        public Func<ValueTask>? TimeoutRegistration { get; set; }
 1135        public CancellationTokenSource? TimeoutCancellation { get; set; }
 1136        public Func<DbChannelMessage, Task> ProcessUnderContextAsync { get; set; }
 1137
 1138        public bool HasSeen(Guid messageId)
 1139        {
 31140            lock (_seenGate)
 1141            {
 31142                return _seen.Contains(messageId);
 1143            }
 31144        }
 1145
 1146        public bool MarkSeen(Guid messageId)
 1147        {
 31148            lock (_seenGate)
 1149            {
 31150                if (!_seen.Add(messageId))
 31151                    return false;
 1152
 1153                // Use the local observation time, not the database creation time. This keeps the
 1154                // pruning queue monotonic and avoids immediate eviction when app and DB clocks differ.
 31155                _seenOrder.Enqueue((messageId, DateTimeOffset.UtcNow));
 31156                return true;
 1157            }
 31158        }
 1159
 1160        public void PruneSeen(DateTimeOffset cutoffUtc)
 1161        {
 31162            lock (_seenGate)
 1163            {
 31164                while (_seenOrder.TryPeek(out var entry) && entry.SeenAtUtc < cutoffUtc)
 1165                {
 31166                    _seenOrder.Dequeue();
 21167                    _seen.Remove(entry.Id);
 31168                }
 31169            }
 31170        }
 1171
 1172        public async Task ProcessAsync(DbChannelMessage message)
 1173        {
 31174            if (_dropped)
 31175                return;
 1176
 31177            var finished = false;
 1178            try
 1179            {
 31180                var envelope = JsonSerializer.Deserialize(message.EnvelopeJson, AsyncResponseEnvelopeJson.TypeInfo<T>())
 31181                if (envelope is null)
 1182                {
 31183                    finished = true;
 31184                    var error = new JsonException($"Failed to deserialize envelope for correlationId {_correlationId}.")
 31185                    AsyncResponseDiagnostics.SetError(_activity, "deserialize_failure", error.Message);
 31186                    _tcs.TrySetException(error);
 1187                }
 31188                else if (!AsyncResponseEnvelopeSchema.IsReadable(envelope.SchemaVersion))
 1189                {
 31190                    finished = true;
 31191                    var error = new InvalidOperationException(
 31192                        $"Response envelope for correlationId {_correlationId} has schema version {envelope.SchemaVersio
 31193                        $"which this build does not support (current: {AsyncResponseEnvelopeSchema.Current}).");
 31194                    AsyncResponseDiagnostics.SetError(_activity, "schema_mismatch", error.Message);
 31195                    _tcs.TrySetException(error);
 1196                }
 31197                else if (!envelope.Success)
 1198                {
 31199                    finished = true;
 31200                    var remoteFailure = new Exception(envelope.ExceptionMessage ?? "Unknown error during asynchronous pr
 31201                    if (!string.IsNullOrEmpty(envelope.ExceptionStackTrace))
 31202                        remoteFailure.Data["RemoteStackTrace"] = RemoteStackTrace.Cap(envelope.ExceptionStackTrace, _own
 31203                    AsyncResponseDiagnostics.SetError(_activity, "remote_failure", remoteFailure.Message);
 31204                    _tcs.TrySetException(remoteFailure);
 1205                }
 1206                else
 1207                {
 31208                    finished = await _completionPredicate(envelope.Payload!).ConfigureAwait(false);
 31209                    if (finished)
 31210                        _tcs.TrySetResult(envelope.Payload!);
 1211                }
 31212            }
 31213            catch (Exception ex)
 1214            {
 31215                finished = true;
 31216                _owner._logger.LogError(ex, "Error processing {Provider} response for correlationId {CorrelationId}.", _
 31217                AsyncResponseDiagnostics.SetError(_activity, ex);
 31218                _tcs.TrySetException(ex);
 31219            }
 1220            finally
 1221            {
 31222                if (finished)
 31223                    await CleanupOnceAsync(deleteRecoveryState: true).ConfigureAwait(false);
 1224            }
 31225        }
 1226
 1227        /// <summary>
 1228        /// Task-latched so EVERY caller completes only when the one real cleanup has finished —
 1229        /// a fire-once flag alone would let a disposing waiter racing the timeout return before
 1230        /// the response task was settled.
 1231        /// </summary>
 1232        public ValueTask CleanupOnceAsync(bool deleteRecoveryState)
 1233        {
 1234            Task task;
 31235            lock (_cleanupGate)
 1236            {
 31237                task = _cleanupTask ??= CleanupCoreAsync(deleteRecoveryState);
 31238            }
 1239
 31240            return task.IsCompletedSuccessfully ? ValueTask.CompletedTask : new ValueTask(task);
 1241        }
 1242
 1243        /// <summary>
 1244        /// Dispose-path cleanup: DRAINS the per-correlation serial executor before settling. A
 1245        /// delivery may be mid <c>Until</c>-predicate holding a message the claim already acked;
 1246        /// the marker work item completes only after that in-flight item finished, so by the time
 1247        /// cleanup cancels, the task is either settled by the delivery or genuinely undelivered —
 1248        /// never a cancellation stealing a consumed response. Must NOT be called from dispatch
 1249        /// code (which runs ON the executor): the dispatch-triggered cleanup calls
 1250        /// <see cref="CleanupOnceAsync"/> directly, its task already settled.
 1251        /// <para>
 1252        /// The drain is bounded by <c>DisposalDrainTimeout</c> — a single budget covering marker
 1253        /// ADMISSION too, since a full bounded queue behind a wedged item blocks the enqueue
 1254        /// itself. A lapsed budget must not fall back to the cleanup's cancel: the wedged delivery
 1255        /// holds a message the claim already consumed, and "canceled" would tell a re-attaching
 1256        /// caller nothing was delivered. It faults the task with the explicit indeterminate
 1257        /// contract instead, routing durable flows to a fresh idempotent restart. An enqueue
 1258        /// suppressed by the registry's tombstone is the opposite case — the retired executor
 1259        /// finished everything it ever admitted, so nothing is in flight and the plain cancel
 1260        /// below is truthful.
 1261        /// </para>
 1262        /// </summary>
 1263        public async ValueTask DrainThenCleanupAsync(bool deleteRecoveryState)
 1264        {
 31265            if (Volatile.Read(ref _cleanupStarted) == 0)
 1266            {
 11267                var drainTimeout = _owner._options.DisposalDrainTimeout;
 11268                var drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 1269                try
 1270                {
 11271                    using var budget = new CancellationTokenSource(drainTimeout);
 11272                    var accepted = await _owner._executors.EnqueueAsync(_owner.ChannelName(_correlationId), () =>
 11273                    {
 11274                        drained.TrySetResult();
 11275                        return Task.CompletedTask;
 11276                    }, budget.Token).ConfigureAwait(false);
 11277                    if (accepted)
 11278                        await drained.Task.WaitAsync(budget.Token).ConfigureAwait(false);
 11279                }
 11280                catch (Exception drainEx)
 1281                {
 1282                    // Budget lapse — or an unforeseen drain failure: either way the marker never
 1283                    // ran, so an in-flight delivery cannot be ruled out (only accepted=false
 1284                    // proves the executor finished everything). Settlement unproven means the
 1285                    // cleanup's cancel below would be a false "nothing was delivered" — fault
 1286                    // with the explicit indeterminate contract instead. A TrySetResult from the
 1287                    // late-finishing dispatch loses against this and is dropped; its cleanup
 1288                    // call is a no-op behind the latch.
 11289                    _owner._logger.LogWarning(
 11290                        "Disposal drain for {Provider} correlationId {CorrelationId} did not prove settlement within {Dr
 11291                        _owner._providerName, _correlationId, drainTimeout);
 11292                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain did not prove
 11293                    if (drainEx is not OperationCanceledException)
 11294                        _owner._logger.LogDebug(drainEx, "Dispatch drain failed for correlationId {CorrelationId}.", _co
 11295                    _tcs.TrySetException(new AsyncResponseIndeterminateDeliveryException(_correlationId, drainTimeout));
 11296                }
 11297            }
 1298
 31299            await CleanupOnceAsync(deleteRecoveryState).ConfigureAwait(false);
 31300        }
 1301
 1302        private async Task CleanupCoreAsync(bool deleteRecoveryState)
 1303        {
 1304            // The flag is kept alongside the task latch: dispatch cores and white-box tests gate
 1305            // on it, and a pre-set flag (test isolation) must keep skipping the network cleanup.
 31306            if (Interlocked.Exchange(ref _cleanupStarted, 1) != 0)
 31307                return;
 1308
 1309            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 1310            // forever for callers that hold it directly — the timeout dies with this cleanup, so
 1311            // nothing else could ever complete the task. This also covers channel DisposeAsync at
 1312            // host shutdown, which runs this cleanup over every in-flight subscription and would
 1313            // otherwise hang still-awaiting WaitAsync callers. Cancellation is a no-op after a
 1314            // normal completion, timeout, fault, or a delivery drained by DrainThenCleanupAsync.
 31315            _tcs.TrySetCanceled();
 1316
 1317            try
 1318            {
 31319                _dropped = true;
 1320
 1321                try
 1322                {
 1323                    // Delete the recovery state BEFORE removing the subscription (locally and in the
 1324                    // subscriber store). In the reverse order a publish landing in the window sees
 1325                    // "no subscriber, state present" and fires a spurious recovery callback for a wait
 1326                    // that already reached a terminal state. In this order the window shows a
 1327                    // subscriber that drops the message — a late or duplicate terminal message is
 1328                    // droppable; a resurrected recovery callback is not. (Shutdown/redeploy paths pass
 1329                    // deleteRecoveryState: false and keep the state for lost-subscriber recovery.)
 31330                    if (deleteRecoveryState)
 31331                        await _owner._recoveryStateStore.TryDeleteAsync(_correlationId, Id).ConfigureAwait(false);
 11332                }
 31333                catch (Exception ex)
 1334                {
 1335                    // Best-effort: the state expires on its own, and a transient store failure must
 1336                    // not skip the local teardown below.
 31337                    _owner._logger.LogError(ex, "Failed to delete {Provider} recovery state for correlationId {Correlati
 31338                }
 1339
 1340                try
 1341                {
 31342                    await _owner._store.DeleteSubscriberAsync(_correlationId, Id, CancellationToken.None).ConfigureAwait
 11343                }
 31344                catch (Exception ex)
 1345                {
 1346                    // Best-effort: an orphaned subscriber record ages out via the heartbeat timeout.
 31347                    _owner._logger.LogError(ex, "Failed to delete {Provider} subscriber {SubscriberRecord} for correlati
 31348                }
 1349            }
 1350            finally
 1351            {
 1352                // Purely local teardown runs no matter which network call above failed — the
 1353                // cleanup latch is already set, so a skipped removal would leak the subscription
 1354                // map entry and the executor until process exit.
 31355                _owner.RemoveSubscription(_correlationId, Id);
 1356
 1357                // Schedule the executor retirement on the thread pool; do not await directly —
 1358                // dispatch-loop deliveries run this cleanup ON the executor, and RemoveAsync waits
 1359                // for the executor's drain loop to finish, which would be a circular await.
 31360                var channelName = _owner.ChannelName(_correlationId);
 31361                _ = Task.Run(async () =>
 31362                {
 31363                    try
 31364                    {
 31365                        await _owner._executors.RemoveAsync(channelName).ConfigureAwait(false);
 31366                    }
 11367                    catch (Exception ex)
 31368                    {
 11369                        _owner._logger.LogError(ex, "Failed to retire the executor for channel {Channel}.", channelName)
 11370                    }
 31371                });
 1372
 31373                if (TimeoutRegistration is not null)
 11374                    await TimeoutRegistration().ConfigureAwait(false);
 31375                TimeoutCancellation?.Dispose();
 31376                _activity?.Dispose();
 1377            }
 31378        }
 1379
 1380        public async ValueTask DropLocalAsync(CancellationToken cancellationToken)
 1381        {
 11382            _dropped = true;
 11383            await _owner._store.DeleteSubscriberAsync(_correlationId, Id, cancellationToken).ConfigureAwait(false);
 11384        }
 1385    }
 1386}

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory, AsyncResponse.Channels.SqlServer.SqlServerChannelSql, AsyncResponse.IRecoveryStateStore, AsyncResponse.Channels.SqlServer.SqlServerAsyncResponseChannelOptions, AsyncResponse.AsyncResponseContextPropagation, Microsoft.Extensions.Logging.ILogger, string, string, string, string, string)
StartWakeListener(System.Threading.CancellationToken)
CreateResponseWaiter<T>(string, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Nullable<System.TimeSpan>)
CreateRecoverableResponseWaiter<T>(string, AsyncResponse.ReflectionCallDto, AsyncResponse.ReflectionCallDto, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Nullable<System.TimeSpan>)
CreateResponseWaiterCore()
Process()
ProcessUnderCapturedContextAsync()
SetResponse<T>(T, string, System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(object, string, System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(string, string, System.Threading.CancellationToken)
SetResponseCore()
SetRawResponseJsonCore()
SetException()
CountActiveSubscribersAsync()
DropLocalSubscriptionsAsync()
HasLiveSubscriberAsync()
AddSubscription(string, AsyncResponse.Channels.DbAsyncResponseChannelBase.IDbSubscription)
RemoveSubscription(string, System.Guid)
EnsureListenerStarted()
HeartbeatLoopAsync()
SnapshotActiveRegistrations()
DispatchLoopAsync()
CollectDispatchScopeAsync()
DispatchPendingMessagesAsync()
PublishMessageAsync()
DispatchMessageToSubscribersAsync()
IsWithinWatermark(AsyncResponse.Channels.DbAsyncResponseChannelBase.IDbSubscription, AsyncResponse.Channels.SqlServer.SqlServerChannelMessage)
TryDispatchLocalSubscribersAsync()
BeginConfirmation(System.Guid)
TryConfirmDeliveryAsync()
SignalDispatcher(string)
WaitForAcknowledgementAsync()
SerializeRawSuccessEnvelope(string)
HandleWaiterTimeoutAsync()
DisposeAsync()
get_MessageId()
get_Delivered()
Dispose()
.ctor(AsyncResponse.Channels.DbAsyncResponseChannelBase, string, System.Guid, System.DateTimeOffset, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Threading.Tasks.TaskCompletionSource<T>, System.Diagnostics.Activity)
get_Dropped()
HasSeen(System.Guid)
MarkSeen(System.Guid)
PruneSeen(System.DateTimeOffset)
ProcessAsync()
CleanupOnceAsync(bool)
DrainThenCleanupAsync()
CleanupCoreAsync()
<CleanupCoreAsync()
DropLocalAsync()