From c73b7faa117d9f655ca9965d269a4e75af75998a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 8 Jul 2026 16:21:30 -0400 Subject: [PATCH] feat(health): metrics-stale signal + status-transition timestamps; spec now matches heartbeat-liveness code --- .../Component-HealthMonitoring.md | 7 +- .../CentralHealthAggregator.cs | 65 +++++++++++++---- .../HealthMonitoringOptions.cs | 13 ++++ .../HealthMonitoringOptionsValidator.cs | 19 +++++ .../SiteHealthState.cs | 19 +++++ .../CentralHealthAggregatorTests.cs | 69 +++++++++++++++++++ .../HealthMonitoringOptionsValidatorTests.cs | 23 +++++++ 7 files changed, 199 insertions(+), 16 deletions(-) diff --git a/docs/requirements/Component-HealthMonitoring.md b/docs/requirements/Component-HealthMonitoring.md index 26628bc5..6b612d4c 100644 --- a/docs/requirements/Component-HealthMonitoring.md +++ b/docs/requirements/Component-HealthMonitoring.md @@ -42,8 +42,11 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an - Sites send a **health report message** to central at a configurable interval (default: **30 seconds**). - Each report is a **flat snapshot** containing the current values of all monitored metrics, a **monotonic sequence number**, and the **report timestamp** from the site. Central replaces the previous state for that site only if the incoming sequence number is higher than the last received — this prevents stale reports (e.g., delayed in transit or from a pre-failover node) from overwriting newer state. -- **Offline detection**: If central does not receive a report within a configurable timeout window (default: **60 seconds** — 2x the report interval), the site is marked as **offline**. This gives one missed report as grace before marking offline. -- **Online recovery**: When central receives a health report from a site that was marked offline, the site is automatically marked **online**. No manual acknowledgment required — the metrics in the report provide immediate visibility into the site's condition. +- Central tracks two **distinct** signals per site — do not conflate them: + - **Liveness (online/offline)**: driven by the **last signal of any kind** (a full report *or* a transport heartbeat, the latter arriving every ~5s from any reachable site node). If central receives no signal within `OfflineTimeout` (default: **60 seconds**), the site is marked **offline**. Because heartbeats are frequent, this only fires on genuine total loss of contact — not during a single-node failover, when the surviving node keeps heartbeating. The synthetic *central* self-report site has no heartbeat source and uses the longer `CentralOfflineTimeout` (default: **3 minutes**) so a single missed self-report does not flap it offline. + - **Metrics staleness**: an **online** site (heartbeats still arriving) that has produced no full **report** within `MetricsStaleTimeout` (default: **2 minutes**) is flagged **metrics stale**. This surfaces the failure mode where a site's report pipeline (`HealthReportSender`) has died but the node is otherwise reachable — previously such a site showed "online with frozen metrics forever". `MetricsStaleTimeout` must be positive and no shorter than the report interval. +- **Online recovery**: when central receives a health report from a site that was marked offline, the site is automatically marked **online** and the metrics-stale flag cleared (a fresh report proves the pipeline is alive); a heartbeat alone also restores **online** but leaves the metrics-stale flag untouched. No manual acknowledgment required. +- **Status-transition timestamp**: each online↔offline flip stamps `LastStatusChangeAt`, so the UI can answer "since when has this site been offline/online" rather than only "is it offline now". Report/heartbeat traffic that leaves the status unchanged does not move it. ## Error Rate Metrics diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs index 20952c12..3f5a2da1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs @@ -48,7 +48,9 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat LastReportReceivedAt = now, LastHeartbeatAt = now, LastSequenceNumber = report.SequenceNumber, - IsOnline = true + IsOnline = true, + IsMetricsStale = false + // LastStatusChangeAt stays null: first observation is not a flip. }; if (_siteStates.TryAdd(report.SiteId, registered)) @@ -76,7 +78,12 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat LastReportReceivedAt = now, LastHeartbeatAt = now, LastSequenceNumber = report.SequenceNumber, - IsOnline = true + IsOnline = true, + // A fresh report is proof the metrics pipeline is alive again. + 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 }; if (_siteStates.TryUpdate(report.SiteId, updated, existing)) @@ -155,7 +162,14 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat var updated = existing with { LastHeartbeatAt = newHeartbeat, - IsOnline = true + IsOnline = true, + // Stamp the flip when this heartbeat promotes an offline site back + // online. IsMetricsStale is deliberately left untouched — a + // heartbeat says nothing about whether the metrics pipeline + // recovered; only a full report clears staleness. + LastStatusChangeAt = existing.IsOnline + ? existing.LastStatusChangeAt + : _timeProvider.GetUtcNow() }; if (_siteStates.TryUpdate(siteId, updated, existing)) @@ -246,18 +260,41 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat : _options.OfflineTimeout; var elapsed = now - state.LastHeartbeatAt; - if (elapsed <= timeout) - continue; - - // Atomically swap to an offline copy. If the CAS loses to a - // concurrent report/heartbeat the site was just heard from, so - // leaving it online is the correct outcome — no retry needed. - var offline = state with { IsOnline = false }; - if (_siteStates.TryUpdate(kvp.Key, offline, state)) + if (elapsed > timeout) { - _logger.LogWarning( - "Site {SiteId} marked offline — no signal for {Elapsed}s (timeout: {Timeout}s)", - state.SiteId, elapsed.TotalSeconds, timeout.TotalSeconds); + // Atomically swap to an offline copy, stamping the flip. If the + // CAS loses to a concurrent report/heartbeat the site was just + // heard from, so leaving it online is correct — no retry needed. + var offline = state with { IsOnline = false, LastStatusChangeAt = now }; + if (_siteStates.TryUpdate(kvp.Key, offline, state)) + { + _logger.LogWarning( + "Site {SiteId} marked offline — no signal for {Elapsed}s (timeout: {Timeout}s)", + state.SiteId, elapsed.TotalSeconds, timeout.TotalSeconds); + } + + // Whether the CAS won or lost, do not also evaluate staleness on + // this stale snapshot — offline supersedes metrics-stale. + continue; + } + + // Second signal: the site is still live (heartbeats within timeout) + // but its full-report pipeline may have died. Flag it metrics-stale + // when no report has arrived within MetricsStaleTimeout. Distinct from + // offline — a site whose HealthReportSender crashed keeps heartbeating + // and would otherwise show "online with frozen metrics forever". + if (state.LastReportReceivedAt is { } lastReport + && now - lastReport > _options.MetricsStaleTimeout + && !state.IsMetricsStale) + { + var stale = state with { IsMetricsStale = true }; + if (_siteStates.TryUpdate(kvp.Key, stale, state)) + { + _logger.LogWarning( + "Site {SiteId} metrics are stale — online (heartbeats) but no report for {Elapsed}s (timeout: {Timeout}s)", + state.SiteId, (now - lastReport).TotalSeconds, _options.MetricsStaleTimeout.TotalSeconds); + } + // CAS loss ⇒ a fresh report/heartbeat swapped in ⇒ correct to skip. } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs index 51df1432..645da52d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs @@ -19,4 +19,17 @@ public class HealthMonitoringOptions /// the design doc grants real sites. Default: 3x the report interval. /// public TimeSpan CentralOfflineTimeout { get; set; } = TimeSpan.FromMinutes(3); + + /// + /// Duration an online site may go without a full health report + /// (heartbeats notwithstanding) before it is flagged + /// . Distinct from + /// , which is heartbeat-based liveness: a site + /// whose HealthReportSender has died keeps heartbeating and stays "online", + /// but its metrics freeze — this window surfaces that. Must be positive and + /// no shorter than (a window shorter than one + /// report interval would flag every healthy site). Default: 2 minutes + /// (~4 missed 30s reports). + /// + public TimeSpan MetricsStaleTimeout { get; set; } = TimeSpan.FromMinutes(2); } diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs index a119c56d..64df0047 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs @@ -45,5 +45,24 @@ public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " + "no heartbeat source and is fed only by the slower self-report loop, so it needs at " + "least as much offline grace as a real site."); + + builder.RequireThat(options.MetricsStaleTimeout > TimeSpan.Zero, + $"ScadaBridge:HealthMonitoring:MetricsStaleTimeout must be a positive duration " + + $"(was {options.MetricsStaleTimeout}); it is the window an online site may go without " + + "a full report before being flagged metrics-stale."); + + // A stale window shorter than one report interval flags every healthy + // site (reports simply can't arrive faster than they are emitted). Same + // De Morgan guard shape as the Central/Offline pair above: stay silent + // when either field is non-positive so the dedicated positive-duration + // checks own that failure rather than double-firing here. + builder.RequireThat( + !(options.ReportInterval > TimeSpan.Zero + && options.MetricsStaleTimeout > TimeSpan.Zero + && options.MetricsStaleTimeout < options.ReportInterval), + $"ScadaBridge:HealthMonitoring:MetricsStaleTimeout ({options.MetricsStaleTimeout}) " + + $"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."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs index 123ee003..3ef9c184 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs @@ -44,4 +44,23 @@ public sealed record SiteHealthState public long LastSequenceNumber { get; init; } /// Gets a value indicating whether the site is currently considered online. public bool IsOnline { get; init; } + + /// + /// Gets a value indicating whether the site is online (heartbeats still + /// arriving) but has sent no full within + /// . This is a signal + /// distinct from liveness: a site whose HealthReportSender died keeps + /// heartbeating and would otherwise show "online with frozen metrics forever". + /// Cleared whenever a fresh report is processed. Heartbeats never set or clear + /// it — they say nothing about the metrics pipeline. + /// + public bool IsMetricsStale { 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 + /// did this site drop / come back". Not moved by report/heartbeat traffic that + /// leaves the online/offline status unchanged, nor by the metrics-stale flag. + /// + public DateTimeOffset? LastStatusChangeAt { get; init; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs index b1b93e26..c2ca7aeb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthAggregatorTests.cs @@ -40,6 +40,23 @@ public class CentralHealthAggregatorTests _timeProvider); } + /// + /// Builds a fresh aggregator + manual time provider with an explicit + /// so the metrics-staleness tests can + /// pick a window independent of the shared 60s OfflineTimeout. + /// + private static (CentralHealthAggregator Aggregator, TestTimeProvider Time) NewAggregator( + TimeSpan? metricsStaleTimeout = null) + { + var time = new TestTimeProvider(DateTimeOffset.UtcNow); + var opts = new HealthMonitoringOptions { OfflineTimeout = TimeSpan.FromSeconds(60) }; + if (metricsStaleTimeout is { } mst) + opts.MetricsStaleTimeout = mst; + var aggregator = new CentralHealthAggregator( + Options.Create(opts), NullLogger.Instance, time); + return (aggregator, time); + } + private static SiteHealthReport MakeReport(string siteId, long seq) => new( SiteId: siteId, @@ -423,4 +440,56 @@ public class CentralHealthAggregatorTests _aggregator.ProcessReport(MakeReport("site-1", 11)); Assert.Equal(11, _aggregator.GetSiteState("site-1")!.LastSequenceNumber); } + + /// + /// Review 01 [Medium]: a site whose HealthReportSender died keeps + /// heartbeating and previously showed "online with frozen metrics forever". + /// Metrics staleness is a distinct signal from liveness: heartbeats keep the + /// site online, but the absence of a full report for + /// flags it stale. + /// + [Fact] + public void HeartbeatingSiteWithStaleReports_IsFlaggedMetricsStale_ButStaysOnline() + { + var (aggregator, time) = NewAggregator(metricsStaleTimeout: TimeSpan.FromMinutes(2)); + aggregator.ProcessReport(MakeReport("site-a", 1)); + time.Advance(TimeSpan.FromMinutes(3)); + aggregator.MarkHeartbeat("site-a", time.GetUtcNow()); // heartbeats keep flowing + aggregator.CheckForOfflineSites(); + var state = aggregator.GetSiteState("site-a")!; + Assert.True(state.IsOnline); // heartbeat-based liveness unchanged + Assert.True(state.IsMetricsStale); // NEW distinct signal + } + + /// A fresh full report clears the metrics-stale flag. + [Fact] + public void FreshReport_ClearsMetricsStale() + { + var (aggregator, time) = NewAggregator(metricsStaleTimeout: TimeSpan.FromMinutes(2)); + aggregator.ProcessReport(MakeReport("site-a", 1)); + time.Advance(TimeSpan.FromMinutes(3)); + aggregator.CheckForOfflineSites(); + aggregator.ProcessReport(MakeReport("site-a", 2)); + Assert.False(aggregator.GetSiteState("site-a")!.IsMetricsStale); + } + + /// + /// Review 01 underdeveloped #7: "when did the site drop" was unanswerable. + /// Every online↔offline flip stamps . + /// + [Fact] + public void OnlineOfflineTransitions_RecordLastStatusChangeAt() + { + var (aggregator, time) = NewAggregator(); + aggregator.ProcessReport(MakeReport("site-a", 1)); + time.Advance(TimeSpan.FromMinutes(5)); // > OfflineTimeout + aggregator.CheckForOfflineSites(); + var offlineAt = aggregator.GetSiteState("site-a")!.LastStatusChangeAt; + Assert.Equal(time.GetUtcNow(), offlineAt); + // Advance so the recovery flip lands at a demonstrably later instant than + // the offline flip — proves LastStatusChangeAt is re-stamped on each flip. + time.Advance(TimeSpan.FromSeconds(10)); + aggregator.MarkHeartbeat("site-a", time.GetUtcNow()); + Assert.NotEqual(offlineAt, aggregator.GetSiteState("site-a")!.LastStatusChangeAt); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs index 089c9137..c4e7fbb5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs @@ -70,4 +70,27 @@ public class HealthMonitoringOptionsValidatorTests Assert.True(result.Failed); Assert.Contains("CentralOfflineTimeout", result.FailureMessage); } + + [Fact] + public void ZeroMetricsStaleTimeout_IsRejected() + { + var result = Validate(new HealthMonitoringOptions { MetricsStaleTimeout = TimeSpan.Zero }); + + Assert.True(result.Failed); + Assert.Contains("MetricsStaleTimeout", result.FailureMessage); + } + + [Fact] + public void MetricsStaleTimeout_ShorterThanReportInterval_IsRejected() + { + // A stale window shorter than one report interval flags every site. + var result = Validate(new HealthMonitoringOptions + { + ReportInterval = TimeSpan.FromSeconds(30), + MetricsStaleTimeout = TimeSpan.FromSeconds(10) + }); + + Assert.True(result.Failed); + Assert.Contains("MetricsStaleTimeout", result.FailureMessage); + } }