diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs index 581a5ee2..a6a2dc89 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs @@ -47,7 +47,14 @@ public record SiteHealthReport( // hosted service. Point-in-time (not reset on collect) — mirrors the // SiteAuditBacklog pattern. Defaults to 0 so existing producers / tests that // don't wire the poller stay valid. - long SiteEventLogWriteFailures = 0); + long SiteEventLogWriteFailures = 0, + // Age (seconds) of the oldest parked + // store-and-forward message, computed at report time on the site; null when no + // rows are parked. Parked rows persist until operator action, so this aging + // signal surfaces a forgotten site's stale backlog (count alone hides age). + // Additive-only trailing member — defaults to null for producers / tests that + // don't populate it. + double? OldestParkedMessageAgeSeconds = null); /// /// Broadcast wrapper used between central nodes to keep per-node diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs index d5335f08..7941b81b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs @@ -107,6 +107,15 @@ public class HealthReportSender : BackgroundService { var parkedCount = await _sfStorage.GetParkedMessageCountAsync(); _collector.SetParkedMessageCount(parkedCount); + + // Parked-retention aging signal: age of the oldest parked row + // (null when none are parked), so a forgotten site's stale + // backlog is visible by age, not just count. + var oldestParked = await _sfStorage.GetOldestParkedCreatedAtAsync(); + _collector.SetOldestParkedMessageAgeSeconds( + oldestParked.HasValue + ? (DateTimeOffset.UtcNow - oldestParked.Value).TotalSeconds + : null); } catch (Exception ex) { diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs index fe3139a1..afc12ba6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ISiteHealthCollector.cs @@ -111,6 +111,19 @@ public interface ISiteHealthCollector /// The number of parked messages. void SetParkedMessageCount(int count); + /// + /// Sets the age (seconds) of the oldest parked store-and-forward message for the + /// next call; null when no rows are parked. + /// Point-in-time (not reset on collect), refreshed by the parked-count poller in + /// HealthReportSender. Default interface implementation is a no-op so + /// existing test fakes continue to compile without per-fake updates. + /// + /// Age in seconds of the oldest parked row, or null if none are parked. + void SetOldestParkedMessageAgeSeconds(double? ageSeconds) + { + // Default no-op so test fakes do not need to be updated. + } + /// /// Replace the latest cumulative /// site-event-log write-failure count (SQLite error, disk full, diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs index 48578805..7e978331 100644 --- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs +++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs @@ -25,6 +25,10 @@ public class SiteHealthCollector : ISiteHealthCollector private IReadOnlyDictionary _sfBufferDepths = new Dictionary(); private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount; private int _parkedMessageCount; + // Age (seconds) of the oldest parked message, stored as raw double bits so it can + // be read/written atomically via Interlocked (double? is neither atomic nor + // volatile-able). NaN is the "no parked rows" sentinel (ages are always >= 0). + private long _oldestParkedAgeBits = BitConverter.DoubleToInt64Bits(double.NaN); private volatile string _nodeHostname = ""; private volatile IReadOnlyList? _clusterNodes; private volatile bool _isActiveNode; @@ -123,6 +127,20 @@ public class SiteHealthCollector : ISiteHealthCollector Interlocked.Exchange(ref _parkedMessageCount, count); } + /// + public void SetOldestParkedMessageAgeSeconds(double? ageSeconds) + { + Interlocked.Exchange(ref _oldestParkedAgeBits, + BitConverter.DoubleToInt64Bits(ageSeconds ?? double.NaN)); + } + + /// Reads the atomically-stored oldest-parked age, mapping the NaN sentinel back to null. + private double? ReadOldestParkedAgeSeconds() + { + var value = BitConverter.Int64BitsToDouble(Interlocked.Read(ref _oldestParkedAgeBits)); + return double.IsNaN(value) ? null : value; + } + /// public void SetNodeHostname(string hostname) => _nodeHostname = hostname; @@ -214,6 +232,7 @@ public class SiteHealthCollector : ISiteHealthCollector SiteAuditWriteFailures: siteAuditWriteFailures, AuditRedactionFailure: auditRedactionFailures, SiteAuditBacklog: _siteAuditBacklog, - SiteEventLogWriteFailures: Interlocked.Read(ref _siteEventLogWriteFailures)); + SiteEventLogWriteFailures: Interlocked.Read(ref _siteEventLogWriteFailures), + OldestParkedMessageAgeSeconds: ReadOldestParkedAgeSeconds()); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs index 26a7a7d4..542af5b6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs @@ -663,6 +663,26 @@ public class StoreAndForwardStorage return Convert.ToInt32(result); } + /// + /// Gets the created_at of the oldest parked message, or null when + /// no rows are parked. Backs the parked-retention aging signal on the site health + /// report: parked rows persist until an operator acts, so their age — not + /// just their count — is what tells an operator a forgotten site's backlog is + /// growing stale. + /// + /// A task that resolves to the oldest parked row's creation time, or null if none are parked. + public async Task GetOldestParkedCreatedAtAsync() + { + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT MIN(created_at) FROM sf_messages WHERE status = @parked"; + cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked); + var result = await cmd.ExecuteScalarAsync(); + return result is null or DBNull + ? null + : DateTimeOffset.Parse((string)result); + } + /// /// Gets total message count by status. /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index 317d704c..ad6057fb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -790,4 +790,22 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-1")); Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-2")); } + + // ── Task 23: oldest-parked-age health signal ── + + [Fact] + public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull() + { + Assert.Null(await _storage.GetOldestParkedCreatedAtAsync()); + + var old = NewMsg("old-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddDays(-3)); + var recent = NewMsg("new-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddHours(-1)); + var pending = NewMsg("pending", StoreAndForwardMessageStatus.Pending, DateTimeOffset.UtcNow.AddDays(-9)); // must not count + await _storage.EnqueueAsync(old); + await _storage.EnqueueAsync(recent); + await _storage.EnqueueAsync(pending); + + var oldest = await _storage.GetOldestParkedCreatedAtAsync(); + Assert.Equal(old.CreatedAt, oldest!.Value, TimeSpan.FromSeconds(1)); + } }