perf(adminui): coalesce /script-log and /alerts renders with a ring buffer (R04 P7)

Each received event used to Insert(0) into a List and render the full page.
LiveTailBuffer queues events from the bridge thread into a bounded newest-first
ring; CoalescingRenderScheduler (TimeProvider-driven, drained on dispose)
flushes and renders at most once per 200 ms window. ScriptLog's filtered rows
are recomputed on flush/filter change instead of three times per render.

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 348ffaec1c
commit 8e69118200
5 changed files with 426 additions and 47 deletions
@@ -0,0 +1,185 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.LiveTail;
/// <summary>
/// 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 <see cref="TimeProvider"/>, so nothing
/// depends on wall-clock scheduling.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LiveTailTests
{
[Fact]
public void Buffer_items_are_newest_first_and_only_change_on_flush()
{
var buffer = new LiveTailBuffer<int>(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<int>(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<int>(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);
}
}
/// <summary>A deterministic <see cref="TimeProvider"/>: timers fire only when <see cref="Advance"/> passes their due time.</summary>
private sealed class ManualTimeProvider : TimeProvider
{
private readonly List<ManualTimer> _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;
}
}
}
}