diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index 7cee1860..3ead3ee0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -12,6 +12,7 @@ @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @inject IAdminOperationsClient AdminOps +@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail @implements IAsyncDisposable @@ -29,7 +30,7 @@ Shows the most-recent @Capacity entries since the page opened; reload for a fresh window. -@if (_rows.Count == 0) +@if (_tail.Items.Count == 0) {
No alarms in the current window. The table will populate as soon as a @@ -39,7 +40,7 @@ else {
-
Recent transitions (@_rows.Count)
+
Recent transitions (@_tail.Items.Count)
@@ -58,7 +59,7 @@ else - @foreach (var e in _rows) + @foreach (var e in _tail.Items) { @@ -137,7 +138,9 @@ else @code { private const int Capacity = 200; - private readonly List _rows = new(); + // Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7). + private readonly LiveTailBuffer _tail = new(Capacity); + private CoalescingRenderScheduler? _render; private bool _connected; // Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls. @@ -172,6 +175,8 @@ else // Subscribe to the health signal BEFORE reading IsConnected so a state change between the // read and the subscribe can't be missed (TOCTOU) — mirrors DriverStatusPanel's // SnapshotChanged-then-TryGet ordering. + _render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow, + () => InvokeAsync(FlushTail)); Alarms.ConnectionStateChanged += OnConnectionStateChanged; Alarms.Received += OnAlarm; _connected = Alarms.IsConnected; @@ -275,15 +280,18 @@ else null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan); } - private void OnAlarm(AlarmTransitionEvent evt) => - // Marshal both the mutation and the re-render onto the circuit sync context so this can't - // race ClearAsync (which runs there) over the shared _rows list. - InvokeAsync(() => - { - _rows.Insert(0, evt); - if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - StateHasChanged(); - }); + // Bridge-actor thread: queue only. The first transition of a window arms one render ~200 ms + // later, so an alarm storm costs one render per window per tab instead of one per transition. + private void OnAlarm(AlarmTransitionEvent evt) + { + if (_tail.Add(evt)) _render?.Schedule(); + } + + // Render context: drain the queue into the ring and render once. + private void FlushTail() + { + if (_tail.Flush()) StateHasChanged(); + } // Raised by the broadcaster (on the bridge actor's thread) whenever the upstream DPS // subscription's health flips. Marshal onto the circuit sync context like OnAlarm so the live @@ -293,7 +301,7 @@ else private async Task ClearAsync() { - _rows.Clear(); + _tail.Clear(); await InvokeAsync(StateHasChanged); } @@ -314,5 +322,6 @@ else Alarms.ConnectionStateChanged -= OnConnectionStateChanged; Alarms.Received -= OnAlarm; if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync(); + if (_render is not null) await _render.DisposeAsync(); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor index ff2893a1..65dd7551 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -6,13 +6,14 @@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging @inject IInProcessBroadcaster ScriptLogs -@implements IDisposable +@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail +@implements IAsyncDisposable

Script log

- @@ -21,7 +22,7 @@ + placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" @bind:after="RecomputeVisible" /> @(_connected ? "live" : "disconnected") @@ -29,23 +30,23 @@
-@if (VisibleRows.Count == 0) +@if (_visible.Count == 0) {
- @if (_rows.Count == 0) + @if (_tail.Items.Count == 0) { No script-log entries yet. } else { - No entries match the current filter (@_rows.Count entries available). + No entries match the current filter (@_tail.Items.Count entries available). }
} else {
-
Showing @VisibleRows.Count of @_rows.Count
+
Showing @_visible.Count of @_tail.Items.Count
@e.TimestampUtc.ToString("HH:mm:ss.fff")
@@ -58,7 +59,7 @@ else - @foreach (var e in VisibleRows) + @foreach (var e in _visible) { @@ -82,7 +83,11 @@ else @code { private const int Capacity = 500; - private readonly List _rows = new(); + // Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7). + private readonly LiveTailBuffer _tail = new(Capacity); + private CoalescingRenderScheduler? _render; + // The filtered rows, recomputed on flush / filter change — not on every render. + private IReadOnlyList _visible = Array.Empty(); private bool _connected; private string _levelFilter = ""; private string _scriptFilter = ""; @@ -92,22 +97,19 @@ else ["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5, }; - private List VisibleRows + private void RecomputeVisible() { - get + IEnumerable q = _tail.Items; + if (!string.IsNullOrWhiteSpace(_levelFilter) + && LevelRank.TryGetValue(_levelFilter, out var minRank)) { - IEnumerable q = _rows; - if (!string.IsNullOrWhiteSpace(_levelFilter) - && LevelRank.TryGetValue(_levelFilter, out var minRank)) - { - q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank); - } - if (!string.IsNullOrWhiteSpace(_scriptFilter)) - { - q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase)); - } - return q.ToList(); + q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank); } + if (!string.IsNullOrWhiteSpace(_scriptFilter)) + { + q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase)); + } + _visible = q.ToList(); } protected override void OnInitialized() @@ -116,6 +118,8 @@ else // 'script-logs' DPS topic). Blazor Server can't self-connect a SignalR HubConnection behind // a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead. // Subscribe the state-change event BEFORE reading IsConnected so no transition is missed. + _render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow, + () => InvokeAsync(FlushTail)); ScriptLogs.ConnectionStateChanged += OnConnectionStateChanged; ScriptLogs.Received += OnEntry; _connected = ScriptLogs.IsConnected; @@ -124,19 +128,25 @@ else private void OnConnectionStateChanged(bool connected) => InvokeAsync(() => { _connected = connected; StateHasChanged(); }); - private void OnEntry(ScriptLogEntry entry) => - // Marshal both the mutation and the re-render onto the circuit sync context so this can't - // race ClearAsync (which runs there) over the shared _rows list. - InvokeAsync(() => - { - _rows.Insert(0, entry); - if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - StateHasChanged(); - }); + // Bridge-actor thread: queue only. The first entry of a window arms one render ~200 ms later, so + // a chatty script costs one render per window per tab instead of one per log line. + private void OnEntry(ScriptLogEntry entry) + { + if (_tail.Add(entry)) _render?.Schedule(); + } + + // Render context: drain the queue into the ring and render once. + private void FlushTail() + { + if (!_tail.Flush()) return; + RecomputeVisible(); + StateHasChanged(); + } private async Task ClearAsync() { - _rows.Clear(); + _tail.Clear(); + RecomputeVisible(); await InvokeAsync(StateHasChanged); } @@ -148,9 +158,11 @@ else _ => "chip-idle", }; - public void Dispose() + public async ValueTask DisposeAsync() { ScriptLogs.ConnectionStateChanged -= OnConnectionStateChanged; ScriptLogs.Received -= OnEntry; + // Drains an in-flight window and suppresses later ones, so no flush renders a gone component. + if (_render is not null) await _render.DisposeAsync(); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/CoalescingRenderScheduler.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/CoalescingRenderScheduler.cs new file mode 100644 index 00000000..11ba73be --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/CoalescingRenderScheduler.cs @@ -0,0 +1,79 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail; + +/// +/// Coalesces bursts of "please re-render" requests into at most one flush per window. The first +/// after a flush arms a one-shot timer; further calls inside the window are +/// no-ops; when the timer fires the flush callback runs once (the page marshals it onto its render +/// context with InvokeAsync). Disposal drains an in-flight callback and suppresses later ones. +/// +public sealed class CoalescingRenderScheduler : IAsyncDisposable +{ + /// The default coalescing window for live-tail pages. + public static readonly TimeSpan DefaultWindow = TimeSpan.FromMilliseconds(200); + + private readonly TimeSpan _window; + private readonly Func _flush; + private readonly ITimer _timer; + private int _armed; + private volatile bool _disposed; + + /// Creates the scheduler. + /// The coalescing window. + /// The callback run once per window; exceptions are swallowed (the page is the + /// only consumer, and a throw here would surface as an unobserved timer-thread exception). + /// The time source; when null. + public CoalescingRenderScheduler(TimeSpan window, Func flush, TimeProvider? timeProvider = null) + { + _window = window; + _flush = flush; + _timer = (timeProvider ?? TimeProvider.System) + .CreateTimer(OnFire, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + /// Requests a flush within one window. Safe from any thread. + public void Schedule() + { + if (_disposed) return; + if (Interlocked.CompareExchange(ref _armed, 1, 0) != 0) return; + try + { + _timer.Change(_window, Timeout.InfiniteTimeSpan); + } + catch (ObjectDisposedException) + { + // Raced DisposeAsync — nothing left to render to. + } + } + + private void OnFire(object? state) + { + // Disarm BEFORE flushing: an item arriving during the flush schedules the next window rather + // than being stranded until some later item happens to arrive. + Volatile.Write(ref _armed, 0); + if (_disposed) return; + + // Not async void: a fault is observed on the returned task, never thrown onto the timer thread. + // The circuit going away mid-flush (ObjectDisposedException and friends) is the expected fault, + // and a live-tail render failure is never worth an unhandled exception. + Task task; + try + { + task = _flush(); + } + catch + { + return; + } + + _ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + /// + public async ValueTask DisposeAsync() + { + _disposed = true; + await _timer.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/LiveTailBuffer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/LiveTailBuffer.cs new file mode 100644 index 00000000..961dd305 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/LiveTail/LiveTailBuffer.cs @@ -0,0 +1,94 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail; + +/// +/// A bounded, newest-first buffer for a live event tail (the /script-log and /alerts +/// pages). Producers call from the bridge actor's thread; it only queues the item. +/// The page drains the queue into a fixed-capacity ring on its render context (), +/// once per coalescing window, so a chatty feed costs one render per window instead of one per event, +/// and no List.Insert(0, …) memmove per event. +/// +/// The event type. +public sealed class LiveTailBuffer +{ + private readonly object _gate = new(); + private readonly T[] _ring; + private readonly List _pending = new(); + private int _head; // index the next item is written to + private int _count; + private IReadOnlyList _snapshot = Array.Empty(); + + /// Creates a buffer that keeps the most recent items. + /// Maximum retained items; must be positive. + public LiveTailBuffer(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _ring = new T[capacity]; + } + + /// The maximum number of retained items. + public int Capacity => _ring.Length; + + /// Retained items, newest first, as of the last or . + public IReadOnlyList Items => Volatile.Read(ref _snapshot); + + /// Queues an item. Safe from any thread; does not change until the next flush. + /// The item to queue. + /// when the queue was empty before this call, i.e. the caller should + /// schedule a flush. + public bool Add(T item) + { + lock (_gate) + { + // A burst larger than the ring would be evicted at flush anyway; dropping the oldest pending + // now keeps the queue bounded when no flush runs (e.g. a stalled circuit). + if (_pending.Count >= _ring.Length) _pending.RemoveAt(0); + _pending.Add(item); + return _pending.Count == 1; + } + } + + /// Moves the queued items into the ring (evicting the oldest past capacity) and refreshes . + /// when anything was queued, i.e. a render is worthwhile. + public bool Flush() + { + lock (_gate) + { + if (_pending.Count == 0) return false; + foreach (var item in _pending) + { + _ring[_head] = item; + _head = (_head + 1) % _ring.Length; + if (_count < _ring.Length) _count++; + } + + _pending.Clear(); + Volatile.Write(ref _snapshot, BuildNewestFirst()); + return true; + } + } + + /// Drops every retained and queued item. + public void Clear() + { + lock (_gate) + { + _pending.Clear(); + Array.Clear(_ring); + _head = 0; + _count = 0; + Volatile.Write(ref _snapshot, Array.Empty()); + } + } + + private T[] BuildNewestFirst() + { + var result = new T[_count]; + for (var i = 0; i < _count; i++) + { + var idx = (_head - 1 - i + _ring.Length) % _ring.Length; + result[i] = _ring[idx]; + } + + return result; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/LiveTail/LiveTailTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/LiveTail/LiveTailTests.cs new file mode 100644 index 00000000..8b075444 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/LiveTail/LiveTailTests.cs @@ -0,0 +1,185 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.LiveTail; + +/// +/// R04 P7: the live-tail pages coalesce event bursts into one render per window and keep their rows +/// in a bounded ring. All timing here is driven by a manual , so nothing +/// depends on wall-clock scheduling. +/// +[Trait("Category", "Unit")] +public sealed class LiveTailTests +{ + [Fact] + public void Buffer_items_are_newest_first_and_only_change_on_flush() + { + var buffer = new LiveTailBuffer(capacity: 3); + + buffer.Add(1).ShouldBeTrue(); // first pending ⇒ caller schedules + buffer.Add(2).ShouldBeFalse(); // already pending ⇒ no second schedule + buffer.Items.ShouldBeEmpty(); // not visible until flush + + buffer.Flush().ShouldBeTrue(); + buffer.Items.ShouldBe([2, 1]); + buffer.Flush().ShouldBeFalse(); // nothing queued ⇒ no render worth doing + } + + [Fact] + public void Buffer_evicts_the_oldest_past_capacity() + { + var buffer = new LiveTailBuffer(capacity: 3); + for (var i = 1; i <= 5; i++) buffer.Add(i); + buffer.Flush(); + buffer.Items.ShouldBe([5, 4, 3]); + + buffer.Add(6); + buffer.Flush(); + buffer.Items.ShouldBe([6, 5, 4]); + } + + [Fact] + public void Buffer_Clear_drops_retained_and_pending_items() + { + var buffer = new LiveTailBuffer(capacity: 3); + buffer.Add(1); + buffer.Flush(); + buffer.Add(2); + + buffer.Clear(); + + buffer.Items.ShouldBeEmpty(); + buffer.Flush().ShouldBeFalse(); + // Positive control: the buffer still works after a clear, and the next add re-arms scheduling. + buffer.Add(3).ShouldBeTrue(); + buffer.Flush(); + buffer.Items.ShouldBe([3]); + } + + [Fact] + public async Task Scheduler_runs_one_flush_per_window_for_a_burst() + { + var time = new ManualTimeProvider(); + var flushes = 0; + await using var scheduler = new CoalescingRenderScheduler( + TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time); + + for (var i = 0; i < 100; i++) scheduler.Schedule(); + flushes.ShouldBe(0); + + time.Advance(TimeSpan.FromMilliseconds(199)); + flushes.ShouldBe(0); + time.Advance(TimeSpan.FromMilliseconds(1)); + flushes.ShouldBe(1); + + // The window re-arms after a fire: a later burst gets exactly one more flush. + scheduler.Schedule(); + scheduler.Schedule(); + time.Advance(TimeSpan.FromMilliseconds(200)); + flushes.ShouldBe(2); + } + + [Fact] + public async Task Scheduler_does_not_flush_after_dispose() + { + var time = new ManualTimeProvider(); + var flushes = 0; + var scheduler = new CoalescingRenderScheduler( + TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time); + + // Positive control: the scheduler does flush before dispose. + scheduler.Schedule(); + time.Advance(TimeSpan.FromMilliseconds(200)); + flushes.ShouldBe(1); + + scheduler.Schedule(); + await scheduler.DisposeAsync(); + time.Advance(TimeSpan.FromMilliseconds(500)); + flushes.ShouldBe(1); + Should.NotThrow(scheduler.Schedule); + } + + [Fact] + public async Task Scheduler_swallows_a_faulting_flush_and_keeps_working() + { + var time = new ManualTimeProvider(); + var calls = 0; + await using var scheduler = new CoalescingRenderScheduler(TimeSpan.FromMilliseconds(200), () => + { + calls++; + if (calls == 1) throw new ObjectDisposedException("renderer"); + return Task.FromException(new InvalidOperationException("late")); + }, time); + + scheduler.Schedule(); + Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200))); + scheduler.Schedule(); + Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200))); + + calls.ShouldBe(2); + } + + [Fact] + public void Pages_use_the_coalescing_tail_instead_of_a_render_per_event() + { + foreach (var page in new[] { "ScriptLog.razor", "Alerts.razor" }) + { + var src = AdminUiSource.Read("Components", "Pages", page); + src.ShouldContain("CoalescingRenderScheduler", customMessage: page); + src.ShouldContain("LiveTailBuffer<", customMessage: page); + src.ShouldNotContain("_rows.Insert(0,", customMessage: page); + } + } + + /// A deterministic : timers fire only when passes their due time. + private sealed class ManualTimeProvider : TimeProvider + { + private readonly List _timers = new(); + private DateTimeOffset _now = new(2026, 10, 22, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state); + timer.Change(dueTime, period); + _timers.Add(timer); + return timer; + } + + public void Advance(TimeSpan by) + { + _now += by; + foreach (var t in _timers.ToArray()) t.FireIfDue(_now); + } + + private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer + { + private DateTimeOffset? _due; + private bool _disposed; + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + if (_disposed) throw new ObjectDisposedException(nameof(ManualTimer)); + _due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime; + return true; + } + + public void FireIfDue(DateTimeOffset now) + { + if (_disposed || _due is null || _due > now) return; + _due = null; + callback(state); + } + + public void Dispose() => _disposed = true; + + public ValueTask DisposeAsync() + { + _disposed = true; + return ValueTask.CompletedTask; + } + } + } +}
@e.TimestampUtc.ToString("HH:mm:ss.fff")