fix(adminui): snapshot already old at dialog open is stale immediately (#857)

The Controls panel primed its per-node view from the snapshot store with
ReceivedUtc = now, so a node that stopped publishing before the dialog
opened looked live for the first 30 s stale window.

InMemoryDriverStatusSnapshotStore now records each snapshot's LOCAL
receive time (TimeProvider-injectable) and exposes it via
GetAllWithReceiveTime(); the panel primes through the pure seam
DriverStatusByNode.Prime/PrimedReceiveTime, which keeps the store's
receive time (clamped to now; still skew-proof). A store that does not
track one (default interface impl -> null) falls back to the publish
time only when that is already older than the stale window, else now.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 09:50:15 -05:00
parent 03e5145796
commit 2bd3a6c3c8
5 changed files with 198 additions and 15 deletions
@@ -16,14 +16,23 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// </remarks>
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
{
private readonly ConcurrentDictionary<(string DriverInstanceId, string NodeId), DriverHealthChanged> _byInstance = new();
// Each entry carries the LOCAL receive time (lmxopcua#857) so a view primed from the store can age a snapshot
// from when this process actually got it, not from when the view happened to read it.
private readonly ConcurrentDictionary<(string DriverInstanceId, string NodeId), StoredDriverHealth> _byInstance = new();
private readonly ILogger<InMemoryDriverStatusSnapshotStore>? _logger;
private readonly TimeProvider _time;
private readonly FailureLogThrottle _failureLog = new();
/// <summary>Creates the store.</summary>
/// <param name="logger">Optional logger for a subscriber that throws during a raise.</param>
public InMemoryDriverStatusSnapshotStore(ILogger<InMemoryDriverStatusSnapshotStore>? logger = null)
=> _logger = logger;
/// <param name="time">Clock that stamps each snapshot's local receive time; <see cref="TimeProvider.System"/>
/// when null.</param>
public InMemoryDriverStatusSnapshotStore(
ILogger<InMemoryDriverStatusSnapshotStore>? logger = null, TimeProvider? time = null)
{
_logger = logger;
_time = time ?? TimeProvider.System;
}
/// <summary>Raised whenever a driver status snapshot is stored or updated.</summary>
public event Action<DriverHealthChanged>? SnapshotChanged;
@@ -33,7 +42,7 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
/// <inheritdoc />
public void Upsert(DriverHealthChanged snapshot)
{
_byInstance[KeyOf(snapshot)] = snapshot;
_byInstance[KeyOf(snapshot)] = new StoredDriverHealth(snapshot, _time.GetUtcNow().UtcDateTime);
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one
// subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor).
SafeMulticast.Invoke(SnapshotChanged, snapshot,
@@ -44,11 +53,11 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc)
{
var removed = 0;
foreach (var (key, snapshot) in _byInstance)
foreach (var (key, entry) in _byInstance)
{
if (configuredInstanceIds.Contains(key.DriverInstanceId) || snapshot.PublishedUtc >= olderThanUtc) continue;
// Remove only the exact snapshot inspected: a fresh publish that raced in is kept.
if (_byInstance.TryRemove(new KeyValuePair<(string, string), DriverHealthChanged>(key, snapshot))) removed++;
if (configuredInstanceIds.Contains(key.DriverInstanceId) || entry.Snapshot.PublishedUtc >= olderThanUtc) continue;
// Remove only the exact entry inspected: a fresh publish that raced in is kept.
if (_byInstance.TryRemove(new KeyValuePair<(string, string), StoredDriverHealth>(key, entry))) removed++;
}
return removed;
@@ -58,8 +67,9 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
{
DriverHealthChanged? latest = null;
foreach (var (key, s) in _byInstance)
foreach (var (key, entry) in _byInstance)
{
var s = entry.Snapshot;
if (!string.Equals(key.DriverInstanceId, driverInstanceId, StringComparison.Ordinal)) continue;
if (latest is null || s.PublishedUtc > latest.PublishedUtc) latest = s;
}
@@ -69,5 +79,8 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
}
/// <inheritdoc />
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.ToArray();
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.Select(e => e.Snapshot).ToArray();
/// <inheritdoc />
public IReadOnlyCollection<StoredDriverHealth> GetAllWithReceiveTime() => _byInstance.Values.ToArray();
}