namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
///
/// A process-level memo slot: one value, a time-to-live, and single-flight production.
///
/// Blazor Server runs one circuit per connected browser, so a page that polls on a timer
/// runs its query per circuit. 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 same
/// , and callers arriving after it completes are served the
/// memoized value until the TTL expires.
///
///
/// 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.
///
///
/// The memoized value type.
internal sealed class SingleFlightMemo
{
private readonly object _lock = new();
private readonly TimeSpan _ttl;
private readonly Func _clock;
/// The current flight, completed or not. Null until the first call.
private Task? _current;
/// Wall-clock instant after which is stale. Only meaningful when it completed successfully.
private DateTimeOffset _freshUntil;
///
/// Initializes a memo slot.
///
/// How long a successfully produced value stays fresh.
/// Clock used for freshness, injectable so tests need no real delay.
public SingleFlightMemo(TimeSpan ttl, Func? clock = null)
{
_ttl = ttl;
_clock = clock ?? (() => DateTimeOffset.UtcNow);
}
///
/// Number of times the factory has actually been invoked. Diagnostics and tests only.
///
public int FlightCount { get; private set; }
///
/// Returns the memoized value, starting a flight if none is fresh.
///
/// Produces a fresh value. Invoked outside the slot's lock.
///
/// When , a completed-but-still-fresh value is discarded and a new
/// flight starts. An in-flight round is still joined rather than duplicated, so a
/// burst of operator "Refresh" clicks remains one round trip.
///
///
/// Cancels this caller's wait only — never the shared flight, which other callers may still
/// be awaiting.
///
///
/// Freshness window for this 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).
///
/// The memoized (or freshly produced) value.
public Task GetAsync(
Func> factory,
bool forceRefresh = false,
CancellationToken cancellationToken = default,
TimeSpan? ttlOverride = null)
{
ArgumentNullException.ThrowIfNull(factory);
TaskCompletionSource? started = null;
Task 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(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> factory, TaskCompletionSource 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);
}
}
}