From 348ffaec1cd6ff97accfe01ab6e069a34b8e5dc0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 17:28:23 -0400 Subject: [PATCH] perf(adminui): /hosts rebuilds driver rows on the tick, not per health publish (R04 P4) OnSnapshotChanged used to re-project every cluster group and re-render the whole page for each driver publish, per open tab. It now only marks a CoalescingDirtyFlag; the 5 s tick rebuilds when the flag is set or the config load changed (or evicted) anything. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Components/Pages/Hosts.razor | 35 ++++++++---- .../Hosts/CoalescingDirtyFlag.cs | 20 +++++++ .../Hosts/HostsRenderCoalescingTests.cs | 53 +++++++++++++++++++ .../Hubs/StoreEvictionAndIsolationTests.cs | 2 +- 4 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/CoalescingDirtyFlag.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsRenderCoalescingTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index d4a1d6a3..67c9be56 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -347,6 +347,9 @@ else // next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard. private int _tickRunning; + // Set by OnSnapshotChanged (bridge actor thread), consumed by TickAsync. + private readonly CoalescingDirtyFlag _driversDirty = new(); + // Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit // goes away aborts its queries instead of running them against a component that's gone. private readonly CancellationTokenSource _cts = new(); @@ -372,8 +375,11 @@ else try { Refresh(); - await LoadConfigAsync(); - RebuildDriverGroups(); + var configChanged = await LoadConfigAsync(); + // Re-project the driver rows only when a health snapshot or the config actually changed + // since the last tick (R04 P4). The snapshot handler just marks the flag, so N drivers + // publishing every 5 s cost one rebuild per tick per tab, not N rebuilds + N full renders. + if (_driversDirty.TryConsume() | configChanged) RebuildDriverGroups(); StateHasChanged(); } catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) @@ -397,6 +403,8 @@ else await Task.Yield(); Refresh(); await LoadConfigAsync(); + // A manual refresh always rebuilds; clear the flag so the next tick does not rebuild again. + _driversDirty.TryConsume(); RebuildDriverGroups(); } catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) @@ -418,8 +426,11 @@ else // Load the per-cluster node + driver-instance config from the ConfigDB. Kept cheap and // swallow-on-failure so a transient DB hiccup dims the enrichment (rows fall back to id/—) // without crashing the page. The live health feed (the snapshot store) is independent of this. - private async Task LoadConfigAsync() + // Returns whether the loaded config differs from the previous load (records compare by value). + private async Task LoadConfigAsync() { + var previousNodes = _nodes; + var previousInstances = _instances; try { await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); @@ -437,8 +448,12 @@ else var configured = _instances .Select(i => i.DriverInstanceId) .ToHashSet(StringComparer.OrdinalIgnoreCase); - DriverStore.RetainOnly(configured); + var evicted = DriverStore.RetainOnly(configured); ResilienceStore.RetainOnly(configured); + + return evicted > 0 + || !_nodes.SequenceEqual(previousNodes) + || !_instances.SequenceEqual(previousInstances); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { @@ -456,6 +471,7 @@ else Logger.LogWarning(ex, "/hosts: failed to load driver-instance config; rows show ids only."); _nodes = new(); _instances = new(); + return true; } } @@ -483,11 +499,12 @@ else return (null, null); } - // Raised by the snapshot store on the bridge actor's thread for every driver instance. Rebuild - // from the cached config (cheap — no DB hit) and marshal onto the render sync context. A - // brand-new driver shows by id until the next config reload (timer/manual Refresh) enriches it. - private void OnSnapshotChanged(DriverHealthChanged _) - => InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); }); + // Raised by the snapshot store on the bridge actor's thread for every driver instance. It only + // marks the rows dirty: the 5 s tick does the rebuild + render (R04 P4). Rendering here cost one + // full re-projection and full-page diff per driver publish per open tab (200 drivers ≈ 40/s). + // A health change therefore shows within one tick; the open Controls dialog's DriverStatusPanel + // subscribes to the store itself and stays live. + private void OnSnapshotChanged(DriverHealthChanged _) => _driversDirty.Mark(); private void Refresh() { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/CoalescingDirtyFlag.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/CoalescingDirtyFlag.cs new file mode 100644 index 00000000..3f5e538b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/CoalescingDirtyFlag.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; + +/// +/// A thread-safe "something changed since you last looked" flag. A high-rate producer (the driver +/// health store raises once per driver per publish, on the bridge actor's thread) calls +/// , which is cheap and never renders; the page's own periodic tick calls +/// and does the expensive re-projection + render at most once per tick, +/// however many marks arrived in between. +/// +public sealed class CoalescingDirtyFlag +{ + private int _dirty; + + /// Records that the underlying data changed. Safe from any thread. + public void Mark() => Volatile.Write(ref _dirty, 1); + + /// Returns whether anything was marked since the last call, and clears the flag. + /// when at least one happened since the last consume. + public bool TryConsume() => Interlocked.Exchange(ref _dirty, 0) == 1; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsRenderCoalescingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsRenderCoalescingTests.cs new file mode 100644 index 00000000..7583d508 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsRenderCoalescingTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts; + +/// +/// R04 P4: a driver health publish only marks /hosts dirty; the page's 5 s tick rebuilds and +/// renders at most once, however many publishes arrived. +/// +[Trait("Category", "Unit")] +public sealed class HostsRenderCoalescingTests +{ + [Fact] + public void Many_marks_coalesce_into_one_consume() + { + var flag = new CoalescingDirtyFlag(); + for (var i = 0; i < 200; i++) flag.Mark(); + + flag.TryConsume().ShouldBeTrue(); + flag.TryConsume().ShouldBeFalse(); + } + + [Fact] + public void A_fresh_flag_is_clean_and_a_mark_after_consume_is_seen() + { + var flag = new CoalescingDirtyFlag(); + flag.TryConsume().ShouldBeFalse(); + + // Positive control: the flag is not stuck clean. + flag.Mark(); + flag.TryConsume().ShouldBeTrue(); + } + + [Fact] + public void Concurrent_marks_are_never_lost() + { + var flag = new CoalescingDirtyFlag(); + Parallel.For(0, 1000, _ => flag.Mark()); + + flag.TryConsume().ShouldBeTrue(); + } + + /// Source pin (no bUnit): the snapshot handler must not render or rebuild. + [Fact] + public void Snapshot_handler_only_marks_and_the_tick_consumes() + { + var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor"); + + src.ShouldContain("private void OnSnapshotChanged(DriverHealthChanged _) => _driversDirty.Mark();"); + src.ShouldContain("if (_driversDirty.TryConsume() | configChanged) RebuildDriverGroups();"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs index 4cc857b3..411eeb20 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs @@ -128,7 +128,7 @@ public sealed class HostsEvictionWiringTests { var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor"); - var load = src.IndexOf("private async Task LoadConfigAsync()", StringComparison.Ordinal); + var load = src.IndexOf("private async Task LoadConfigAsync()", StringComparison.Ordinal); load.ShouldBeGreaterThan(0); var firstCatch = src.IndexOf("catch (Exception ex) when (ex is OperationCanceledException", load, StringComparison.Ordinal); firstCatch.ShouldBeGreaterThan(load);