feat(health): KPI history + sustained-backlog badge for LocalDb replication (row 33)
LocalDbOplogBacklog was reported and Prometheus-exported with nothing consuming it: not sampled into KPI history, so a purge-burst spike could not be reviewed after the fact, and no threshold treatment anywhere. Both halves shipped. KPI SAMPLING. SiteHealthKpiSampleSource now emits localDbOplogBacklog and localDbReplicationConnected (0/1), and the backlog is charted on the Health dashboard's per-site trend panel. Charting is the point: a point-in-time tile can never answer 'did that spike drain?', which is the only thing separating an expected purge burst from a stalled peer. Both are CONDITIONALLY emitted rather than coerced with ?? 0 like the auditBacklogPending metric beside them. Both health-report fields are nullable and null means 'no reading' — replication not configured (rig site-b/site-c) or a failed poll — never zero. A fabricated 0 would render a pair that cannot read its own oplog, or one deliberately unreplicated, as perfectly healthy once a minute forever, burying the real series. So a site emits 12 metrics when unreplicated and 14 when replicated, and a GAP in these two series truthfully means 'not replicated / not reporting'. Note false and null differ for the connected flag: a configured link that is DOWN samples as 0; no link samples as nothing. Both fold as Gauge (summing an oplog depth, or a 0/1 flag, per hour is meaningless). THRESHOLD SEMANTICS — sustained, not instantaneous. This is the design, not a refinement. The site_events retention purge is CDC-captured like any other write, so the backlog jumps by the deleted batch and drains as the peer acks it; a bare 'backlog > threshold' badge would fire on every replicated site every day and be ignored within a week. Healthy is defined by the backlog RETURNING to ~0, which only a dwell can express. CentralHealthAggregator holds LocalDbBacklogAboveSince — the instant the backlog first rose above LocalDbBacklogThreshold (default 1000) — and the badge appears only after LocalDbBacklogSustainedFor (default 15 min). A report at or below the threshold clears the anchor; one above leaves the ORIGINAL instant intact, so the window is sustained rather than sliding. The null case is deliberately a third branch: a null backlog leaves the anchor UNCHANGED rather than clearing it. Null is evidence neither of a drain nor of a backlog, so a node going blind mid-climb must not silently reset its own dwell, and an unreplicated site (null forever) never accumulates an anchor and can never be badged. Validation rejects a non-positive threshold (0 would badge every replicated site permanently, since an idle backlog already sits at it) and a dwell shorter than one ReportInterval (satisfiable by a single sample, which collapses the check back into the bare threshold it replaces). DOC CORRECTION worth flagging: this row's premise was 'mirror the SiteAuditBacklog configurable threshold badge'. That badge does not exist — Component-HealthMonitoring.md described it in two places but no option, no comparison and no markup were ever implemented. The two stale claims are corrected here rather than left to send the next reader looking for it; the LocalDb badge is built fresh on the Metrics-stale badge's pattern, which is a real configurable-threshold badge. Tests: HealthMonitoring 115 (9 new aggregator tests incl. purge-burst-drains, unreplicated-never-anchors, null-mid-climb, exact-threshold, stale-report; 5 new validator tests; 4 new sampler tests incl. the null-semantics and false-vs-null cases); CentralUI 1042 (5 new badge/chart tests incl. the purge-burst negative control); Commons Kpi 93; KpiHistory 54.
This commit is contained in:
@@ -170,6 +170,17 @@
|
||||
IsAvailable="@_sfBufferDepthAvailable"
|
||||
ErrorMessage="@_sfBufferDepthError" />
|
||||
</div>
|
||||
@* Register row 33. The point of charting the backlog is retrospective:
|
||||
the daily purge burst is expected, so what an operator needs is to see
|
||||
whether a past spike DRAINED — which a point-in-time tile can never
|
||||
answer. An unreplicated site emits no samples, so this chart is
|
||||
legitimately empty there rather than a flat, misleading zero line. *@
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<KpiTrendChart Title="LocalDb Replication Backlog"
|
||||
Points="@_localDbBacklogSeries"
|
||||
IsAvailable="@_localDbBacklogAvailable"
|
||||
ErrorMessage="@_localDbBacklogError" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -248,6 +259,14 @@
|
||||
Metrics stale
|
||||
</span>
|
||||
}
|
||||
@if (IsLocalDbBacklogSustained(state))
|
||||
{
|
||||
<span class="badge bg-warning text-dark"
|
||||
data-test="localdb-backlog-badge"
|
||||
title="LocalDb replication oplog backlog has stayed above @HealthOptions.Value.LocalDbBacklogThreshold for over @BacklogSustainedDisplay. A short daily spike at the site_events purge is expected and is NOT this — the peer has stopped draining.">
|
||||
Replication backlog
|
||||
</span>
|
||||
}
|
||||
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
|
||||
{
|
||||
<small class="text-muted">offline since @changedAt.ToString("u")</small>
|
||||
@@ -526,6 +545,27 @@
|
||||
private string StaleTimeoutDisplay =>
|
||||
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
||||
|
||||
// Human-friendly rendering of the configured sustained-backlog dwell for the
|
||||
// "Replication backlog" badge tooltip (e.g. "15 minutes").
|
||||
private string BacklogSustainedDisplay =>
|
||||
FormatDuration(HealthOptions.Value.LocalDbBacklogSustainedFor);
|
||||
|
||||
// Register row 33: the sustained LocalDb replication backlog badge.
|
||||
//
|
||||
// Reads the anchor the aggregator maintains (LocalDbBacklogAboveSince) rather than the
|
||||
// instantaneous backlog on the report, because a bare "backlog > threshold" badge would
|
||||
// fire on EVERY replicated site EVERY day: the site_events retention purge is CDC-captured
|
||||
// like any other write, so the backlog jumps by the deleted batch size and then drains.
|
||||
// Healthy is "the backlog comes back down"; only a dwell can express that.
|
||||
//
|
||||
// A site that reports no backlog at all (unreplicated — rig site-b/site-c — or a failed
|
||||
// poll) never gets an anchor, so it can never be badged. That is the whole null-semantics
|
||||
// requirement: null means "no reading", not zero.
|
||||
private bool IsLocalDbBacklogSustained(SiteHealthState state) =>
|
||||
state.IsOnline
|
||||
&& state.LocalDbBacklogAboveSince is { } since
|
||||
&& DateTimeOffset.UtcNow - since >= HealthOptions.Value.LocalDbBacklogSustainedFor;
|
||||
|
||||
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
|
||||
// Fewer than two means no standby, which disables the manual-failover control. This is
|
||||
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
|
||||
@@ -616,6 +656,9 @@
|
||||
private IReadOnlyList<KpiSeriesPoint>? _sfBufferDepthSeries;
|
||||
private bool _sfBufferDepthAvailable = true;
|
||||
private string? _sfBufferDepthError;
|
||||
private IReadOnlyList<KpiSeriesPoint>? _localDbBacklogSeries;
|
||||
private bool _localDbBacklogAvailable = true;
|
||||
private string? _localDbBacklogError;
|
||||
|
||||
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||
{
|
||||
@@ -739,6 +782,8 @@
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.ScriptErrors, siteId, fromUtc, toUtc);
|
||||
(_sfBufferDepthSeries, _sfBufferDepthAvailable, _sfBufferDepthError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.SfBufferDepth, siteId, fromUtc, toUtc);
|
||||
(_localDbBacklogSeries, _localDbBacklogAvailable, _localDbBacklogError) =
|
||||
await LoadTrendSeriesAsync(KpiMetrics.SiteHealth.LocalDbOplogBacklog, siteId, fromUtc, toUtc);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -120,5 +120,22 @@ public static class KpiMetrics
|
||||
|
||||
/// <summary>Summed store-and-forward buffer depth across all buffers.</summary>
|
||||
public const string SfBufferDepth = "sfBufferDepth";
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth (register row 33). Emitted ONLY by sites
|
||||
/// that actually report a backlog — an unreplicated site reports null, and null means
|
||||
/// "no reading", never zero, so no sample is written at all rather than a fabricated 0.
|
||||
/// A gap in this series therefore means "not replicated / not reporting", which is
|
||||
/// exactly what it should mean.
|
||||
/// </summary>
|
||||
public const string LocalDbOplogBacklog = "localDbOplogBacklog";
|
||||
|
||||
/// <summary>
|
||||
/// LocalDb replication link state as 1/0 (register row 33). Emitted ONLY when the site
|
||||
/// reports a value; null (replication not configured) writes no sample. Note that
|
||||
/// <c>false</c> and null are different states here — false is a configured link that is
|
||||
/// down, null is no link configured — which is why this cannot be coerced to 0.
|
||||
/// </summary>
|
||||
public const string LocalDbReplicationConnected = "localDbReplicationConnected";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
FirstSeenAt = now,
|
||||
LastSequenceNumber = report.SequenceNumber,
|
||||
IsOnline = true,
|
||||
IsMetricsStale = false
|
||||
IsMetricsStale = false,
|
||||
LocalDbBacklogAboveSince = NextBacklogAboveSince(null, report, now)
|
||||
// LastStatusChangeAt stays null: first observation is not a flip.
|
||||
};
|
||||
|
||||
@@ -84,7 +85,9 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
IsMetricsStale = false,
|
||||
// Stamp a flip only when this report brings the site back online;
|
||||
// a report from an already-online site leaves the timestamp be.
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now
|
||||
LastStatusChangeAt = existing.IsOnline ? existing.LastStatusChangeAt : now,
|
||||
LocalDbBacklogAboveSince =
|
||||
NextBacklogAboveSince(existing.LocalDbBacklogAboveSince, report, now)
|
||||
};
|
||||
|
||||
if (_siteStates.TryUpdate(report.SiteId, updated, existing))
|
||||
@@ -320,4 +323,43 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the sustained-backlog anchor for one incoming report (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three cases, and the null one is the subtle one:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Backlog <c>null</c> — no reading. An unreplicated site (rig site-b/site-c) reports this
|
||||
/// forever, and a replicated site reports it when a poll fails. Carry the previous anchor
|
||||
/// through UNCHANGED: null is not zero, so it is neither evidence of a drain (which would
|
||||
/// justify clearing) nor of a backlog (which would justify starting one). An unreplicated
|
||||
/// site therefore never accumulates an anchor and can never be badged.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog above the threshold — start the clock if it is not already running. Keeping the
|
||||
/// ORIGINAL start instant is what makes the window sustained rather than sliding.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Backlog at or below the threshold — clear. This is the daily purge burst's exit path:
|
||||
/// the spike drains within a few reports and the anchor is discarded long before the dwell
|
||||
/// elapses, so the expected burst never badges the site.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="current">The anchor currently held for this site, if any.</param>
|
||||
/// <param name="report">The incoming health report.</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <returns>The anchor to store.</returns>
|
||||
private DateTimeOffset? NextBacklogAboveSince(
|
||||
DateTimeOffset? current, SiteHealthReport report, DateTimeOffset now)
|
||||
{
|
||||
if (report.LocalDbOplogBacklog is not { } backlog)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return backlog > _options.LocalDbBacklogThreshold ? current ?? now : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,32 @@ public class HealthMonitoringOptions
|
||||
/// (~4 missed 30s reports).
|
||||
/// </summary>
|
||||
public TimeSpan MetricsStaleTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Unacked LocalDb replication oplog depth above which a site is considered backlogged
|
||||
/// (register row 33). Crossing this alone shows nothing — the backlog must also stay
|
||||
/// above it for <see cref="LocalDbBacklogSustainedFor"/> before the Health dashboard
|
||||
/// badges the site. Must be positive. Default: <c>1000</c> entries.
|
||||
/// </summary>
|
||||
public long LocalDbBacklogThreshold { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// How long the oplog backlog must stay continuously above
|
||||
/// <see cref="LocalDbBacklogThreshold"/> before the Health dashboard badges the site
|
||||
/// (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This dwell time is the whole point of the signal, not a refinement of it. A bare
|
||||
/// threshold would fire every single day on a healthy replicated site: the `site_events`
|
||||
/// retention purge and the storage-cap trim are CDC-captured like any other write, so the
|
||||
/// backlog jumps by the deleted batch size and then drains as the peer acks it. That spike
|
||||
/// is expected and documented (see <c>docs/deployment/topology-guide.md</c> → *Reading the
|
||||
/// replication backlog*). Healthy means the backlog RETURNS TO ~0; unhealthy means it
|
||||
/// stays up. Only a sustained window can tell those apart, so the default is set well
|
||||
/// clear of a purge drain. Must be positive and no shorter than
|
||||
/// <see cref="ReportInterval"/> — a window shorter than one report interval could be
|
||||
/// satisfied by a single sample, which is exactly the bare threshold this exists to avoid.
|
||||
/// Default: 15 minutes.
|
||||
/// </remarks>
|
||||
public TimeSpan LocalDbBacklogSustainedFor { get; set; } = TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
@@ -64,5 +64,29 @@ public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<Heal
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a stale window shorter than one " +
|
||||
"report interval would flag every healthy site, since reports cannot arrive faster than " +
|
||||
"they are emitted.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogThreshold > 0,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold must be positive " +
|
||||
$"(was {options.LocalDbBacklogThreshold}); a zero/negative threshold would badge every " +
|
||||
"replicated site permanently, since an idle backlog of 0 already sits at or above it.");
|
||||
|
||||
builder.RequireThat(options.LocalDbBacklogSustainedFor > TimeSpan.Zero,
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor must be a positive duration " +
|
||||
$"(was {options.LocalDbBacklogSustainedFor}); it is the dwell time a backlog must stay " +
|
||||
"above the threshold before the site is badged.");
|
||||
|
||||
// A dwell shorter than one report interval can be satisfied by a single sample,
|
||||
// degrading the sustained check back into the bare threshold it exists to replace —
|
||||
// which would fire on every daily purge burst. Same De Morgan guard shape as the
|
||||
// pairs above: silent unless BOTH fields are positive and the dwell is too short,
|
||||
// so the dedicated positive-value checks own that failure.
|
||||
builder.RequireThat(
|
||||
!(options.ReportInterval > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor > TimeSpan.Zero
|
||||
&& options.LocalDbBacklogSustainedFor < options.ReportInterval),
|
||||
$"ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor ({options.LocalDbBacklogSustainedFor}) " +
|
||||
$"must be >= ReportInterval ({options.ReportInterval}): a dwell shorter than one report " +
|
||||
"interval can be satisfied by a single sample, which turns the sustained check back into " +
|
||||
"a bare threshold and re-introduces the daily purge-burst false positive it exists to avoid.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
private const string MetricDisabledInstances = "disabledInstances";
|
||||
private const string MetricAuditBacklogPending = "auditBacklogPending";
|
||||
private const string MetricEventLogWriteFailures = KpiMetrics.SiteHealth.EventLogWriteFailures;
|
||||
private const string MetricLocalDbOplogBacklog = KpiMetrics.SiteHealth.LocalDbOplogBacklog;
|
||||
private const string MetricLocalDbReplicationConnected = KpiMetrics.SiteHealth.LocalDbReplicationConnected;
|
||||
|
||||
private readonly ICentralHealthAggregator _aggregator;
|
||||
|
||||
@@ -136,6 +138,32 @@ public sealed class SiteHealthKpiSampleSource : IKpiSampleSource
|
||||
report.SiteAuditBacklog?.PendingCount ?? 0));
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricEventLogWriteFailures, siteId, report.SiteEventLogWriteFailures));
|
||||
|
||||
// Register row 33: LocalDb replication. Both fields are NULLABLE and null is
|
||||
// load-bearing — it means "no reading" (replication not configured, as on rig
|
||||
// site-b/site-c, or a failed poll), NOT zero. So unlike the audit-backlog line
|
||||
// above, these are CONDITIONALLY emitted rather than coerced with `?? 0`:
|
||||
//
|
||||
// - A fabricated 0 backlog would render a pair that cannot read its own oplog,
|
||||
// or one that simply isn't replicated, as perfectly healthy — and would do it
|
||||
// once a minute, forever, burying the real series for the sites that are.
|
||||
// - For the connected flag, `false` and null are genuinely different states: a
|
||||
// configured link that is DOWN versus no link at all. Collapsing null to 0
|
||||
// would alarm on every unreplicated site permanently.
|
||||
//
|
||||
// The cost is that a site's sample count varies (12 unreplicated, 14 replicated).
|
||||
// That is the intended shape: a GAP in these two series means "not replicated / not
|
||||
// reporting", which is a truthful reading, whereas a flat 0 line is a lie.
|
||||
if (report.LocalDbOplogBacklog is { } backlog)
|
||||
{
|
||||
samples.Add(Sample(capturedAtUtc, MetricLocalDbOplogBacklog, siteId, backlog));
|
||||
}
|
||||
|
||||
if (report.LocalDbReplicationConnected is { } connected)
|
||||
{
|
||||
samples.Add(Sample(
|
||||
capturedAtUtc, MetricLocalDbReplicationConnected, siteId, connected ? 1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
private static KpiSample Sample(
|
||||
|
||||
@@ -67,6 +67,28 @@ public sealed record SiteHealthState
|
||||
/// </summary>
|
||||
public bool IsMetricsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant this site's LocalDb replication oplog backlog first rose above
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogThreshold"/> and stayed there, or
|
||||
/// <c>null</c> if the backlog is not currently above it (register row 33).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The anchor for the *sustained* backlog badge: the badge shows only once
|
||||
/// <see cref="HealthMonitoringOptions.LocalDbBacklogSustainedFor"/> has elapsed since this
|
||||
/// instant. Holding a start time rather than a boolean is what makes the signal survive the
|
||||
/// expected daily purge burst — that spike raises the backlog for a drain's worth of
|
||||
/// reports and then clears this back to null, never reaching the dwell.
|
||||
/// <para>
|
||||
/// Null is set two different ways and they mean different things. A report whose backlog is
|
||||
/// at or below the threshold CLEARS it (the backlog demonstrably drained). A report whose
|
||||
/// backlog is <c>null</c> — an unreplicated site, or a failed poll — LEAVES IT UNCHANGED:
|
||||
/// null means "no reading", never zero, so a node that stops reporting mid-climb must not
|
||||
/// silently reset the clock, and an unreplicated site (which reports null forever) never
|
||||
/// starts one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DateTimeOffset? LocalDbBacklogAboveSince { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instant of the last online↔offline flip, or <c>null</c> if the
|
||||
/// site has never changed status since it was first observed. Answers "when
|
||||
|
||||
Reference in New Issue
Block a user