feat(health): oldest-parked-message age on the site health report (parked-retention aging signal)

This commit is contained in:
Joseph Doherty
2026-07-08 21:49:29 -04:00
parent 0321ad0c20
commit 11c72ecf0f
6 changed files with 88 additions and 2 deletions
@@ -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)
{
@@ -111,6 +111,19 @@ public interface ISiteHealthCollector
/// <param name="count">The number of parked messages.</param>
void SetParkedMessageCount(int count);
/// <summary>
/// Sets the age (seconds) of the oldest parked store-and-forward message for the
/// next <see cref="CollectReport"/> call; <c>null</c> when no rows are parked.
/// Point-in-time (not reset on collect), refreshed by the parked-count poller in
/// <c>HealthReportSender</c>. Default interface implementation is a no-op so
/// existing test fakes continue to compile without per-fake updates.
/// </summary>
/// <param name="ageSeconds">Age in seconds of the oldest parked row, or <c>null</c> if none are parked.</param>
void SetOldestParkedMessageAgeSeconds(double? ageSeconds)
{
// Default no-op so test fakes do not need to be updated.
}
/// <summary>
/// Replace the latest cumulative
/// site-event-log write-failure count (SQLite error, disk full,
@@ -25,6 +25,10 @@ public class SiteHealthCollector : ISiteHealthCollector
private IReadOnlyDictionary<string, int> _sfBufferDepths = new Dictionary<string, int>();
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<Commons.Messages.Health.NodeStatus>? _clusterNodes;
private volatile bool _isActiveNode;
@@ -123,6 +127,20 @@ public class SiteHealthCollector : ISiteHealthCollector
Interlocked.Exchange(ref _parkedMessageCount, count);
}
/// <inheritdoc />
public void SetOldestParkedMessageAgeSeconds(double? ageSeconds)
{
Interlocked.Exchange(ref _oldestParkedAgeBits,
BitConverter.DoubleToInt64Bits(ageSeconds ?? double.NaN));
}
/// <summary>Reads the atomically-stored oldest-parked age, mapping the NaN sentinel back to null.</summary>
private double? ReadOldestParkedAgeSeconds()
{
var value = BitConverter.Int64BitsToDouble(Interlocked.Read(ref _oldestParkedAgeBits));
return double.IsNaN(value) ? null : value;
}
/// <inheritdoc />
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());
}
}