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

Information
Class: AsyncResponse.AsyncResponseWatchdogReport
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/AsyncResponseWatchdog.cs
Line coverage
100%
Covered lines: 45
Uncovered lines: 0
Coverable lines: 45
Total lines: 424
Line coverage: 100%
Branch coverage
100%
Covered branches: 36
Total branches: 36
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Evaluate(...)100%2222100%
IsOlder(...)100%44100%
Classify(...)100%1010100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/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 recovery entries one scan buffers (the scan dedupes in memory before
 32    /// probing liveness). When the store holds more, the scan stops enumerating at the cap,
 33    /// reports the buffered subset (<see cref="AsyncResponseWatchdogReport.Truncated"/> is set,
 34    /// the health check degrades), and logs a warning — bounding scan memory on very large
 35    /// stores at the cost of an incomplete staleness report. The count is a MEMORY bound, not a
 36    /// flow count: grouped entries occupy one slot per unique correlation id, correlation-less
 37    /// entries one slot per row. Default: 100 000.
 38    /// </summary>
 39    public int MaxScanEntries { get; set; } = 100_000;
 40}
 41
 42/// <summary>
 43/// Snapshot of one persisted recovery entry as observed by the watchdog.
 44/// </summary>
 45/// <param name="CorrelationId">The correlation id the entry belongs to.</param>
 46/// <param name="RegisteredAtUtc">When the waiter registered, or <c>null</c> if unknown.</param>
 47/// <param name="ActiveSubscribers">
 48/// Live subscribers awaiting this correlation id's channel: <c>0</c> = no live waiter, a positive
 49/// value = at least one, a negative value = liveness could not be probed (no
 50/// <see cref="IActiveSubscriberProbe"/>).
 51/// </param>
 52/// <param name="PayloadTypeFullName">The payload type the waiter subscribed for.</param>
 53public sealed record RecoveryStateObservation(
 54    string? CorrelationId,
 55    DateTime? RegisteredAtUtc,
 56    long ActiveSubscribers,
 57    string? PayloadTypeFullName);
 58
 59/// <summary>
 60/// Outcome of one watchdog scan attempt, as published for consumers
 61/// (e.g. <see cref="AsyncResponseRecoveryHealthCheck"/>).
 62/// </summary>
 63/// <param name="ScanCompletedUtc">When the scan attempt finished.</param>
 64/// <param name="ScanInterval">The configured scan interval, so consumers can judge snapshot freshness.</param>
 65/// <param name="Report">The evaluation result; <c>null</c> when the scan failed.</param>
 66/// <param name="Error">The scan failure message; <c>null</c> when the scan succeeded.</param>
 67public sealed record AsyncResponseWatchdogSnapshot(
 68    DateTime ScanCompletedUtc,
 69    TimeSpan ScanInterval,
 70    AsyncResponseWatchdogReport? Report,
 71    string? Error);
 72
 73/// <summary>
 74/// Holds the latest watchdog scan result. The watchdog is the single writer; readers (e.g. the
 75/// readiness health check) get a cheap, consistent snapshot without touching the recovery store.
 76/// </summary>
 77public sealed class AsyncResponseWatchdogState
 78{
 79    private volatile AsyncResponseWatchdogSnapshot? _latest;
 80
 81    /// <summary>The most recent scan outcome, or <c>null</c> when no scan has completed yet.</summary>
 82    public AsyncResponseWatchdogSnapshot? Latest => _latest;
 83
 84    /// <summary>Publishes the latest watchdog snapshot for health checks and metrics.</summary>
 85    public void Publish(AsyncResponseWatchdogSnapshot snapshot) => _latest = snapshot;
 86}
 87
 88/// <summary>Result of evaluating a snapshot of the persisted recovery state.</summary>
 89/// <param name="TotalEntries">Recovery registrations observed, deduplicated per correlation id.</param>
 90/// <param name="EntriesWithActiveWaiter">Observed entries with at least one live subscriber.</param>
 91/// <param name="StaleEntries">Entries with no live waiter registered longer ago than the staleness threshold.</param>
 92/// <param name="UnknownAgeEntries">Entries with no live waiter and no registration timestamp — reported separately, nev
 93/// <param name="Truncated">
 94/// Whether the scan stopped at <see cref="AsyncResponseWatchdogOptions.MaxScanEntries"/> before
 95/// exhausting the store — the counts and stale list then describe the buffered subset only, and
 96/// the health check degrades rather than attesting a staleness verdict it cannot back.
 97/// </param>
 398public sealed record AsyncResponseWatchdogReport(
 399    int TotalEntries,
 3100    int EntriesWithActiveWaiter,
 3101    IReadOnlyList<RecoveryStateObservation> StaleEntries,
 3102    int UnknownAgeEntries,
 3103    bool Truncated = false)
 104{
 105    /// <summary>
 106    /// Pure evaluation: an entry is <em>stale</em> when nobody is subscribed to its channel
 107    /// (the waiter died) and it has been registered for longer than <paramref name="staleAfter"/>
 108    /// without any response triggering the lost-subscriber recovery. Entries without a
 109    /// registration timestamp are reported separately as unknown-age. Entries whose liveness could
 110    /// not be probed (negative <see cref="RecoveryStateObservation.ActiveSubscribers"/>) are never
 111    /// flagged stale, to avoid false positives.
 112    /// </summary>
 113    public static AsyncResponseWatchdogReport Evaluate(
 114        IReadOnlyCollection<RecoveryStateObservation> entries,
 115        DateTime utcNow,
 116        TimeSpan staleAfter)
 117    {
 118        // Dedupe keeps the OLDEST registration per correlation id. Sibling registrations share a
 119        // correlation id by design (fan-out waiters, a flow re-attaching after a crash), and the
 120        // scanner contract deliberately promises no ordering — preferring the oldest makes the
 121        // verdict order-independent, so a young sibling can never mask an older stale one.
 122        // Entries without a correlation id cannot be grouped and are classified individually.
 123        // Structures stay lazily allocated: an empty snapshot allocates nothing.
 3124        Dictionary<string, RecoveryStateObservation>? byCorrelationId = null;
 3125        List<RecoveryStateObservation>? ungrouped = null;
 126
 3127        foreach (var entry in entries)
 128        {
 3129            if (string.IsNullOrEmpty(entry.CorrelationId))
 130            {
 2131                (ungrouped ??= []).Add(entry);
 2132                continue;
 133            }
 134
 3135            byCorrelationId ??= new Dictionary<string, RecoveryStateObservation>(entries.Count, StringComparer.Ordinal);
 3136            if (!byCorrelationId.TryGetValue(entry.CorrelationId, out var kept) || IsOlder(entry, kept))
 3137                byCorrelationId[entry.CorrelationId] = entry;
 138        }
 139
 3140        var totalEntries = 0;
 3141        var entriesWithActiveWaiter = 0;
 3142        var unknownAgeEntries = 0;
 3143        List<RecoveryStateObservation>? staleEntries = null;
 144
 3145        if (byCorrelationId is not null)
 146        {
 3147            foreach (var entry in byCorrelationId.Values)
 3148                Classify(entry, utcNow, staleAfter, ref totalEntries, ref entriesWithActiveWaiter, ref unknownAgeEntries
 149        }
 150
 3151        if (ungrouped is not null)
 152        {
 2153            foreach (var entry in ungrouped)
 2154                Classify(entry, utcNow, staleAfter, ref totalEntries, ref entriesWithActiveWaiter, ref unknownAgeEntries
 155        }
 156
 3157        return new AsyncResponseWatchdogReport(
 3158            totalEntries,
 3159            entriesWithActiveWaiter,
 3160            staleEntries ?? [],
 3161            unknownAgeEntries);
 162    }
 163
 164    /// <summary>Prefers the entry with the oldest known registration; a known age beats an unknown one.</summary>
 165    internal static bool IsOlder(RecoveryStateObservation candidate, RecoveryStateObservation kept)
 2166        => candidate.RegisteredAtUtc is { } candidateRegistered
 2167           && (kept.RegisteredAtUtc is not { } keptRegistered || candidateRegistered < keptRegistered);
 168
 169    private static void Classify(
 170        RecoveryStateObservation entry,
 171        DateTime utcNow,
 172        TimeSpan staleAfter,
 173        ref int totalEntries,
 174        ref int entriesWithActiveWaiter,
 175        ref int unknownAgeEntries,
 176        ref List<RecoveryStateObservation>? staleEntries)
 177    {
 3178        totalEntries++;
 3179        var activeSubscribers = entry.ActiveSubscribers;
 3180        if (activeSubscribers > 0)
 181        {
 3182            entriesWithActiveWaiter++;
 3183            return;
 184        }
 185
 186        // Negative liveness means it could not be probed; never flag those as stale.
 3187        if (activeSubscribers != 0)
 2188            return;
 189
 3190        if (entry.RegisteredAtUtc is not { } registeredAtUtc)
 191        {
 2192            unknownAgeEntries++;
 2193            return;
 194        }
 195
 3196        if (utcNow - registeredAtUtc >= staleAfter)
 3197            (staleEntries ??= []).Add(entry);
 3198    }
 199}
 200
 201/// <summary>
 202/// Periodic, report-only scanner of the persisted async-response recovery state. It is part of the
 203/// engine and runs by default for whatever channel is registered: it enumerates recovery entries
 204/// through <see cref="IRecoveryStateScanner"/> and checks waiter liveness through
 205/// <see cref="IActiveSubscriberProbe"/>, so it is independent of any specific store or broker.
 206/// <para>
 207/// Every recovery entry represents an outstanding wait registration. A healthy entry either has a
 208/// live subscriber (the waiter is awaiting in some process) or is young — armed recovery state for
 209/// a response that has not arrived yet. An entry that is <em>old</em> and has <em>no subscriber</em>
 210/// means the waiter died and nothing (response, resume, retry) has touched the flow since: the
 211/// precursor of an operation stuck "in progress". The watchdog logs a warning per such entry and
 212/// publishes a summary snapshot for the health check. It deliberately performs no remediation —
 213/// recovery belongs to the lost-subscriber dispatcher and the flows' own retry paths.
 214/// </para>
 215/// </summary>
 216internal sealed class AsyncResponseWatchdog : BackgroundService
 217{
 218    private readonly IRecoveryStateScanner? _scanner;
 219    private readonly IActiveSubscriberProbe? _subscriberProbe;
 220    private readonly AsyncResponseWatchdogState _state;
 221    private readonly AsyncResponseWatchdogOptions _options;
 222    private readonly ILogger<AsyncResponseWatchdog> _logger;
 223
 224    /// <summary>Creates the background recovery watchdog.</summary>
 225    public AsyncResponseWatchdog(
 226        IEnumerable<IRecoveryStateScanner> scanners,
 227        IEnumerable<IActiveSubscriberProbe> subscriberProbes,
 228        AsyncResponseWatchdogState state,
 229        IOptions<AsyncResponseOptions> options,
 230        ILogger<AsyncResponseWatchdog> logger)
 231    {
 232        _scanner = scanners.FirstOrDefault();
 233        _subscriberProbe = subscriberProbes.FirstOrDefault();
 234        _state = state;
 235        _options = options.Value.Watchdog;
 236        _logger = logger;
 237
 238        AsyncResponseDiagnostics.EnsureWatchdogGauges(state);
 239    }
 240
 241    /// <inheritdoc />
 242    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 243    {
 244        if (!_options.Enabled)
 245        {
 246            _logger.LogInformation("Recovery watchdog disabled via options; not scanning.");
 247            return;
 248        }
 249
 250        if (_scanner is null)
 251        {
 252            _logger.LogInformation("Recovery watchdog idle: no IRecoveryStateScanner registered (the configured channel 
 253            return;
 254        }
 255
 256        _logger.LogInformation("Recovery watchdog started. Interval: {Interval}, stale threshold: {StaleAfter}.", _optio
 257
 258        try
 259        {
 260            await Task.Delay(_options.StartupDelay, stoppingToken).ConfigureAwait(false);
 261
 262            while (!stoppingToken.IsCancellationRequested)
 263            {
 264                try
 265                {
 266                    var report = await ScanOnceAsync(stoppingToken).ConfigureAwait(false);
 267                    _state.Publish(new AsyncResponseWatchdogSnapshot(DateTime.UtcNow, _options.Interval, report, Error: 
 268                }
 269                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 270                {
 271                    break;
 272                }
 273                catch (Exception ex)
 274                {
 275                    _logger.LogError(ex, "Recovery watchdog scan failed; next attempt in {Interval}.", _options.Interval
 276                    _state.Publish(new AsyncResponseWatchdogSnapshot(DateTime.UtcNow, _options.Interval, Report: null, E
 277                }
 278
 279                await Task.Delay(_options.Interval, stoppingToken).ConfigureAwait(false);
 280            }
 281        }
 282        catch (OperationCanceledException)
 283        {
 284            // Host shutdown.
 285        }
 286    }
 287
 288    private async Task<AsyncResponseWatchdogReport> ScanOnceAsync(CancellationToken cancellationToken)
 289    {
 290        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.watchdog.scan");
 291
 292        try
 293        {
 294            // Phase 1 — stream the scan and dedupe, keeping the OLDEST registration per
 295            // correlation id (see Evaluate for why oldest). Only the fields the classifier needs
 296            // are buffered, not whole recovery states. Buffering before probing also lets the
 297            // scanner's enumeration (a long-lived reader connection on the relational stores)
 298            // finish before the per-id probe connections open.
 299            Dictionary<string, (DateTime? RegisteredAtUtc, string? PayloadTypeFullName)>? byCorrelationId = null;
 300            List<(string? CorrelationId, DateTime? RegisteredAtUtc, string? PayloadTypeFullName)>? ungrouped = null;
 301            var truncated = false;
 302            int BufferedCount() => (byCorrelationId?.Count ?? 0) + (ungrouped?.Count ?? 0);
 303
 304            await foreach (var entry in _scanner!.ScanAsync(cancellationToken).ConfigureAwait(false))
 305            {
 306                if (entry is null)
 307                    continue;
 308
 309                if (string.IsNullOrEmpty(entry.CorrelationId))
 310                {
 311                    // The cap gates growth only — replacing an already-buffered correlation id
 312                    // with an older sibling costs nothing, so oldest-wins keeps working at the cap.
 313                    if (BufferedCount() >= _options.MaxScanEntries)
 314                    {
 315                        truncated = true;
 316                        break;
 317                    }
 318
 319                    (ungrouped ??= []).Add((entry.CorrelationId, entry.RegisteredAtUtc, entry.PayloadTypeFullName));
 320                    continue;
 321                }
 322
 323                byCorrelationId ??= new Dictionary<string, (DateTime?, string?)>(StringComparer.Ordinal);
 324                if (byCorrelationId.TryGetValue(entry.CorrelationId, out var kept))
 325                {
 326                    if (entry.RegisteredAtUtc is { } candidate && (kept.RegisteredAtUtc is not { } existing || candidate
 327                        byCorrelationId[entry.CorrelationId] = (entry.RegisteredAtUtc, entry.PayloadTypeFullName);
 328                }
 329                else
 330                {
 331                    if (BufferedCount() >= _options.MaxScanEntries)
 332                    {
 333                        truncated = true;
 334                        break;
 335                    }
 336
 337                    byCorrelationId[entry.CorrelationId] = (entry.RegisteredAtUtc, entry.PayloadTypeFullName);
 338                }
 339            }
 340
 341            // Phase 2 — one liveness probe per unique correlation id, then the same pure
 342            // classifier the report type exposes publicly, so this scan and Evaluate (the tested
 343            // and benchmarked surface) can never drift apart again.
 344            var observations = new List<RecoveryStateObservation>((byCorrelationId?.Count ?? 0) + (ungrouped?.Count ?? 0
 345
 346            if (byCorrelationId is not null)
 347            {
 348                foreach (var (correlationId, entry) in byCorrelationId)
 349                {
 350                    observations.Add(new RecoveryStateObservation(
 351                        correlationId,
 352                        entry.RegisteredAtUtc,
 353                        await CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false),
 354                        entry.PayloadTypeFullName));
 355                }
 356            }
 357
 358            if (ungrouped is not null)
 359            {
 360                foreach (var entry in ungrouped)
 361                {
 362                    observations.Add(new RecoveryStateObservation(
 363                        entry.CorrelationId,
 364                        entry.RegisteredAtUtc,
 365                        await CountActiveSubscribersAsync(entry.CorrelationId, cancellationToken).ConfigureAwait(false),
 366                        entry.PayloadTypeFullName));
 367                }
 368            }
 369
 370            var report = AsyncResponseWatchdogReport.Evaluate(observations, DateTime.UtcNow, _options.StaleAfter);
 371
 372            if (truncated)
 373            {
 374                // Carried on the report itself, not just telemetry: the health check and gauges
 375                // read the report, and a silently truncated scan would otherwise attest a
 376                // staleness verdict it never actually computed.
 377                report = report with { Truncated = true };
 378                activity?.SetTag("asyncresponse.watchdog.truncated", true);
 379                _logger.LogWarning(
 380                    "Recovery watchdog scan stopped at the {MaxScanEntries}-entry buffer cap; staleness is reported for 
 381                    _options.MaxScanEntries);
 382            }
 383
 384            activity?.SetTag("asyncresponse.watchdog.total_entries", report.TotalEntries);
 385            activity?.SetTag("asyncresponse.watchdog.active_waiters", report.EntriesWithActiveWaiter);
 386            activity?.SetTag("asyncresponse.watchdog.stale_entries", report.StaleEntries.Count);
 387            activity?.SetTag("asyncresponse.watchdog.unknown_age_entries", report.UnknownAgeEntries);
 388
 389            _logger.LogInformation("Recovery watchdog scan complete. Outstanding registrations: {Total}, with live waite
 390
 391            foreach (var stale in report.StaleEntries)
 392            {
 393                _logger.LogWarning("Stale async-response recovery state — correlationId {CorrelationId}, payload type {P
 394            }
 395
 396            return report;
 397        }
 398        catch (Exception ex)
 399        {
 400            AsyncResponseDiagnostics.SetError(activity, ex);
 401            throw;
 402        }
 403    }
 404
 405    /// <summary>
 406    /// Liveness for one entry. Returns <c>-1</c> (unknown) when there is no probe or no correlation
 407    /// id; the report treats unknown liveness as "not stale" so it never raises a false alarm.
 408    /// </summary>
 409    private async ValueTask<long> CountActiveSubscribersAsync(string? correlationId, CancellationToken cancellationToken
 410    {
 411        if (_subscriberProbe is null || string.IsNullOrWhiteSpace(correlationId))
 412            return -1;
 413
 414        try
 415        {
 416            return await _subscriberProbe.CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(f
 417        }
 418        catch (Exception ex)
 419        {
 420            _logger.LogDebug(ex, "Recovery watchdog failed to probe subscribers for correlationId {CorrelationId}.", cor
 421            return -1;
 422        }
 423    }
 424}