From 99ce5b401e80635e2f6c206a0ae90076ecd59e1b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:43:47 -0400 Subject: [PATCH] fix(r2-10): in-process resilience status store --- ...esilience-observability-plan.md.tasks.json | 2 +- .../Hubs/HubServiceCollectionExtensions.cs | 1 + .../Hubs/IDriverResilienceStatusStore.cs | 36 ++++++++ .../InMemoryDriverResilienceStatusStore.cs | 33 +++++++ .../DriverResilienceStatusStoreTests.cs | 87 +++++++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json index 7c6e7eb1..1053d3e8 100644 --- a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json +++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json @@ -8,7 +8,7 @@ { "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] }, { "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] }, { "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "pending", "blockedBy": ["T4"] }, + { "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] }, { "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "pending", "blockedBy": ["T7"] }, { "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "pending", "blockedBy": ["T7"] }, { "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "pending", "blockedBy": ["T6", "T8", "T9"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs index 6867d222..b77d7048 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class HubServiceCollectionExtensions public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(typeof(IInProcessBroadcaster<>), typeof(InProcessBroadcaster<>)); return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs new file mode 100644 index 00000000..0e503824 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs @@ -0,0 +1,36 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// Singleton last-snapshot-per-(instance, host) cache of driver resilience status +/// (breaker open / consecutive failures / in-flight depth). Populated by +/// DriverResilienceStatusBridge as it forwards driver-resilience-status DPS +/// messages; read in-process by the driver status panel's resilience section via +/// + . Mirrors +/// — the panel reads this singleton directly instead of +/// opening a self-targeted SignalR connection (which a server-side Blazor component cannot reach +/// behind a reverse proxy). +/// +public interface IDriverResilienceStatusStore +{ + /// Stores or replaces the last-known resilience snapshot for a (instance, host) pair. + /// The resilience status snapshot to store. + void Upsert(DriverResilienceStatusChanged snapshot); + + /// Returns every stored host snapshot for one driver instance (empty if none). + /// The driver instance's stable logical ID. + /// The per-host resilience snapshots for that instance. + IReadOnlyList GetForInstance(string driverInstanceId); + + /// Returns a point-in-time snapshot of every tracked (instance, host) pair. + /// A read-only collection of the last-known resilience snapshot for each pair. + IReadOnlyCollection GetAll(); + + /// + /// Raised after every with the just-stored snapshot. Lets the Blazor Server + /// driver status panel receive live updates by reading this singleton directly. Handlers run on + /// the caller's thread (the bridge actor), so subscribers must marshal to their own sync context. + /// + event Action? SnapshotChanged; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs new file mode 100644 index 00000000..0097b644 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// Thread-safe in-memory implementation of . +/// Keyed by (DriverInstanceId, HostName); last write wins. +/// +public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatusStore +{ + private readonly ConcurrentDictionary<(string Instance, string Host), DriverResilienceStatusChanged> _byPair = new(); + + /// + public event Action? SnapshotChanged; + + /// + public void Upsert(DriverResilienceStatusChanged snapshot) + { + _byPair[(snapshot.DriverInstanceId, snapshot.HostName)] = snapshot; + // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise. + SnapshotChanged?.Invoke(snapshot); + } + + /// + public IReadOnlyList GetForInstance(string driverInstanceId) => + _byPair.Values + .Where(s => string.Equals(s.DriverInstanceId, driverInstanceId, StringComparison.Ordinal)) + .ToList(); + + /// + public IReadOnlyCollection GetAll() => _byPair.Values.ToArray(); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs new file mode 100644 index 00000000..4dd5ab91 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs @@ -0,0 +1,87 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Covers the in-process push contract the Blazor Server driver status panel's resilience section +/// relies on: fires on every +/// , keyed per (instance, host) with +/// last-write-wins, and GetForInstance returns only the queried instance's hosts. Mirrors +/// ; the panel reads this store directly (no +/// self-targeted SignalR connection). +/// +public sealed class DriverResilienceStatusStoreTests +{ + private static DriverResilienceStatusChanged Msg( + string instance, string host, bool breakerOpen = false, int failures = 0) => + new(instance, host, breakerOpen, failures, 0, null, + new DateTime(2026, 7, 13, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 7, 13, 0, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void Upsert_raises_SnapshotChanged_with_the_stored_snapshot() + { + var store = new InMemoryDriverResilienceStatusStore(); + var received = new List(); + store.SnapshotChanged += received.Add; + + var msg = Msg("drv-1", "host-a", breakerOpen: true); + store.Upsert(msg); + + received.Count.ShouldBe(1); + received[0].ShouldBeSameAs(msg); + } + + [Fact] + public void Upsert_is_last_write_wins_per_instance_host() + { + var store = new InMemoryDriverResilienceStatusStore(); + store.Upsert(Msg("drv-1", "host-a", failures: 3)); + store.Upsert(Msg("drv-1", "host-a", failures: 0)); + + var hosts = store.GetForInstance("drv-1"); + hosts.Count.ShouldBe(1); + hosts[0].ConsecutiveFailures.ShouldBe(0); + } + + [Fact] + public void GetForInstance_returns_only_the_queried_instances_hosts() + { + var store = new InMemoryDriverResilienceStatusStore(); + store.Upsert(Msg("drv-1", "host-a")); + store.Upsert(Msg("drv-1", "host-b")); + store.Upsert(Msg("drv-2", "host-a")); + + var hosts = store.GetForInstance("drv-1"); + + hosts.Count.ShouldBe(2); + hosts.ShouldAllBe(h => h.DriverInstanceId == "drv-1"); + } + + [Fact] + public void GetForInstance_returns_empty_for_unknown_instance() + { + var store = new InMemoryDriverResilienceStatusStore(); + store.Upsert(Msg("drv-1", "host-a")); + + store.GetForInstance("nope").ShouldBeEmpty(); + } + + [Fact] + public void Unsubscribed_handler_stops_receiving_after_removal() + { + var store = new InMemoryDriverResilienceStatusStore(); + var count = 0; + void Handler(DriverResilienceStatusChanged _) => count++; + + store.SnapshotChanged += Handler; + store.Upsert(Msg("drv-1", "host-a")); + store.SnapshotChanged -= Handler; + store.Upsert(Msg("drv-1", "host-a")); + + count.ShouldBe(1); + } +}