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
This commit is contained in:
Joseph Doherty
2026-10-22 17:28:23 -04:00
parent a04196d725
commit 348ffaec1c
4 changed files with 100 additions and 10 deletions
@@ -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<bool> 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()
{
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
/// <summary>
/// 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
/// <see cref="Mark"/>, which is cheap and never renders; the page's own periodic tick calls
/// <see cref="TryConsume"/> and does the expensive re-projection + render at most once per tick,
/// however many marks arrived in between.
/// </summary>
public sealed class CoalescingDirtyFlag
{
private int _dirty;
/// <summary>Records that the underlying data changed. Safe from any thread.</summary>
public void Mark() => Volatile.Write(ref _dirty, 1);
/// <summary>Returns whether anything was marked since the last call, and clears the flag.</summary>
/// <returns><see langword="true"/> when at least one <see cref="Mark"/> happened since the last consume.</returns>
public bool TryConsume() => Interlocked.Exchange(ref _dirty, 0) == 1;
}