perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders
This commit is contained in:
@@ -291,13 +291,56 @@
|
||||
.OrderBy(a => a.AttributeName)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Attribute composition forest, rebuilt from the live latest-per-name dictionary.</summary>
|
||||
private IReadOnlyList<DebugTreeNode> AttributeForest =>
|
||||
DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter);
|
||||
// ── Tree memoization (arch-review WP2.4) ──────────────────────────────────
|
||||
// Both forests used to be rebuilt from scratch on EVERY render — and a render
|
||||
// fired per streamed event. A busy instance therefore rebuilt two whole
|
||||
// composition trees hundreds of times a second, on the circuit's single
|
||||
// dispatcher thread. The rebuild now sits behind a version stamp bumped
|
||||
// whenever the underlying dictionaries change (a coalesced stream flush,
|
||||
// Connect, Disconnect), so an unrelated re-render (tab switch, toast, status
|
||||
// strip tick) reuses the forest instead of rebuilding it. Reusing the same
|
||||
// instance also lets Blazor skip re-rendering the TreeView subtree entirely.
|
||||
//
|
||||
// The cached key includes the dictionary Count as well as the version: the
|
||||
// trees must never go stale if the dictionaries are populated by some path
|
||||
// that forgets to bump the stamp.
|
||||
private int _dataVersion;
|
||||
|
||||
/// <summary>Alarm composition forest, rebuilt from the live latest-per-name dictionary.</summary>
|
||||
private IReadOnlyList<DebugTreeNode> AlarmForest =>
|
||||
DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter);
|
||||
private IReadOnlyList<DebugTreeNode>? _attrForestCache;
|
||||
private (int Version, int Count, string Filter) _attrForestKey = (-1, -1, string.Empty);
|
||||
|
||||
private IReadOnlyList<DebugTreeNode>? _alarmForestCache;
|
||||
private (int Version, int Count, string Filter) _alarmForestKey = (-1, -1, string.Empty);
|
||||
|
||||
/// <summary>Attribute composition forest, rebuilt only when the underlying data or filter changed.</summary>
|
||||
private IReadOnlyList<DebugTreeNode> AttributeForest
|
||||
{
|
||||
get
|
||||
{
|
||||
var key = (_dataVersion, _attributeValues.Count, _attrFilter);
|
||||
if (_attrForestCache is null || _attrForestKey != key)
|
||||
{
|
||||
_attrForestCache = DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter);
|
||||
_attrForestKey = key;
|
||||
}
|
||||
return _attrForestCache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Alarm composition forest, rebuilt only when the underlying data or filter changed.</summary>
|
||||
private IReadOnlyList<DebugTreeNode> AlarmForest
|
||||
{
|
||||
get
|
||||
{
|
||||
var key = (_dataVersion, _alarmStates.Count, _alarmFilter);
|
||||
if (_alarmForestCache is null || _alarmForestKey != key)
|
||||
{
|
||||
_alarmForestCache = DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter);
|
||||
_alarmForestKey = key;
|
||||
}
|
||||
return _alarmForestCache;
|
||||
}
|
||||
}
|
||||
|
||||
private DebugStreamSession? _session;
|
||||
private ToastNotification _toast = default!;
|
||||
@@ -458,6 +501,9 @@
|
||||
foreach (var al in session.InitialSnapshot.AlarmStates)
|
||||
_alarmStates[al.AlarmName] = al;
|
||||
|
||||
// Snapshot seeding is a data change like any other — bump the stamp so
|
||||
// the memoized forests rebuild from it.
|
||||
_dataVersion++;
|
||||
_snapshot = session.InitialSnapshot;
|
||||
_connected = true;
|
||||
|
||||
@@ -492,6 +538,11 @@
|
||||
_snapshot = null;
|
||||
_attributeValues.Clear();
|
||||
_alarmStates.Clear();
|
||||
// Drop anything the stream buffered for the session we just tore down, so a
|
||||
// late flush can't repopulate a disconnected view.
|
||||
_pendingAttributes.Clear();
|
||||
_pendingAlarms.Clear();
|
||||
_dataVersion++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -508,37 +559,108 @@
|
||||
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
|
||||
}
|
||||
|
||||
// ── Render coalescing (arch-review WP2.4) ─────────────────────────────────
|
||||
// Every streamed event used to marshal onto the dispatcher and call
|
||||
// StateHasChanged individually, so a chatty instance drove one full render
|
||||
// (and two full tree rebuilds) per value change. Events now land in a
|
||||
// thread-safe pending map keyed by name — repeated updates to the same tag
|
||||
// inside one window collapse to the latest — and exactly ONE dispatcher
|
||||
// marshal per window drains them, bumps the version stamp and renders once.
|
||||
private const int RenderCoalesceMs = 250;
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, AttributeValueChanged> _pendingAttributes = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, AlarmStateChanged> _pendingAlarms = new();
|
||||
|
||||
/// <summary>0/1 flag: whether a coalesce window is already armed. Interlocked-guarded.</summary>
|
||||
private int _flushArmed;
|
||||
|
||||
/// <summary>
|
||||
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
|
||||
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
|
||||
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
|
||||
/// render thread (the tree forests + <see cref="FilteredAttributeValues"/> are
|
||||
/// built from them). <c>Dictionary</c> is not thread-safe (CentralUI-021): a
|
||||
/// write racing an enumeration can throw or corrupt the buckets. The mutation
|
||||
/// is therefore marshalled onto the renderer's dispatcher via
|
||||
/// <see cref="SafeInvokeAsync"/> so every access to the dictionaries — read
|
||||
/// and write — happens on the render thread.
|
||||
/// write racing an enumeration can throw or corrupt the buckets. The event is
|
||||
/// therefore parked in a concurrent pending map here and applied on the
|
||||
/// renderer's dispatcher by <see cref="FlushPendingAsync"/>, so every access to
|
||||
/// the render dictionaries — read and write — still happens on one thread.
|
||||
/// </summary>
|
||||
private void HandleStreamEvent(object evt)
|
||||
{
|
||||
// CentralUI-009: the component may have been disposed while this event
|
||||
// was in flight on the Akka/gRPC thread.
|
||||
if (_disposed) return;
|
||||
_ = SafeInvokeAsync(() =>
|
||||
|
||||
switch (evt)
|
||||
{
|
||||
case AttributeValueChanged av:
|
||||
_pendingAttributes[av.AttributeName] = av;
|
||||
break;
|
||||
case AlarmStateChanged al:
|
||||
_pendingAlarms[al.AlarmName] = al;
|
||||
break;
|
||||
default:
|
||||
// Unknown event type — nothing to apply, no render needed.
|
||||
return;
|
||||
}
|
||||
|
||||
// Arm the window if it isn't already. Losers of the CAS have nothing to do:
|
||||
// their event is in the pending map and the armed flush will pick it up.
|
||||
if (Interlocked.CompareExchange(ref _flushArmed, 1, 0) == 0)
|
||||
{
|
||||
_ = FlushPendingAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits out the coalesce window, then applies everything buffered in one
|
||||
/// dispatcher pass and renders once.
|
||||
/// </summary>
|
||||
private async Task FlushPendingAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(RenderCoalesceMs);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Delay never faults in practice; if it ever did, fall through and drain.
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
Volatile.Write(ref _flushArmed, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
await SafeInvokeAsync(() =>
|
||||
{
|
||||
// Disarm BEFORE draining: an event arriving mid-drain then arms a fresh
|
||||
// window rather than being stranded until the next unrelated event.
|
||||
Volatile.Write(ref _flushArmed, 0);
|
||||
if (_disposed) return;
|
||||
switch (evt)
|
||||
|
||||
var applied = false;
|
||||
foreach (var key in _pendingAttributes.Keys)
|
||||
{
|
||||
case AttributeValueChanged av:
|
||||
if (_pendingAttributes.TryRemove(key, out var av))
|
||||
{
|
||||
_attributeValues[av.AttributeName] = av;
|
||||
break;
|
||||
case AlarmStateChanged al:
|
||||
_alarmStates[al.AlarmName] = al;
|
||||
break;
|
||||
default:
|
||||
// Unknown event type — no re-render needed.
|
||||
return;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
foreach (var key in _pendingAlarms.Keys)
|
||||
{
|
||||
if (_pendingAlarms.TryRemove(key, out var al))
|
||||
{
|
||||
_alarmStates[al.AlarmName] = al;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!applied) return;
|
||||
|
||||
_dataVersion++;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user