2bd3a6c3c8
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
87 lines
4.1 KiB
C#
87 lines
4.1 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
|
|
|
/// <summary>
|
|
/// Thread-safe in-memory implementation of <see cref="IDriverStatusSnapshotStore"/>.
|
|
/// Keyed by (<see cref="DriverHealthChanged.DriverInstanceId"/>, <see cref="DriverHealthChanged.ReportingNodeId"/>);
|
|
/// last write wins per key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Keyed per reporting node since lmxopcua#847: both nodes of a redundant pair run the same driver instance,
|
|
/// so a driver-id-only key made a driver Faulted on central-1 and Healthy on central-2 flip between the two on
|
|
/// every publish. A snapshot from a node that predates <c>ReportingNodeId</c> keys under the empty node.
|
|
/// </remarks>
|
|
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
|
|
{
|
|
// 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>
|
|
/// <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;
|
|
|
|
private static (string, string) KeyOf(DriverHealthChanged s) => (s.DriverInstanceId, s.ReportingNodeId ?? "");
|
|
|
|
/// <inheritdoc />
|
|
public void Upsert(DriverHealthChanged 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,
|
|
ex => _logger?.Log(_failureLog.NextLevel(), ex, "Driver status subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc)
|
|
{
|
|
var removed = 0;
|
|
foreach (var (key, entry) in _byInstance)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
|
|
{
|
|
DriverHealthChanged? latest = null;
|
|
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;
|
|
}
|
|
|
|
snapshot = latest!;
|
|
return latest is not null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.Select(e => e.Snapshot).ToArray();
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<StoredDriverHealth> GetAllWithReceiveTime() => _byInstance.Values.ToArray();
|
|
}
|