perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A process-level memo slot: one value, a time-to-live, and single-flight production.
|
||||
/// <para>
|
||||
/// Blazor Server runs one circuit per connected browser, so a page that polls on a timer
|
||||
/// runs its query <em>per circuit</em>. Ten operators on the Health dashboard meant ten
|
||||
/// identical KPI round trips every ten seconds. This slot collapses them: the first caller
|
||||
/// in a TTL window starts the flight, every concurrent caller awaits the <em>same</em>
|
||||
/// <see cref="Task{TResult}"/>, and callers arriving after it completes are served the
|
||||
/// memoized value until the TTL expires.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Failures are never cached — a faulted flight is discarded so the next caller retries.
|
||||
/// The caller's own exception handling is therefore unchanged: it still sees the fault
|
||||
/// (all callers of that one flight see it), and the next window starts clean.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The memoized value type.</typeparam>
|
||||
internal sealed class SingleFlightMemo<T>
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeSpan _ttl;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
|
||||
/// <summary>The current flight, completed or not. Null until the first call.</summary>
|
||||
private Task<T>? _current;
|
||||
|
||||
/// <summary>Wall-clock instant after which <see cref="_current"/> is stale. Only meaningful when it completed successfully.</summary>
|
||||
private DateTimeOffset _freshUntil;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a memo slot.
|
||||
/// </summary>
|
||||
/// <param name="ttl">How long a successfully produced value stays fresh.</param>
|
||||
/// <param name="clock">Clock used for freshness, injectable so tests need no real delay.</param>
|
||||
public SingleFlightMemo(TimeSpan ttl, Func<DateTimeOffset>? clock = null)
|
||||
{
|
||||
_ttl = ttl;
|
||||
_clock = clock ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of times the factory has actually been invoked. Diagnostics and tests only.
|
||||
/// </summary>
|
||||
public int FlightCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the memoized value, starting a flight if none is fresh.
|
||||
/// </summary>
|
||||
/// <param name="factory">Produces a fresh value. Invoked outside the slot's lock.</param>
|
||||
/// <param name="forceRefresh">
|
||||
/// When <see langword="true"/>, a completed-but-still-fresh value is discarded and a new
|
||||
/// flight starts. An <em>in-flight</em> round is still joined rather than duplicated, so a
|
||||
/// burst of operator "Refresh" clicks remains one round trip.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">
|
||||
/// Cancels this caller's wait only — never the shared flight, which other callers may still
|
||||
/// be awaiting.
|
||||
/// </param>
|
||||
/// <param name="ttlOverride">
|
||||
/// Freshness window for <em>this</em> flight, overriding the slot's default. Used where the
|
||||
/// acceptable staleness depends on state the caller knows (the Alarm Summary poll needs a
|
||||
/// far shorter window while the live alarm cache is cold than while it is serving deltas).
|
||||
/// </param>
|
||||
/// <returns>The memoized (or freshly produced) value.</returns>
|
||||
public Task<T> GetAsync(
|
||||
Func<Task<T>> factory,
|
||||
bool forceRefresh = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? ttlOverride = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
|
||||
TaskCompletionSource<T>? started = null;
|
||||
Task<T> result;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var current = _current;
|
||||
if (current is { IsCompleted: false })
|
||||
{
|
||||
// A round is already in progress — join it, force or not.
|
||||
result = current;
|
||||
}
|
||||
else if (!forceRefresh
|
||||
&& current is { IsCompletedSuccessfully: true }
|
||||
&& _clock() < _freshUntil)
|
||||
{
|
||||
result = current;
|
||||
}
|
||||
else
|
||||
{
|
||||
// RunContinuationsAsynchronously: a caller's continuation must never run
|
||||
// inline on the thread that completes the flight (that thread is another
|
||||
// circuit's dispatcher).
|
||||
started = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_current = started.Task;
|
||||
FlightCount++;
|
||||
result = started.Task;
|
||||
}
|
||||
}
|
||||
|
||||
// The factory runs OUTSIDE the lock: it does I/O (an Ask, a SQL round trip) and must
|
||||
// never be able to re-enter this slot on the calling thread while the lock is held.
|
||||
if (started is not null)
|
||||
{
|
||||
_ = RunAsync(factory, started, ttlOverride ?? _ttl);
|
||||
}
|
||||
|
||||
return cancellationToken.CanBeCanceled ? result.WaitAsync(cancellationToken) : result;
|
||||
}
|
||||
|
||||
private async Task RunAsync(Func<Task<T>> factory, TaskCompletionSource<T> completion, TimeSpan ttl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await factory().ConfigureAwait(false);
|
||||
lock (_lock)
|
||||
{
|
||||
_freshUntil = _clock() + ttl;
|
||||
}
|
||||
completion.SetResult(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failure is not memoized: leave _freshUntil in the past so the next caller
|
||||
// starts a new flight instead of re-serving the fault for the whole TTL.
|
||||
lock (_lock)
|
||||
{
|
||||
_freshUntil = default;
|
||||
}
|
||||
completion.SetException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user