diff --git a/docs/deployment/topology-guide.md b/docs/deployment/topology-guide.md
index d06e5211..cc279f92 100644
--- a/docs/deployment/topology-guide.md
+++ b/docs/deployment/topology-guide.md
@@ -255,6 +255,32 @@ LocalDbReplicationConnected staying true with the backlog returning to ~0 is the
An unreplicated node logs only the first line. If a backlog spike has no such line near it in the
active node's log, the purge is *not* the explanation and the spike is worth investigating.
+**Two dashboard surfaces make this reviewable rather than a log-archaeology exercise** (register
+row 33):
+
+- **The "Replication backlog" badge** on the site tile of the Health dashboard. It is deliberately
+ a *sustained* signal, not a bare threshold: the badge appears only once the backlog has stayed
+ above `ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold` (default **1000**) continuously for
+ `ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor` (default **15 minutes**). The daily
+ purge burst described above drains in seconds to a couple of minutes, so it never reaches the
+ dwell and never badges the site — which is the entire reason the dwell exists. A bare threshold
+ would fire once a day on every replicated site and be ignored within a week. The dwell is
+ validated to be at least one `ReportInterval`, since a shorter window could be satisfied by a
+ single sample and would collapse back into a bare threshold.
+- **The "LocalDb Replication Backlog" trend chart** in the dashboard's per-site Site Health Trends
+ panel, backed by the KPI history store (sampled every minute, retained 90 days raw / 365 days
+ hourly). This is what answers the question a point-in-time reading cannot: *did that spike
+ drain?* A past purge burst appears as a spike that returns to ~0; a real fault appears as a
+ level that never comes back down.
+
+**Both surfaces are silent on an unreplicated site, by design.** `LocalDbOplogBacklog` and
+`LocalDbReplicationConnected` are nullable, and null means "no reading", never zero. The KPI
+sampler therefore writes **no sample at all** rather than a fabricated 0, so site-b/site-c show an
+empty chart rather than a flat zero line, and they never accumulate the dwell anchor the badge
+keys off. A gap in those series means "not replicated / not reporting" — which is the truth. Note
+that `false` and null differ for the connected flag: a configured link that is DOWN samples as 0,
+while no link at all samples as nothing.
+
### Site Pair Upgrades — stop and start BOTH nodes together
**A rolling upgrade of a site pair, one node at a time, is no longer supported.** It worked while
diff --git a/docs/requirements/Component-HealthMonitoring.md b/docs/requirements/Component-HealthMonitoring.md
index 931007f3..e33f8c40 100644
--- a/docs/requirements/Component-HealthMonitoring.md
+++ b/docs/requirements/Component-HealthMonitoring.md
@@ -34,7 +34,7 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an
| Notification Outbox queue depth | Notification Outbox (central) | Count of `Pending` + `Retrying` notifications — central-computed, not site-reported |
| Notification Outbox stuck count | Notification Outbox (central) | Count of `Pending` / `Retrying` notifications older than the configurable stuck-age threshold — central-computed, not site-reported |
| Notification Outbox parked count | Notification Outbox (central) | Count of `Parked` notifications — central-computed, not site-reported |
-| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. A configurable threshold drives a Health dashboard warning on the affected site tile. |
+| `SiteAuditBacklog` | Audit Log (site) | Count of `Pending` rows in the site-local `AuditLog` plus oldest-pending-age plus on-disk bytes. Surfaced on the Audit KPI tiles (which border-highlight any non-zero backlog) and sampled into KPI history as `auditBacklogPending`. **No configurable per-site threshold badge exists for this metric** — earlier revisions of this document described one, but none was ever implemented and no such option exists. The only configurable sustained-threshold site badge is the LocalDb replication backlog (register row 33, below). |
| `SiteAuditWriteFailures` | Audit Log (site) | Count of failed hot-path audit appends at the site since the last health report. |
| `AuditRedactionFailure` | Audit Log (central) | Count of payload redactor errors (over-redacted payloads, safety-net hit) since the last interval. |
| `LocalDbReplicationConnected` | Consolidated site LocalDb | Whether a peer sync session is currently running on this node. **Nullable — null means "no reading", not "disconnected".** Reported only by a node with replication configured. |
@@ -90,7 +90,7 @@ The Audit Log spans both sites (hot-path append + telemetry forward) and central
- **Audit volume** — events/min landing in the central `AuditLog` table, shown global plus per-site sparkline; sourced from the Audit Log component on the active central node.
- **Audit error rate** — percent of central `AuditLog` rows with `Status` other than `Success` / `Delivered` / `Enqueued` over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, transient failures, parked deliveries, etc.) — NOT the audit writer's own health. Audit-writer issues surface separately via `AuditRedactionFailure`.
-- **Audit backlog** — global aggregate of `SiteAuditBacklog` across reporting sites (count of `Pending` site-local audit rows, oldest pending age, on-disk bytes); click drills into a per-site breakdown. The per-site tile surfaces a warning badge when its `SiteAuditBacklog` crosses the configurable threshold.
+- **Audit backlog** — global aggregate of `SiteAuditBacklog` across reporting sites (count of `Pending` site-local audit rows, oldest pending age, on-disk bytes); click drills into a per-site breakdown. The tile border-highlights a non-zero aggregate backlog; there is **no** configurable per-site `SiteAuditBacklog` threshold badge (see the note in Monitored Metrics — earlier revisions described one that was never built).
These tiles are **point-in-time** like the Notification Outbox and Site Call Audit KPI tiles — no time-series store; consistent with Health Monitoring's "current status only" philosophy. The site-scoped `SiteAuditBacklog` / `SiteAuditWriteFailures` metrics arrive in the existing site health report; the central-scoped `AuditRedactionFailure` metric is central-computed alongside the existing central KPIs.
@@ -101,6 +101,46 @@ These tiles are **point-in-time** like the Notification Outbox and Site Call Aud
- Notification Outbox and Site Call Audit KPIs are not stored by Health Monitoring; they are computed point-in-time from the central `Notifications` and `SiteCalls` tables respectively each time the dashboard refreshes — consistent with the current-status-only philosophy.
- Site connectivity history (online/offline transitions) may optionally be logged via the Audit Log or a separate mechanism if needed in the future.
+## Sustained LocalDb Replication Backlog Badge
+
+The per-site tile shows a **Replication backlog** warning badge when `LocalDbOplogBacklog` has
+stayed above `LocalDbBacklogThreshold` (default **1000**) continuously for
+`LocalDbBacklogSustainedFor` (default **15 minutes**). Both are configurable under
+`ScadaBridge:HealthMonitoring` and validated at startup.
+
+- **Sustained, not instantaneous — and that is the design, not a refinement.** The daily
+ `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 drains as the peer acks it. A bare
+ `backlog > threshold` badge would therefore fire on every replicated site every day. Healthy is
+ defined by the backlog *returning to ~0*, which only a dwell can express. `LocalDbBacklogSustainedFor`
+ is validated to be at least one `ReportInterval`: a shorter dwell could be satisfied by a single
+ sample and would collapse back into the bare threshold.
+- **Implementation.** `CentralHealthAggregator` holds `SiteHealthState.LocalDbBacklogAboveSince`,
+ the instant the backlog first rose above the threshold. Reports at or below the threshold clear
+ it; reports above it leave the original instant intact (a sustained window, not a sliding one).
+- **Null handling is load-bearing.** A report with a `null` backlog leaves the anchor *unchanged*
+ rather than clearing it — null means "no reading" (unreplicated site, or a failed poll), which is
+ evidence neither of a drain nor of a backlog. An unreplicated site (rig site-b/site-c) reports
+ null forever, so it never accumulates an anchor and can never be badged.
+- Offline sites are not badged; the Offline badge is the signal that matters there.
+
+## KPI History Sampling of Replication State
+
+`SiteHealthKpiSampleSource` samples `localDbOplogBacklog` and `localDbReplicationConnected` (0/1)
+into the KPI history store, and the backlog is charted in the Health dashboard's per-site Site
+Health Trends panel as **LocalDb Replication Backlog**. This is the retrospective half of the
+signal: a point-in-time tile can never answer "did that spike drain?", which is the only thing
+distinguishing an expected purge burst from a stalled peer.
+
+Both metrics are **conditionally emitted** — a site reporting `null` writes no sample rather than a
+coerced `0`, unlike the `auditBacklogPending` metric alongside them. A fabricated 0 would render a
+pair that cannot read its own oplog, or one deliberately not replicated, as perfectly healthy once
+a minute forever. Consequently a site's sample count varies: **12 metrics for an unreplicated site,
+14 for a replicated one**, and a gap in these two series truthfully means "not replicated / not
+reporting". Note `false` and `null` are different states for the connected flag — a configured link
+that is down samples as `0`; no link at all samples as nothing. Both fold as **Gauge** in the hourly
+rollup catalog (summing an oplog depth, or a 0/1 flag, per hour would be meaningless).
+
## Prometheus Metrics (site node)
Distinct from the health *report* above: these are scraped from the site node's `/metrics`
@@ -151,4 +191,4 @@ endpoint (`MetricsPort`, default 8084) rather than sent to central.
- **Central UI**: Health Monitoring Dashboard displays aggregated metrics.
- **Communication Layer**: Health reports flow as periodic messages.
-- **KPI History (#26)**: emits `IKpiSampleSource` (`SiteHealthKpiSampleSource`, per-Site) consumed by the KpiHistory recorder (#26). It reads the in-memory `ICentralHealthAggregator.GetAllSiteStates()` (no DB), turning the per-site snapshot — previously sequence-numbered every 30s but discarded — into 12 sampled `KpiSample` metrics per site, persisted every minute: `connectionsUp`/`connectionsDown`, `scriptErrors`, `alarmEvalErrors`, `sfBufferDepth`, `deadLetters`, `parkedMessages`, `deployedInstances`/`enabledInstances`/`disabledInstances`, `auditBacklogPending`, `eventLogWriteFailures`. Of these, **only four are charted** in the dashboard's per-site `KpiTrendChart` panel — `connectionsDown`, `scriptErrors`, `sfBufferDepth`, and `deadLetters` (the metrics carried in the shared `KpiMetrics.SiteHealth` Commons catalog and fetched by `Health.razor`'s trend selector). The remaining eight are **sampled and persisted but not (yet) charted** — they are retained in the `KpiSample` store and available for future trend surfaces or ad-hoc query. The synthetic central self-report (`CentralHealthReportLoop.CentralSiteId = "$central"`, fed by the leader-only report loop via `CollectReport($central)`) appears in `GetAllSiteStates()` and is **intentionally sampled into the per-Site KPI store as a real `KpiScopes.Site` series** keyed `$central`; the Central UI deliberately **pins `$central` first** in the per-site trend selector (rendered as "Central Cluster"), so the central cluster's own trends sit alongside the real sites'. (`$central`'s zero-valued connection/instance/S&F metrics reflect the central self-report carrying no site-runtime data, not a defect.) See [Component-KpiHistory.md](Component-KpiHistory.md).
+- **KPI History (#26)**: emits `IKpiSampleSource` (`SiteHealthKpiSampleSource`, per-Site) consumed by the KpiHistory recorder (#26). It reads the in-memory `ICentralHealthAggregator.GetAllSiteStates()` (no DB), turning the per-site snapshot — previously sequence-numbered every 30s but discarded — into 12–14 sampled `KpiSample` metrics per site, persisted every minute: `connectionsUp`/`connectionsDown`, `scriptErrors`, `alarmEvalErrors`, `sfBufferDepth`, `deadLetters`, `parkedMessages`, `deployedInstances`/`enabledInstances`/`disabledInstances`, `auditBacklogPending`, `eventLogWriteFailures`, plus the two conditionally-emitted LocalDb replication metrics `localDbOplogBacklog` and `localDbReplicationConnected` (register row 33 — emitted only when the site actually reports a reading, hence 12 for an unreplicated site and 14 for a replicated one; see *KPI History Sampling of Replication State* above). Of these, **only five are charted** in the dashboard's per-site `KpiTrendChart` panel — `connectionsDown`, `scriptErrors`, `sfBufferDepth`, `deadLetters`, and `localDbOplogBacklog` (the metrics carried in the shared `KpiMetrics.SiteHealth` Commons catalog and fetched by `Health.razor`'s trend selector). The rest are **sampled and persisted but not (yet) charted** — they are retained in the `KpiSample` store and available for future trend surfaces or ad-hoc query. The synthetic central self-report (`CentralHealthReportLoop.CentralSiteId = "$central"`, fed by the leader-only report loop via `CollectReport($central)`) appears in `GetAllSiteStates()` and is **intentionally sampled into the per-Site KPI store as a real `KpiScopes.Site` series** keyed `$central`; the Central UI deliberately **pins `$central` first** in the per-site trend selector (rendered as "Central Cluster"), so the central cluster's own trends sit alongside the real sites'. (`$central`'s zero-valued connection/instance/S&F metrics reflect the central self-report carrying no site-runtime data, not a defect.) See [Component-KpiHistory.md](Component-KpiHistory.md).
diff --git a/docs/requirements/Component-KpiHistory.md b/docs/requirements/Component-KpiHistory.md
index 11f3b15b..7069b2fc 100644
--- a/docs/requirements/Component-KpiHistory.md
+++ b/docs/requirements/Component-KpiHistory.md
@@ -143,6 +143,17 @@ Reads `ICentralHealthAggregator.GetAllSiteStates()` (in-memory, no DB). Scope: *
- `disabledInstances`
- `auditBacklogPending`
- `eventLogWriteFailures`
+- `localDbOplogBacklog` — **conditionally emitted** (register row 33)
+- `localDbReplicationConnected` — **conditionally emitted** (register row 33), 0/1
+
+The last two are written **only when the site actually reports a reading**. Both underlying health-report
+fields are nullable and null means "no reading" (replication not configured, or a failed poll), never
+zero — so unlike `auditBacklogPending` above, they are not coerced with `?? 0`. A site therefore emits
+**12 metrics when unreplicated and 14 when replicated**, and a gap in these two series truthfully means
+"not replicated / not reporting" rather than a flat zero line that would render an unreplicated pair, or
+one that cannot read its own oplog, as perfectly healthy. Both fold as **Gauge** in
+`KpiMetricAggregationCatalog` (they are instantaneous readings; summing an oplog depth or a 0/1 flag per
+hour would be meaningless). `localDbOplogBacklog` is charted on the Health dashboard's per-site trend panel.
## Query + UI
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor
index bbcd16a6..c2b0f5be 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor
@@ -170,6 +170,17 @@
IsAvailable="@_sfBufferDepthAvailable"
ErrorMessage="@_sfBufferDepthError" />
+ @* 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. *@
+
+
+
}
@@ -248,6 +259,14 @@
Metrics stale
}
+ @if (IsLocalDbBacklogSustained(state))
+ {
+
+ Replication backlog
+
+ }
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
{
offline since @changedAt.ToString("u")
@@ -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? _sfBufferDepthSeries;
private bool _sfBufferDepthAvailable = true;
private string? _sfBufferDepthError;
+ private IReadOnlyList? _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
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs
index ffedf771..2d11a0df 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiMetrics.cs
@@ -120,5 +120,22 @@ public static class KpiMetrics
/// Summed store-and-forward buffer depth across all buffers.
public const string SfBufferDepth = "sfBufferDepth";
+
+ ///
+ /// 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.
+ ///
+ public const string LocalDbOplogBacklog = "localDbOplogBacklog";
+
+ ///
+ /// 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
+ /// false 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.
+ ///
+ public const string LocalDbReplicationConnected = "localDbReplicationConnected";
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs
index 811a6e5f..450054a2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs
@@ -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
}
}
}
+
+ ///
+ /// Advances the sustained-backlog anchor for one incoming report (register row 33).
+ ///
+ ///
+ /// Three cases, and the null one is the subtle one:
+ ///
+ ///
+ /// Backlog null — 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ ///
+ /// The anchor currently held for this site, if any.
+ /// The incoming health report.
+ /// The current time.
+ /// The anchor to store.
+ private DateTimeOffset? NextBacklogAboveSince(
+ DateTimeOffset? current, SiteHealthReport report, DateTimeOffset now)
+ {
+ if (report.LocalDbOplogBacklog is not { } backlog)
+ {
+ return current;
+ }
+
+ return backlog > _options.LocalDbBacklogThreshold ? current ?? now : null;
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs
index 645da52d..ee4fba66 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs
@@ -32,4 +32,32 @@ public class HealthMonitoringOptions
/// (~4 missed 30s reports).
///
public TimeSpan MetricsStaleTimeout { get; set; } = TimeSpan.FromMinutes(2);
+
+ ///
+ /// 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 before the Health dashboard
+ /// badges the site. Must be positive. Default: 1000 entries.
+ ///
+ public long LocalDbBacklogThreshold { get; set; } = 1000;
+
+ ///
+ /// How long the oplog backlog must stay continuously above
+ /// before the Health dashboard badges the site
+ /// (register row 33).
+ ///
+ ///
+ /// 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 docs/deployment/topology-guide.md → *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
+ /// — 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.
+ ///
+ public TimeSpan LocalDbBacklogSustainedFor { get; set; } = TimeSpan.FromMinutes(15);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs
index 64df0047..f0e69950 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs
@@ -64,5 +64,29 @@ public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase= 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.");
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs
index 501cb967..c5b0cef6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs
@@ -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(
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs
index 7767aef2..8c02ee6a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs
@@ -67,6 +67,28 @@ public sealed record SiteHealthState
///
public bool IsMetricsStale { get; init; }
+ ///
+ /// Gets the instant this site's LocalDb replication oplog backlog first rose above
+ /// and stayed there, or
+ /// null if the backlog is not currently above it (register row 33).
+ ///
+ ///
+ /// The anchor for the *sustained* backlog badge: the badge shows only once
+ /// 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.
+ ///
+ /// 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 null — 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.
+ ///
+ ///
+ public DateTimeOffset? LocalDbBacklogAboveSince { get; init; }
+
///
/// Gets the instant of the last online↔offline flip, or null if the
/// site has never changed status since it was first observed. Answers "when
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs
index c7905911..4ee067cf 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs
@@ -346,6 +346,103 @@ public class HealthPageTests : BunitContext
});
}
+ // ------------------------------------------------------------------
+ // Sustained LocalDb replication backlog badge (register row 33)
+ // ------------------------------------------------------------------
+
+ [Fact]
+ public void Renders_ReplicationBacklogBadge_WhenBacklogSustainedPastTheDwell()
+ {
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-a",
+ IsOnline = true,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ // Default dwell is 15 minutes; 30 minutes above threshold is well past it.
+ LocalDbBacklogAboveSince = DateTimeOffset.UtcNow.AddMinutes(-30),
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Contains("data-test=\"localdb-backlog-badge\"", cut.Markup);
+ Assert.Contains("Replication backlog", cut.Markup);
+ });
+ }
+
+ [Fact]
+ public void DoesNotRender_ReplicationBacklogBadge_DuringAPurgeBurst()
+ {
+ // THE false-positive this badge is shaped to avoid. The daily site_events purge is
+ // CDC-captured, so the backlog spikes and then drains. Two minutes above the
+ // threshold is a purge burst, not a fault, and must stay silent — otherwise the
+ // badge fires on every replicated site every day and gets ignored.
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-a",
+ IsOnline = true,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ LocalDbBacklogAboveSince = DateTimeOffset.UtcNow.AddMinutes(-2),
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
+ Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
+ }
+
+ [Fact]
+ public void DoesNotRender_ReplicationBacklogBadge_ForAnUnreplicatedSite()
+ {
+ // Rig site-b/site-c report a null backlog forever, so the aggregator never gives them
+ // an anchor. A deliberately-unreplicated site must never be badged.
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-b",
+ IsOnline = true,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ LocalDbBacklogAboveSince = null,
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() => Assert.Contains("site-b", cut.Markup));
+ Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
+ }
+
+ [Fact]
+ public void DoesNotRender_ReplicationBacklogBadge_ForAnOfflineSite()
+ {
+ // An offline site's last-known anchor is stale by definition; the Offline badge is the
+ // signal that matters, and stacking a backlog warning on top is noise.
+ SeedStates(new SiteHealthState
+ {
+ SiteId = "site-a",
+ IsOnline = false,
+ LastHeartbeatAt = DateTimeOffset.UtcNow.AddMinutes(-30),
+ LocalDbBacklogAboveSince = DateTimeOffset.UtcNow.AddMinutes(-30),
+ });
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
+ Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
+ }
+
+ [Fact]
+ public void Renders_LocalDbBacklogTrendChart()
+ {
+ // The retrospective half of row 33: a point-in-time tile can never answer "did that
+ // spike drain?", which is the only question that distinguishes healthy from faulty.
+ SeedSites("site-a");
+
+ var cut = RenderHealthPage();
+
+ cut.WaitForAssertion(() =>
+ Assert.Contains("LocalDb Replication Backlog", cut.Markup));
+ }
+
[Fact]
public void Renders_OfflineSince_ForOfflineSiteWithStatusChange()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs
index a3cb9b08..d6df9cbf 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiMetricAggregationTests.cs
@@ -47,6 +47,12 @@ public class KpiMetricAggregationTests
(KpiSources.SiteHealth, "disabledInstances"),
(KpiSources.SiteHealth, "auditBacklogPending"),
(KpiSources.SiteHealth, "eventLogWriteFailures"),
+ // Register row 33. Both are instantaneous readings, so both fold as Gauge (the
+ // default) — summing an oplog depth per hour would be meaningless, and summing a
+ // 0/1 link flag doubly so. Both are CONDITIONALLY emitted: a site that reports no
+ // reading writes no sample rather than a fabricated 0.
+ (KpiSources.SiteHealth, "localDbOplogBacklog"),
+ (KpiSources.SiteHealth, "localDbReplicationConnected"),
};
// The (source, metric) pairs that must fold as Rate (sum-per-hour).
@@ -76,6 +82,8 @@ public class KpiMetricAggregationTests
(KpiSources.SiteHealth, "connectionsDown"),
(KpiSources.SiteHealth, "sfBufferDepth"),
(KpiSources.SiteHealth, "deployedInstances"),
+ (KpiSources.SiteHealth, "localDbOplogBacklog"),
+ (KpiSources.SiteHealth, "localDbReplicationConnected"),
};
public static IEnumerable