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

Information
Class: AsyncResponse.AsyncResponseRecoveryHealthCheck
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseRecoveryHealthCheck.cs
Line coverage
100%
Covered lines: 81
Uncovered lines: 0
Coverable lines: 81
Total lines: 202
Line coverage: 100%
Branch coverage
97%
Covered branches: 33
Total branches: 34
Branch coverage: 97%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CheckHealthAsync(...)100%22100%
Evaluate(...)100%1212100%
EvaluateBeforeFirstScan(...)92.85%1414100%
BuildData(...)100%66100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseRecoveryHealthCheck.cs

#LineLine coverage
 1using Microsoft.Extensions.Diagnostics.HealthChecks;
 2using System.Text.Json.Serialization;
 3
 4namespace AsyncResponse;
 5
 6/// <summary>
 7/// Aggregate counts the recovery health check reports under the <c>stats</c> key of its
 8/// <see cref="HealthCheckResult.Data"/>. A named type (with pinned JSON property names) rather
 9/// than an anonymous one so trimmed/Native AOT apps can serialize health reports: register it —
 10/// plus <see cref="AsyncResponseStaleRecoveryEntry"/> — in the app's
 11/// <see cref="JsonSerializerContext"/> if the app writes health data as JSON.
 12/// </summary>
 13public sealed record AsyncResponseRecoveryStats(
 14    [property: JsonPropertyName("outstandingRegistrations")] int OutstandingRegistrations,
 15    [property: JsonPropertyName("withLiveWaiter")] int WithLiveWaiter,
 16    [property: JsonPropertyName("stale")] int Stale,
 17    [property: JsonPropertyName("unknownAge")] int UnknownAge,
 18    [property: JsonPropertyName("unprobeable")] int Unprobeable = 0);
 19
 20/// <summary>
 21/// One stale recovery registration listed under the <c>staleEntries</c> key of the recovery
 22/// health check's <see cref="HealthCheckResult.Data"/> (JSON names pinned; see
 23/// <see cref="AsyncResponseRecoveryStats"/> for the AOT registration note).
 24/// </summary>
 25public sealed record AsyncResponseStaleRecoveryEntry(
 26    [property: JsonPropertyName("correlationId")] string? CorrelationId,
 27    [property: JsonPropertyName("payloadType")] string? PayloadType,
 28    [property: JsonPropertyName("registeredAtUtc")] DateTime? RegisteredAtUtc);
 29
 30/// <summary>
 31/// Surfaces the async-response watchdog findings on the health endpoints (e.g. <c>/readyz</c>).
 32/// <para>
 33/// Reads the snapshot cached by <see cref="AsyncResponseWatchdogState"/> — probes never touch the
 34/// recovery store. The check reports at most <see cref="HealthStatus.Degraded"/>: stale recovery
 35/// state means business flows are likely stuck and need operator attention, but the process itself
 36/// is fully able to serve traffic, so this check should never flip readiness to 503 and pull
 37/// instances out of rotation (map <see cref="HealthStatus.Degraded"/> to HTTP 200 in your
 38/// health-endpoint options, which is the ASP.NET Core default).
 39/// </para>
 40/// <list type="bullet">
 41/// <item><description><b>Healthy</b> — last scan found no stale entries; no scan has run yet but
 42/// the watchdog is inside its first-scan budget (it starts with a delay, and readiness must not
 43/// block on it); or this host's watchdog is deliberately idle (disabled, or the channel has no
 44/// scanner — the multi-host pattern), reported as explicit data rather than an alert.</description></item>
 45/// <item><description><b>Degraded</b> — stale entries exist, the last scan failed, the last scan
 46/// was truncated at the buffer cap (its verdict covers a subset only), waiter liveness could not
 47/// be probed for some entries (their staleness is unknown), or the watchdog stopped publishing
 48/// (snapshot older than twice the scan interval, or no first snapshot after the startup delay
 49/// plus twice the interval).</description></item>
 50/// </list>
 51/// </summary>
 3552public sealed class AsyncResponseRecoveryHealthCheck(AsyncResponseWatchdogState _state, TimeProvider? _timeProvider = nu
 53{
 54    /// <summary>Caps the number of stale entries listed in the health payload.</summary>
 55    private const int MaxReportedStaleEntries = 10;
 56
 57    /// <summary>Runs the CheckHealthAsync operation.</summary>
 58    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = de
 3759        => Task.FromResult(Evaluate(_state.Latest, _state.Activation, (_timeProvider ?? TimeProvider.System).GetUtcNow()
 60
 61    internal static HealthCheckResult Evaluate(
 62        AsyncResponseWatchdogSnapshot? snapshot,
 63        AsyncResponseWatchdogState.WatchdogActivation? activation,
 64        DateTime utcNow)
 65    {
 4166        if (snapshot is null)
 1767            return EvaluateBeforeFirstScan(activation, utcNow);
 68
 2469        var snapshotAge = utcNow - snapshot.ScanCompletedUtc;
 2470        if (snapshotAge > snapshot.ScanInterval * 2)
 71        {
 272            return HealthCheckResult.Degraded(
 273                $"Async-response watchdog stopped reporting: last scan {snapshot.ScanCompletedUtc:u} is older than twice
 274                data: BuildData(snapshot));
 75        }
 76
 2277        if (snapshot.Error is not null)
 78        {
 479            return HealthCheckResult.Degraded(
 480                $"Async-response watchdog scan failed: {snapshot.Error}",
 481                data: BuildData(snapshot));
 82        }
 83
 1884        var report = snapshot.Report!;
 1885        if (report.StaleEntries.Count > 0)
 86        {
 887            return HealthCheckResult.Degraded(
 888                $"{report.StaleEntries.Count} async-response flow(s) look stuck: persisted recovery state with no live w
 889                data: BuildData(snapshot));
 90        }
 91
 1092        if (report.Truncated)
 93        {
 94            // Zero stale entries in a truncated scan is not a verdict — arbitrarily many stale
 95            // entries can sit past the buffer cap. "The scan was incomplete" is a health fact.
 296            return HealthCheckResult.Degraded(
 297                "Async-response watchdog scan was truncated at the MaxScanEntries buffer cap; staleness was assessed for
 298                data: BuildData(snapshot));
 99        }
 100
 8101        if (report.UnprobeableEntries > 0)
 102        {
 103            // Unknown liveness is never flagged stale (no false alarms), so without this a probe
 104            // outage would zero every counter and read as a clean pass the scan never computed.
 4105            return HealthCheckResult.Degraded(
 4106                $"Async-response watchdog could not probe waiter liveness for {report.UnprobeableEntries} of {report.Tot
 4107                data: BuildData(snapshot));
 108        }
 109
 4110        return HealthCheckResult.Healthy(
 4111            "No stale async-response recovery state.",
 4112            BuildData(snapshot));
 113    }
 114
 115    /// <summary>
 116    /// Attestation before any snapshot exists. Three honest answers instead of a blanket Healthy:
 117    /// a deliberately idle watchdog (disabled, or no scanner — the documented multi-host pattern)
 118    /// stays alert-quiet but says so in data; an armed scan loop is Healthy only inside its
 119    /// first-scan budget (startup delay plus two intervals) — past it the loop is as dead as one
 120    /// that stopped publishing, and "no scan yet" must not mask that forever; with no activation
 121    /// marker (watchdog not started, or none registered) there is no deadline to hold it to.
 122    /// </summary>
 123    private static HealthCheckResult EvaluateBeforeFirstScan(
 124        AsyncResponseWatchdogState.WatchdogActivation? activation,
 125        DateTime utcNow)
 126    {
 17127        if (activation is { Scanning: false })
 128        {
 4129            return HealthCheckResult.Healthy(
 4130                $"Async-response watchdog on this host does not scan ({activation.IdleReason}); recovery staleness is at
 4131                new Dictionary<string, object>
 4132                {
 4133                    ["scanning"] = false,
 4134                    ["reason"] = activation.IdleReason ?? "unknown"
 4135                });
 136        }
 137
 13138        if (activation is { Scanning: true, StartedUtc: { } startedUtc })
 139        {
 9140            var firstScanDueByUtc = startedUtc + activation.StartupDelay + 2 * activation.Interval;
 9141            var data = new Dictionary<string, object>
 9142            {
 9143                ["scanned"] = false,
 9144                ["scanning"] = true,
 9145                ["firstScanDueByUtc"] = firstScanDueByUtc
 9146            };
 147
 9148            if (utcNow > firstScanDueByUtc)
 149            {
 4150                return HealthCheckResult.Degraded(
 4151                    $"Async-response watchdog never completed its first scan: it started {startedUtc:u} and published no
 4152                    data: data);
 153            }
 154
 5155            return HealthCheckResult.Healthy("Async-response watchdog has not completed a scan yet.", data);
 156        }
 157
 4158        return HealthCheckResult.Healthy(
 4159            "Async-response watchdog has not completed a scan yet.",
 4160            new Dictionary<string, object> { ["scanned"] = false });
 161    }
 162
 163    private static Dictionary<string, object> BuildData(AsyncResponseWatchdogSnapshot snapshot)
 164    {
 24165        var data = new Dictionary<string, object>
 24166        {
 24167            ["lastScanUtc"] = snapshot.ScanCompletedUtc,
 24168            // Human-readable text plus a lossless numeric — alert math derived from a
 24169            // whole-minutes value reads 0 for any sub-minute interval.
 24170            ["scanInterval"] = snapshot.ScanInterval.ToString(),
 24171            ["scanIntervalSeconds"] = snapshot.ScanInterval.TotalSeconds
 24172        };
 173
 24174        if (snapshot.Report is not { } report)
 4175            return data;
 176
 20177        data["truncated"] = report.Truncated;
 178
 20179        data["stats"] = new AsyncResponseRecoveryStats(
 20180            report.TotalEntries,
 20181            report.EntriesWithActiveWaiter,
 20182            report.StaleEntries.Count,
 20183            report.UnknownAgeEntries,
 20184            report.UnprobeableEntries);
 185
 20186        if (report.StaleEntries.Count > 0)
 187        {
 8188            data["staleEntries"] = report.StaleEntries
 8189                .Take(MaxReportedStaleEntries)
 26190                .Select(e => new AsyncResponseStaleRecoveryEntry(
 26191                    e.CorrelationId,
 26192                    e.PayloadTypeFullName,
 26193                    e.RegisteredAtUtc))
 8194                .ToList();
 195
 8196            if (report.StaleEntries.Count > MaxReportedStaleEntries)
 2197                data["staleEntriesTruncated"] = report.StaleEntries.Count - MaxReportedStaleEntries;
 198        }
 199
 20200        return data;
 201    }
 202}