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

Information
Class: AsyncResponse.AsyncResponseWatchdogSnapshot
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseWatchdog.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 620
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_ScanCompletedUtc()100%11100%
get_ScanInterval()100%11100%
get_Report()100%11100%
get_Error()100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseWatchdog.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Diagnostics;
 5
 6namespace AsyncResponse;
 7
 8/// <summary>Options for the async-response recovery watchdog.</summary>
 9public sealed class AsyncResponseWatchdogOptions
 10{
 11    /// <summary>
 12    /// Whether the watchdog runs. Default: <c>true</c>. Set to <c>false</c> to disable it — for
 13    /// example in all but one host when several hosts share one durable recovery store, so the
 14    /// scan and its warnings are not duplicated.
 15    /// </summary>
 16    public bool Enabled { get; set; } = true;
 17
 18    /// <summary>How often the watchdog scans the persisted recovery state. Default: 6 hours.</summary>
 19    public TimeSpan Interval { get; set; } = TimeSpan.FromHours(6);
 20
 21    /// <summary>
 22    /// Age past which a recovery entry with no live subscriber is reported as stale.
 23    /// Default: 24 hours.
 24    /// </summary>
 25    public TimeSpan StaleAfter { get; set; } = TimeSpan.FromHours(24);
 26
 27    /// <summary>Delay before the first scan, so startup is never blocked. Default: 5 minutes.</summary>
 28    public TimeSpan StartupDelay { get; set; } = TimeSpan.FromMinutes(5);
 29
 30    /// <summary>
 31    /// Upper bound on the random extra delay added to the startup delay and to every interval
 32    /// wait. Default: 10% of <see cref="Interval"/>.
 33    /// <para>
 34    /// Replicas deployed together start together, so a fixed interval keeps them scanning in
 35    /// lockstep forever: every host walks the same recovery store and fires its own liveness probe
 36    /// per correlation id at the same instant, turning a routine scan into a synchronized burst
 37    /// against the channel. An independent offset per replica spreads that out and costs nothing —
 38    /// the scan is a periodic report, so when it runs within the interval does not matter.
 39    /// </para>
 40    /// <para>
 41    /// Set to <see cref="TimeSpan.Zero"/> for an exactly-periodic scan (single-host deployments,
 42    /// or tests that assert on scan timing).
 43    /// </para>
 44    /// </summary>
 45    public TimeSpan? IntervalJitter { get; set; }
 46
 47    /// <summary>The resolved jitter bound: <see cref="IntervalJitter"/> or 10% of the interval.</summary>
 48    internal TimeSpan ResolvedJitter => IntervalJitter ?? TimeSpan.FromTicks(Interval.Ticks / 10);
 49
 50    /// <summary>
 51    /// Upper bound on the recovery entries one scan buffers (the scan dedupes in memory before
 52    /// probing liveness). When the store holds more, the scan stops enumerating at the cap,
 53    /// reports the buffered subset (<see cref="AsyncResponseWatchdogReport.Truncated"/> is set,
 54    /// the health check degrades), and logs a warning — bounding scan memory on very large
 55    /// stores at the cost of an incomplete staleness report. The count is a MEMORY bound, not a
 56    /// flow count: grouped entries occupy one slot per unique correlation id, correlation-less
 57    /// entries one slot per row. Default: 100 000.
 58    /// </summary>
 59    public int MaxScanEntries { get; set; } = 100_000;
 60
 61    /// <summary>
 62    /// Upper bound on liveness probes one scan runs concurrently. Each probe is its own round
 63    /// trip to the channel (a store query or broker request) and a scan issues one per buffered
 64    /// entry, so probing strictly sequentially would serialize up to <see cref="MaxScanEntries"/>
 65    /// round trips per scan. Must be at least 1 (strictly sequential). Default: 8.
 66    /// </summary>
 67    public int ProbeConcurrency { get; set; } = 8;
 68
 69    /// <summary>Validates the scan-loop knobs so a bad value fails at startup, not mid-scan.</summary>
 70    internal void Validate()
 71    {
 72        // Interval and StartupDelay arm Task.Delay in the scan loop; an out-of-range value there
 73        // would throw outside the per-scan try, fault the background service mid-run, and (with
 74        // the default BackgroundServiceExceptionBehavior.StopHost) take the host down instead of
 75        // failing fast where the misconfiguration is visible.
 76        AsyncResponseChannelOptions.EnsureTimerBacked(Interval, nameof(AsyncResponseWatchdogOptions), nameof(Interval));
 77        AsyncResponseChannelOptions.EnsureTimerBackedAllowZero(StartupDelay, nameof(AsyncResponseWatchdogOptions), nameo
 78        AsyncResponseChannelOptions.EnsureTimerBackedAllowZero(ResolvedJitter, nameof(AsyncResponseWatchdogOptions), nam
 79
 80        // Validating the parts separately is not enough, because NextWait arms their SUM: two
 81        // individually legal values can still hand Task.Delay an out-of-range delay and fault the
 82        // background service on its very first wait — the exact delayed, host-stopping failure
 83        // these checks exist to prevent.
 84        AsyncResponseChannelOptions.EnsureTimerBackedAllowZero(
 85            StartupDelay + ResolvedJitter, nameof(AsyncResponseWatchdogOptions), $"{nameof(StartupDelay)} + {nameof(Inte
 86        AsyncResponseChannelOptions.EnsureTimerBackedAllowZero(
 87            Interval + ResolvedJitter, nameof(AsyncResponseWatchdogOptions), $"{nameof(Interval)} + {nameof(IntervalJitt
 88
 89        // Jitter is a de-synchronization offset, not a second interval. Allowing it to exceed the
 90        // interval lets a perfectly healthy next scan be scheduled beyond the freshness budget the
 91        // recovery health check derives from Interval, so readiness reports the watchdog dead while
 92        // it is doing exactly what it was configured to do.
 93        if (ResolvedJitter > Interval)
 94        {
 95            throw new InvalidOperationException(
 96                $"{nameof(AsyncResponseWatchdogOptions)}.{nameof(IntervalJitter)} ({ResolvedJitter}) cannot exceed " +
 97                $"{nameof(Interval)} ({Interval}): the health check derives its freshness budget from the interval, so a
 98                "larger offset would report a healthy watchdog as stale.");
 99        }
 100
 101        // The probe fan-out degree feeds Parallel.ForEachAsync mid-scan, which rejects values
 102        // below 1 with the same delayed, host-stopping failure mode.
 103        if (ProbeConcurrency < 1)
 104            throw new InvalidOperationException(
 105                $"{nameof(AsyncResponseWatchdogOptions)}.{nameof(ProbeConcurrency)} must be at least 1.");
 106
 107        // The buffer cap gates growth with ">= MaxScanEntries", so a non-positive value truncates
 108        // on the FIRST entry: every scan then classifies an empty set and publishes a report that
 109        // is Truncated with zeroed counters. The health check degrades permanently and no stale
 110        // registration is ever reported — the stuck-flow alarm is silently off while the logs
 111        // still show a scan completing each interval. Nothing later in the scan can catch this,
 112        // so it has to fail here. ("Unlimited" is not a supported value: the cap is a memory
 113        // bound, and int.MaxValue is the way to ask for effectively no limit.)
 114        if (MaxScanEntries < 1)
 115            throw new InvalidOperationException(
 116                $"{nameof(AsyncResponseWatchdogOptions)}.{nameof(MaxScanEntries)} must be at least 1; it bounds scan mem
 117                $"so use {int.MaxValue} for effectively no limit rather than zero.");
 118
 119        // Staleness is judged as "utcNow - registeredAtUtc >= StaleAfter", so a non-positive
 120        // threshold makes EVERY live registration stale and logs a Warning per entry on every
 121        // scan — an alarm that fires constantly is the same as no alarm at all.
 122        if (StaleAfter <= TimeSpan.Zero)
 123            throw new InvalidOperationException(
 124                $"{nameof(AsyncResponseWatchdogOptions)}.{nameof(StaleAfter)} must be positive; a non-positive threshold
 125                "every live registration as stale.");
 126    }
 127}
 128
 129/// <summary>
 130/// Snapshot of one persisted recovery entry as observed by the watchdog.
 131/// </summary>
 132/// <param name="CorrelationId">The correlation id the entry belongs to.</param>
 133/// <param name="RegisteredAtUtc">When the waiter registered, or <c>null</c> if unknown.</param>
 134/// <param name="ActiveSubscribers">
 135/// Live subscribers awaiting this correlation id's channel: <c>0</c> = no live waiter, a positive
 136/// value = at least one, a negative value = liveness could not be probed (no
 137/// <see cref="IActiveSubscriberProbe"/>).
 138/// </param>
 139/// <param name="PayloadTypeFullName">The payload type the waiter subscribed for.</param>
 140public sealed record RecoveryStateObservation(
 141    string? CorrelationId,
 142    DateTime? RegisteredAtUtc,
 143    long ActiveSubscribers,
 144    string? PayloadTypeFullName);
 145
 146/// <summary>
 147/// Outcome of one watchdog scan attempt, as published for consumers
 148/// (e.g. <see cref="AsyncResponseRecoveryHealthCheck"/>).
 149/// </summary>
 150/// <param name="ScanCompletedUtc">When the scan attempt finished.</param>
 151/// <param name="ScanInterval">The configured scan interval, so consumers can judge snapshot freshness.</param>
 152/// <param name="Report">The evaluation result; <c>null</c> when the scan failed.</param>
 153/// <param name="Error">The scan failure message; <c>null</c> when the scan succeeded.</param>
 1637154public sealed record AsyncResponseWatchdogSnapshot(
 50155    DateTime ScanCompletedUtc,
 74156    TimeSpan ScanInterval,
 178157    AsyncResponseWatchdogReport? Report,
 1671158    string? Error);
 159
 160/// <summary>
 161/// Holds the latest watchdog scan result. The watchdog is the single writer; readers (e.g. the
 162/// readiness health check) get a cheap, consistent snapshot without touching the recovery store.
 163/// </summary>
 164public sealed class AsyncResponseWatchdogState
 165{
 166    private volatile AsyncResponseWatchdogSnapshot? _latest;
 167    private volatile WatchdogActivation? _activation;
 168
 169    /// <summary>The most recent scan outcome, or <c>null</c> when no scan has completed yet.</summary>
 170    public AsyncResponseWatchdogSnapshot? Latest => _latest;
 171
 172    /// <summary>
 173    /// Whether this host's watchdog scans: <c>true</c> once its scan loop is armed, <c>false</c>
 174    /// when it declined to run (disabled via options, or the channel registers no scanner), and
 175    /// <c>null</c> while unknown (the watchdog has not started, or none is registered).
 176    /// </summary>
 177    public bool? Scanning => _activation?.Scanning;
 178
 179    /// <summary>Why this host's watchdog does not scan, when <see cref="Scanning"/> is <c>false</c>.</summary>
 180    public string? IdleReason => _activation is { Scanning: false } activation ? activation.IdleReason : null;
 181
 182    internal WatchdogActivation? Activation => _activation;
 183
 184    /// <summary>Publishes the latest watchdog snapshot for health checks and metrics.</summary>
 185    public void Publish(AsyncResponseWatchdogSnapshot snapshot) => _latest = snapshot;
 186
 187    /// <summary>
 188    /// Marks this host's watchdog as armed, so the health check can hold it to a first-scan
 189    /// deadline instead of attesting "no scan yet" for a loop that died before ever publishing.
 190    /// </summary>
 191    internal void MarkScanning(DateTime startedUtc, TimeSpan startupDelay, TimeSpan interval)
 192        => _activation = new WatchdogActivation(Scanning: true, IdleReason: null, startedUtc, startupDelay, interval);
 193
 194    /// <summary>
 195    /// Marks this host's watchdog as deliberately idle, so the health check can attest "this host
 196    /// does not scan" (the documented multi-host pattern) instead of "no scan yet".
 197    /// </summary>
 198    internal void MarkIdle(string reason)
 199        => _activation = new WatchdogActivation(Scanning: false, reason, StartedUtc: null, default, default);
 200
 201    /// <summary>How the watchdog resolved its startup guards. Single writer: the watchdog.</summary>
 202    internal sealed record WatchdogActivation(
 203        bool Scanning,
 204        string? IdleReason,
 205        DateTime? StartedUtc,
 206        TimeSpan StartupDelay,
 207        TimeSpan Interval);
 208}
 209
 210/// <summary>Result of evaluating a snapshot of the persisted recovery state.</summary>
 211/// <param name="TotalEntries">Recovery registrations observed, deduplicated per correlation id.</param>
 212/// <param name="EntriesWithActiveWaiter">Observed entries with at least one live subscriber.</param>
 213/// <param name="StaleEntries">Entries with no live waiter registered longer ago than the staleness threshold.</param>
 214/// <param name="UnknownAgeEntries">Entries with no live waiter and no registration timestamp — reported separately, nev
 215/// <param name="UnprobeableEntries">
 216/// Entries whose waiter liveness could not be probed (negative
 217/// <see cref="RecoveryStateObservation.ActiveSubscribers"/>: the probe failed, or no
 218/// <see cref="IActiveSubscriberProbe"/> is registered). Their staleness is unknown and they are
 219/// never flagged stale, so the health check degrades rather than letting a probe outage read as
 220/// a clean pass with zeroed counters.
 221/// </param>
 222/// <param name="Truncated">
 223/// Whether the scan stopped at <see cref="AsyncResponseWatchdogOptions.MaxScanEntries"/> before
 224/// exhausting the store — the counts and stale list then describe the buffered subset only, and
 225/// the health check degrades rather than attesting a staleness verdict it cannot back.
 226/// </param>
 227public sealed record AsyncResponseWatchdogReport(
 228    int TotalEntries,
 229    int EntriesWithActiveWaiter,
 230    IReadOnlyList<RecoveryStateObservation> StaleEntries,
 231    int UnknownAgeEntries,
 232    int UnprobeableEntries = 0,
 233    bool Truncated = false)
 234{
 235    /// <summary>
 236    /// Pure evaluation: an entry is <em>stale</em> when nobody is subscribed to its channel
 237    /// (the waiter died) and it has been registered for longer than <paramref name="staleAfter"/>
 238    /// without any response triggering the lost-subscriber recovery. Entries without a
 239    /// registration timestamp are reported separately as unknown-age. Entries whose liveness could
 240    /// not be probed (negative <see cref="RecoveryStateObservation.ActiveSubscribers"/>) are never
 241    /// flagged stale, to avoid false positives.
 242    /// </summary>
 243    public static AsyncResponseWatchdogReport Evaluate(
 244        IReadOnlyCollection<RecoveryStateObservation> entries,
 245        DateTime utcNow,
 246        TimeSpan staleAfter)
 247    {
 248        // Dedupe keeps the OLDEST registration per correlation id. Sibling registrations share a
 249        // correlation id by design (fan-out waiters, a flow re-attaching after a crash), and the
 250        // scanner contract deliberately promises no ordering — preferring the oldest makes the
 251        // verdict order-independent, so a young sibling can never mask an older stale one.
 252        // Entries without a correlation id cannot be grouped and are classified individually.
 253        // Structures stay lazily allocated: an empty snapshot allocates nothing.
 254        Dictionary<string, RecoveryStateObservation>? byCorrelationId = null;
 255        List<RecoveryStateObservation>? ungrouped = null;
 256
 257        foreach (var entry in entries)
 258        {
 259            if (string.IsNullOrEmpty(entry.CorrelationId))
 260            {
 261                (ungrouped ??= []).Add(entry);
 262                continue;
 263            }
 264
 265            byCorrelationId ??= new Dictionary<string, RecoveryStateObservation>(entries.Count, StringComparer.Ordinal);
 266            if (!byCorrelationId.TryGetValue(entry.CorrelationId, out var kept) || IsOlder(entry, kept))
 267                byCorrelationId[entry.CorrelationId] = entry;
 268        }
 269
 270        var totalEntries = 0;
 271        var entriesWithActiveWaiter = 0;
 272        var unknownAgeEntries = 0;
 273        var unprobeableEntries = 0;
 274        List<RecoveryStateObservation>? staleEntries = null;
 275
 276        if (byCorrelationId is not null)
 277        {
 278            foreach (var entry in byCorrelationId.Values)
 279                Classify(entry, utcNow, staleAfter, ref totalEntries, ref entriesWithActiveWaiter, ref unknownAgeEntries
 280        }
 281
 282        if (ungrouped is not null)
 283        {
 284            foreach (var entry in ungrouped)
 285                Classify(entry, utcNow, staleAfter, ref totalEntries, ref entriesWithActiveWaiter, ref unknownAgeEntries
 286        }
 287
 288        return new AsyncResponseWatchdogReport(
 289            totalEntries,
 290            entriesWithActiveWaiter,
 291            staleEntries ?? [],
 292            unknownAgeEntries,
 293            unprobeableEntries);
 294    }
 295
 296    /// <summary>Prefers the entry with the oldest known registration; a known age beats an unknown one.</summary>
 297    internal static bool IsOlder(RecoveryStateObservation candidate, RecoveryStateObservation kept)
 298        => candidate.RegisteredAtUtc is { } candidateRegistered
 299           && (kept.RegisteredAtUtc is not { } keptRegistered || candidateRegistered < keptRegistered);
 300
 301    private static void Classify(
 302        RecoveryStateObservation entry,
 303        DateTime utcNow,
 304        TimeSpan staleAfter,
 305        ref int totalEntries,
 306        ref int entriesWithActiveWaiter,
 307        ref int unknownAgeEntries,
 308        ref int unprobeableEntries,
 309        ref List<RecoveryStateObservation>? staleEntries)
 310    {
 311        totalEntries++;
 312        var activeSubscribers = entry.ActiveSubscribers;
 313        if (activeSubscribers > 0)
 314        {
 315            entriesWithActiveWaiter++;
 316            return;
 317        }
 318
 319        // Negative liveness means it could not be probed; never flag those as stale, but count
 320        // them — dropped from every bucket, a probe outage would read as a clean pass.
 321        if (activeSubscribers != 0)
 322        {
 323            unprobeableEntries++;
 324            return;
 325        }
 326
 327        if (entry.RegisteredAtUtc is not { } registeredAtUtc)
 328        {
 329            unknownAgeEntries++;
 330            return;
 331        }
 332
 333        if (utcNow - registeredAtUtc >= staleAfter)
 334            (staleEntries ??= []).Add(entry);
 335    }
 336}
 337
 338/// <summary>
 339/// Periodic, report-only scanner of the persisted async-response recovery state. It is part of the
 340/// engine and runs by default for whatever channel is registered: it enumerates recovery entries
 341/// through <see cref="IRecoveryStateScanner"/> and checks waiter liveness through
 342/// <see cref="IActiveSubscriberProbe"/>, so it is independent of any specific store or broker.
 343/// <para>
 344/// Every recovery entry represents an outstanding wait registration. A healthy entry either has a
 345/// live subscriber (the waiter is awaiting in some process) or is young — armed recovery state for
 346/// a response that has not arrived yet. An entry that is <em>old</em> and has <em>no subscriber</em>
 347/// means the waiter died and nothing (response, resume, retry) has touched the flow since: the
 348/// precursor of an operation stuck "in progress". The watchdog logs a warning per such entry and
 349/// publishes a summary snapshot for the health check. It deliberately performs no remediation —
 350/// recovery belongs to the lost-subscriber dispatcher and the flows' own retry paths.
 351/// </para>
 352/// </summary>
 353internal sealed class AsyncResponseWatchdog : BackgroundService
 354{
 355    private readonly IRecoveryStateScanner? _scanner;
 356    private readonly IActiveSubscriberProbe? _subscriberProbe;
 357    private readonly AsyncResponseWatchdogState _state;
 358    private readonly AsyncResponseWatchdogOptions _options;
 359    private readonly TimeProvider _timeProvider;
 360    private readonly ILogger<AsyncResponseWatchdog> _logger;
 361
 362    /// <summary>Creates the background recovery watchdog.</summary>
 363    public AsyncResponseWatchdog(
 364        IEnumerable<IRecoveryStateScanner> scanners,
 365        IEnumerable<IActiveSubscriberProbe> subscriberProbes,
 366        AsyncResponseWatchdogState state,
 367        IOptions<AsyncResponseOptions> options,
 368        ILogger<AsyncResponseWatchdog> logger,
 369        TimeProvider? timeProvider = null)
 370    {
 371        _scanner = scanners.FirstOrDefault();
 372        _subscriberProbe = subscriberProbes.FirstOrDefault();
 373        _state = state;
 374        _options = options.Value.Watchdog;
 375        _options.Validate();
 376        _timeProvider = timeProvider ?? TimeProvider.System;
 377        _logger = logger;
 378    }
 379
 380    /// <summary>
 381    /// A base wait plus a random offset up to <see cref="AsyncResponseWatchdogOptions.ResolvedJitter"/>,
 382    /// so replicas that started together do not stay in step. Drawn per wait rather than once per
 383    /// process: a single per-process offset keeps the SPACING identical, so two replicas that
 384    /// happen to draw similar offsets collide on every scan thereafter instead of just the first.
 385    /// </summary>
 386    private TimeSpan NextWait(TimeSpan baseDelay)
 387    {
 388        var jitter = _options.ResolvedJitter;
 389        return jitter <= TimeSpan.Zero
 390            ? baseDelay
 391            : baseDelay + TimeSpan.FromTicks(Random.Shared.NextInt64(jitter.Ticks + 1));
 392    }
 393
 394    /// <inheritdoc />
 395    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 396    {
 397        if (!_options.Enabled)
 398        {
 399            _state.MarkIdle($"disabled via {nameof(AsyncResponseOptions)}.{nameof(AsyncResponseOptions.Watchdog)}.{nameo
 400            _logger.LogInformation("Recovery watchdog disabled via options; not scanning.");
 401            return;
 402        }
 403
 404        if (_scanner is null)
 405        {
 406            _state.MarkIdle($"no {nameof(IRecoveryStateScanner)} registered (the configured channel does not support sca
 407            _logger.LogInformation("Recovery watchdog idle: no IRecoveryStateScanner registered (the configured channel 
 408            return;
 409        }
 410
 411        // Only a watchdog that actually scans may take over the process-wide gauge holder: a
 412        // disabled or scanner-less host (the documented multi-host pattern) would otherwise
 413        // permanently zero the gauges for the host that does scan.
 414        _state.MarkScanning(_timeProvider.GetUtcNow().UtcDateTime, _options.StartupDelay, _options.Interval);
 415        AsyncResponseDiagnostics.EnsureWatchdogGauges(_state);
 416
 417        _logger.LogInformation("Recovery watchdog started. Interval: {Interval}, stale threshold: {StaleAfter}.", _optio
 418
 419        try
 420        {
 421            await Task.Delay(NextWait(_options.StartupDelay), _timeProvider, stoppingToken).ConfigureAwait(false);
 422
 423            while (!stoppingToken.IsCancellationRequested)
 424            {
 425                try
 426                {
 427                    var report = await ScanOnceAsync(stoppingToken).ConfigureAwait(false);
 428                    _state.Publish(new AsyncResponseWatchdogSnapshot(_timeProvider.GetUtcNow().UtcDateTime, _options.Int
 429                }
 430                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 431                {
 432                    break;
 433                }
 434                catch (Exception ex)
 435                {
 436                    _logger.LogError(ex, "Recovery watchdog scan failed; next attempt in {Interval}.", _options.Interval
 437                    _state.Publish(new AsyncResponseWatchdogSnapshot(_timeProvider.GetUtcNow().UtcDateTime, _options.Int
 438                }
 439
 440                await Task.Delay(NextWait(_options.Interval), _timeProvider, stoppingToken).ConfigureAwait(false);
 441            }
 442        }
 443        catch (OperationCanceledException)
 444        {
 445            // Host shutdown.
 446        }
 447    }
 448
 449    /// <inheritdoc />
 450    public override async Task StopAsync(CancellationToken cancellationToken)
 451    {
 452        try
 453        {
 454            await base.StopAsync(cancellationToken).ConfigureAwait(false);
 455        }
 456        finally
 457        {
 458            AsyncResponseDiagnostics.ReleaseWatchdogGauges(_state);
 459        }
 460    }
 461
 462    /// <inheritdoc />
 463    public override void Dispose()
 464    {
 465        // Covers hosts torn down without a graceful StopAsync. The release is identity-conditional
 466        // and idempotent, so the double call on the graceful path is harmless.
 467        AsyncResponseDiagnostics.ReleaseWatchdogGauges(_state);
 468        base.Dispose();
 469    }
 470
 471    private async Task<AsyncResponseWatchdogReport> ScanOnceAsync(CancellationToken cancellationToken)
 472    {
 473        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.watchdog.scan");
 474
 475        try
 476        {
 477            // Phase 1 — stream the scan and dedupe, keeping the OLDEST registration per
 478            // correlation id (see Evaluate for why oldest). Only the fields the classifier needs
 479            // are buffered, not whole recovery states. Buffering before probing also lets the
 480            // scanner's enumeration (a long-lived reader connection on the relational stores)
 481            // finish before the per-id probe connections open.
 482            Dictionary<string, (DateTime? RegisteredAtUtc, string? PayloadTypeFullName)>? byCorrelationId = null;
 483            List<(string? CorrelationId, DateTime? RegisteredAtUtc, string? PayloadTypeFullName)>? ungrouped = null;
 484            var truncated = false;
 485            int BufferedCount() => (byCorrelationId?.Count ?? 0) + (ungrouped?.Count ?? 0);
 486
 487            await foreach (var entry in _scanner!.ScanAsync(cancellationToken).ConfigureAwait(false))
 488            {
 489                if (entry is null)
 490                    continue;
 491
 492                if (string.IsNullOrEmpty(entry.CorrelationId))
 493                {
 494                    // The cap gates growth only — replacing an already-buffered correlation id
 495                    // with an older sibling costs nothing, so oldest-wins keeps working at the cap.
 496                    if (BufferedCount() >= _options.MaxScanEntries)
 497                    {
 498                        truncated = true;
 499                        break;
 500                    }
 501
 502                    (ungrouped ??= []).Add((entry.CorrelationId, entry.RegisteredAtUtc, entry.PayloadTypeFullName));
 503                    continue;
 504                }
 505
 506                byCorrelationId ??= new Dictionary<string, (DateTime?, string?)>(StringComparer.Ordinal);
 507                if (byCorrelationId.TryGetValue(entry.CorrelationId, out var kept))
 508                {
 509                    if (entry.RegisteredAtUtc is { } candidate && (kept.RegisteredAtUtc is not { } existing || candidate
 510                        byCorrelationId[entry.CorrelationId] = (entry.RegisteredAtUtc, entry.PayloadTypeFullName);
 511                }
 512                else
 513                {
 514                    if (BufferedCount() >= _options.MaxScanEntries)
 515                    {
 516                        truncated = true;
 517                        break;
 518                    }
 519
 520                    byCorrelationId[entry.CorrelationId] = (entry.RegisteredAtUtc, entry.PayloadTypeFullName);
 521                }
 522            }
 523
 524            // Phase 2 — one liveness probe per unique correlation id, fanned out with bounded
 525            // concurrency (each probe is its own channel round trip; strictly sequential awaits
 526            // would serialize up to MaxScanEntries of them per scan), then the same pure
 527            // classifier the report type exposes publicly, so this scan and Evaluate (the tested
 528            // and benchmarked surface) can never drift apart again.
 529            var pending = new List<(string? CorrelationId, DateTime? RegisteredAtUtc, string? PayloadTypeFullName)>(Buff
 530
 531            if (byCorrelationId is not null)
 532            {
 533                foreach (var (correlationId, entry) in byCorrelationId)
 534                    pending.Add((correlationId, entry.RegisteredAtUtc, entry.PayloadTypeFullName));
 535            }
 536
 537            if (ungrouped is not null)
 538                pending.AddRange(ungrouped);
 539
 540            // Per-slot writes keep the result independent of probe completion order. A probe
 541            // canceled by shutdown still aborts the whole fan-out: CountActiveSubscribersAsync
 542            // rethrows, and ForEachAsync cancels its siblings and surfaces the cancellation.
 543            var observations = new RecoveryStateObservation[pending.Count];
 544            await Parallel.ForEachAsync(
 545                Enumerable.Range(0, pending.Count),
 546                new ParallelOptions { MaxDegreeOfParallelism = _options.ProbeConcurrency, CancellationToken = cancellati
 547                async (index, ct) =>
 548                {
 549                    var (correlationId, registeredAtUtc, payloadTypeFullName) = pending[index];
 550                    observations[index] = new RecoveryStateObservation(
 551                        correlationId,
 552                        registeredAtUtc,
 553                        await CountActiveSubscribersAsync(correlationId, ct).ConfigureAwait(false),
 554                        payloadTypeFullName);
 555                }).ConfigureAwait(false);
 556
 557            var report = AsyncResponseWatchdogReport.Evaluate(observations, _timeProvider.GetUtcNow().UtcDateTime, _opti
 558
 559            if (truncated)
 560            {
 561                // Carried on the report itself, not just telemetry: the health check and gauges
 562                // read the report, and a silently truncated scan would otherwise attest a
 563                // staleness verdict it never actually computed.
 564                report = report with { Truncated = true };
 565                activity?.SetTag("asyncresponse.watchdog.truncated", true);
 566                _logger.LogWarning(
 567                    "Recovery watchdog scan stopped at the {MaxScanEntries}-entry buffer cap; staleness is reported for 
 568                    _options.MaxScanEntries);
 569            }
 570
 571            activity?.SetTag("asyncresponse.watchdog.total_entries", report.TotalEntries);
 572            activity?.SetTag("asyncresponse.watchdog.active_waiters", report.EntriesWithActiveWaiter);
 573            activity?.SetTag("asyncresponse.watchdog.stale_entries", report.StaleEntries.Count);
 574            activity?.SetTag("asyncresponse.watchdog.unknown_age_entries", report.UnknownAgeEntries);
 575            activity?.SetTag("asyncresponse.watchdog.unprobeable_entries", report.UnprobeableEntries);
 576
 577            _logger.LogInformation("Recovery watchdog scan complete. Outstanding registrations: {Total}, with live waite
 578
 579            foreach (var stale in report.StaleEntries)
 580            {
 581                _logger.LogWarning("Stale async-response recovery state — correlationId {CorrelationId}, payload type {P
 582            }
 583
 584            return report;
 585        }
 586        catch (Exception ex)
 587        {
 588            AsyncResponseDiagnostics.SetError(activity, ex);
 589            throw;
 590        }
 591    }
 592
 593    /// <summary>
 594    /// Liveness for one entry. Returns <c>-1</c> (unknown) when there is no probe or no correlation
 595    /// id; the report treats unknown liveness as "not stale" so it never raises a false alarm.
 596    /// </summary>
 597    private async ValueTask<long> CountActiveSubscribersAsync(string? correlationId, CancellationToken cancellationToken
 598    {
 599        if (_subscriberProbe is null || string.IsNullOrWhiteSpace(correlationId))
 600            return -1;
 601
 602        try
 603        {
 604            return await _subscriberProbe.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(f
 605        }
 606        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 607        {
 608            // Shutdown must abort the scan here, not degrade to -1: swallowed, a canceled probe
 609            // would let the loop grind through every remaining entry (throw/catch per id, delaying
 610            // shutdown) and then publish a snapshot attesting a completed scan whose liveness was
 611            // never probed. ExecuteAsync catches this as its stop signal.
 612            throw;
 613        }
 614        catch (Exception ex)
 615        {
 616            _logger.LogDebug(ex, "Recovery watchdog failed to probe subscribers for correlationId {CorrelationId}.", cor
 617            return -1;
 618        }
 619    }
 620}