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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user