Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs
T

324 lines
14 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
/// <summary>
/// Central-side aggregator that receives health reports from all sites,
/// tracks latest metrics in memory, and detects offline sites.
/// No persistence — display-only for Central UI consumption.
/// </summary>
public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregator
{
private readonly ConcurrentDictionary<string, SiteHealthState> _siteStates = new();
private readonly HealthMonitoringOptions _options;
private readonly ILogger<CentralHealthAggregator> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Initializes a new instance of <see cref="CentralHealthAggregator"/>.</summary>
/// <param name="options">Health monitoring configuration.</param>
/// <param name="logger">Logger for aggregator diagnostics.</param>
/// <param name="timeProvider">Optional time provider; defaults to <see cref="TimeProvider.System"/>.</param>
public CentralHealthAggregator(
IOptions<HealthMonitoringOptions> options,
ILogger<CentralHealthAggregator> logger,
TimeProvider? timeProvider = null)
{
_options = options.Value;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public void ProcessReport(SiteHealthReport report)
{
var now = _timeProvider.GetUtcNow();
while (true)
{
if (!_siteStates.TryGetValue(report.SiteId, out var existing))
{
var registered = new SiteHealthState
{
SiteId = report.SiteId,
LatestReport = report,
LastReportReceivedAt = now,
LastHeartbeatAt = now,
FirstSeenAt = now,
LastSequenceNumber = report.SequenceNumber,
IsOnline = true,
IsMetricsStale = false
// LastStatusChangeAt stays null: first observation is not a flip.
};
if (_siteStates.TryAdd(report.SiteId, registered))
{
_logger.LogInformation(
"Site {SiteId} registered with sequence #{Seq}", report.SiteId, report.SequenceNumber);
return;
}
// Lost the race — another thread registered first; retry as an update.
continue;
}
if (report.SequenceNumber <= existing.LastSequenceNumber)
{
_logger.LogDebug(
"Rejecting stale report from site {SiteId}: seq {Incoming} <= {Last}",
report.SiteId, report.SequenceNumber, existing.LastSequenceNumber);
return;
}
var updated = existing with
{
LatestReport = report,
LastReportReceivedAt = now,
LastHeartbeatAt = now,
LastSequenceNumber = report.SequenceNumber,
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))
{
if (!existing.IsOnline)
{
_logger.LogInformation(
"Site {SiteId} is back online (seq #{Seq})", report.SiteId, report.SequenceNumber);
}
return;
}
// CAS lost — the entry changed under us; retry with the fresh value.
}
}
/// <inheritdoc />
public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt)
{
while (true)
{
if (!_siteStates.TryGetValue(siteId, out var existing))
{
// Unknown site — register it as online, awaiting its first
// full report. LatestReport and LastReportReceivedAt both stay
// null until ProcessReport runs — "no report yet" is an explicit
// nullable state, not a DateTime.MinValue sentinel the UI must special-case.
var registered = new SiteHealthState
{
SiteId = siteId,
LatestReport = null,
LastReportReceivedAt = null,
LastHeartbeatAt = receivedAt,
FirstSeenAt = receivedAt,
LastSequenceNumber = 0,
IsOnline = true
};
if (_siteStates.TryAdd(siteId, registered))
{
_logger.LogInformation(
"Site {SiteId} registered online via heartbeat (awaiting first report)", siteId);
return;
}
// Lost the race — another thread registered first; retry as an update.
continue;
}
// When an offline→online transition is being
// applied, the heartbeat timestamp must reflect a fresh observation,
// not the prior stored value. If receivedAt is older than the stored
// LastHeartbeatAt (clock skew, an out-of-order heartbeat arriving
// after an earlier one already advanced the field), promoting the
// site back to online while leaving LastHeartbeatAt stale would let
// CheckForOfflineSites flap it straight back to offline on the next
// tick. Anchor the heartbeat to the current time provider instead,
// so an offline-to-online transition is always backed by an
// up-to-date heartbeat.
DateTimeOffset newHeartbeat;
if (!existing.IsOnline)
{
var now = _timeProvider.GetUtcNow();
newHeartbeat = receivedAt > now ? receivedAt : now;
}
else
{
newHeartbeat = receivedAt > existing.LastHeartbeatAt
? receivedAt
: existing.LastHeartbeatAt;
}
// Nothing to change — avoid a needless swap.
if (newHeartbeat == existing.LastHeartbeatAt && existing.IsOnline)
return;
var updated = existing with
{
LastHeartbeatAt = newHeartbeat,
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))
{
if (!existing.IsOnline)
_logger.LogInformation("Site {SiteId} is back online (heartbeat)", siteId);
return;
}
// CAS lost — retry with the fresh value.
}
}
/// <inheritdoc />
public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates()
{
return new Dictionary<string, SiteHealthState>(_siteStates);
}
/// <inheritdoc />
public SiteHealthState? GetSiteState(string siteId)
{
_siteStates.TryGetValue(siteId, out var state);
return state;
}
/// <inheritdoc />
public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds)
{
var known = new HashSet<string>(knownSiteIds, StringComparer.Ordinal);
foreach (var siteId in _siteStates.Keys)
{
if (siteId == CentralHealthReportLoop.CentralSiteId || known.Contains(siteId))
continue;
if (_siteStates.TryRemove(siteId, out _))
_logger.LogInformation(
"Site {SiteId} evicted from health aggregator (no longer configured)", siteId);
}
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Central health aggregator started, offline timeout {Timeout}s (central {CentralTimeout}s)",
_options.OfflineTimeout.TotalSeconds, _options.CentralOfflineTimeout.TotalSeconds);
// Check at half the shorter of the two offline timeouts so detection is
// timely for whichever site class (real or "central") has the tighter
// window — see ComputeCheckInterval.
using var timer = new PeriodicTimer(ComputeCheckInterval(_options));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
CheckForOfflineSites();
}
}
/// <summary>
/// Computes the offline-check timer cadence: half of the <em>shorter</em> of
/// <see cref="HealthMonitoringOptions.OfflineTimeout"/> and
/// <see cref="HealthMonitoringOptions.CentralOfflineTimeout"/>. Deriving it
/// from the shorter timeout guarantees that whichever site class has the
/// tighter window is still polled at least twice within it — so if an
/// operator configures <c>CentralOfflineTimeout</c> smaller than
/// <c>OfflineTimeout</c>, central offline detection is not delayed by up to a
/// full <c>OfflineTimeout / 2</c>.
/// </summary>
/// <param name="options">The health monitoring options to derive the interval from.</param>
/// <returns>Half the shorter of the two configured offline timeouts.</returns>
internal static TimeSpan ComputeCheckInterval(HealthMonitoringOptions options)
{
var shorter = options.OfflineTimeout < options.CentralOfflineTimeout
? options.OfflineTimeout
: options.CentralOfflineTimeout;
return TimeSpan.FromMilliseconds(shorter.TotalMilliseconds / 2);
}
/// <summary>Iterates all tracked sites and marks any that have exceeded their offline timeout as offline.</summary>
internal void CheckForOfflineSites()
{
var now = _timeProvider.GetUtcNow();
foreach (var kvp in _siteStates)
{
var state = kvp.Value;
if (!state.IsOnline) continue;
// Use LastHeartbeatAt — heartbeats arrive every ~5s from any
// healthy site node (cadence owned by Cluster Infrastructure /
// SiteCommunicationActor — CommunicationOptions.TransportHeartbeatInterval),
// so the 60s OfflineTimeout tolerates several missed heartbeats and
// only fires when no node can reach central, not during single-node
// failovers.
//
// The synthetic "central" site has no heartbeat source — its only
// signal is the 30s CentralHealthReportLoop self-report — so it gets
// a longer grace window (CentralOfflineTimeout) to survive a single
// skipped/late self-report.
var timeout = kvp.Key == CentralHealthReportLoop.CentralSiteId
? _options.CentralOfflineTimeout
: _options.OfflineTimeout;
var elapsed = now - state.LastHeartbeatAt;
if (elapsed > timeout)
{
// 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".
// A site that has NEVER reported (heartbeat-only registration,
// LastReportReceivedAt == null) anchors on FirstSeenAt instead —
// otherwise a pipeline dead from first boot is never flagged (N3).
var reportAnchor = state.LastReportReceivedAt ?? state.FirstSeenAt;
if (reportAnchor is { } anchor
&& now - anchor > _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{NeverNote} (timeout: {Timeout}s)",
state.SiteId, (now - anchor).TotalSeconds,
state.LastReportReceivedAt is null ? " (never reported since first contact)" : string.Empty,
_options.MetricsStaleTimeout.TotalSeconds);
}
// CAS loss ⇒ a fresh report/heartbeat swapped in ⇒ correct to skip.
}
}
}
}