fix(adminui): evict unconfigured drivers from status stores; isolate throwing subscribers (R04 L1, L5)

The driver-status and resilience stores had no removal path, so a deleted
driver stayed on /hosts as a frozen row until restart. Both stores gain
RetainOnly, which /hosts calls after each successful config load (never on a
failed one).

The stores and InProcessBroadcaster now raise their events one subscriber at a
time, so a throwing handler cannot starve later subscribers or escape onto the
bridge actor. The alert/script-log bridges publish inside their try, and the
resilience bridge catches a store failure instead of restarting.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:24:04 -04:00
parent 82380d2bee
commit a04196d725
14 changed files with 314 additions and 13 deletions
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
@@ -10,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
{
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
private readonly ILogger<InMemoryDriverStatusSnapshotStore>? _logger;
/// <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;
/// <summary>Raised whenever a driver status snapshot is stored or updated.</summary>
public event Action<DriverHealthChanged>? SnapshotChanged;
@@ -18,8 +25,22 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
public void Upsert(DriverHealthChanged snapshot)
{
_byInstance[snapshot.DriverInstanceId] = snapshot;
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise.
SnapshotChanged?.Invoke(snapshot);
// 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?.LogDebug(ex, "Driver status subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId));
}
/// <inheritdoc />
public int RetainOnly(IReadOnlySet<string> configuredInstanceIds)
{
var removed = 0;
foreach (var id in _byInstance.Keys)
{
if (!configuredInstanceIds.Contains(id) && _byInstance.TryRemove(id, out _)) removed++;
}
return removed;
}
/// <inheritdoc />