Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Commons/Caching/SingleFlightMemo.cs
T
Joseph Doherty 1ee2e171cf refactor(deployment): move PendingUpdatesCache to DeploymentManager so the CLI shares it (ScadaBridge#91)
`health pending-updates` (#63) called IPendingUpdatesEvaluator.EvaluateAsync directly
from ManagementActor because the cache lived in CentralUI, so every CLI call was a full
uncached fleet flatten. IPendingUpdatesCache/PendingUpdatesCache now live in
DeploymentManager.PendingUpdates and are registered by AddDeploymentManager; the
SingleFlightMemo<T> primitive moves to Commons.Caching (public) so both CentralUI's KPI /
alarm-summary caches and DeploymentManager can use it. HandleGetPendingUpdates reads the
shared cache; the UI circuits and the CLI now share one memo per node.

Tests: ManagementActor repeated-calls test (fails against the direct evaluator call:
2 evaluations instead of 1), AddDeploymentManager singleton-registration test; the cache
and memo tests move with their types.

(ScadaBridge#91)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:54:29 -05:00

181 lines
7.4 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.Commons.Caching;
/// <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>
/// By default a faulted flight is discarded so the next caller retries. A slot built with a
/// <c>faultBackoff</c> instead remembers the fault for that short negative TTL so a sick
/// dependency is not hammered by every poll; a forced refresh bypasses the back-off.
/// The caller's own exception handling is unchanged: it still sees the fault.
/// </para>
/// </summary>
/// <typeparam name="T">The memoized value type.</typeparam>
public 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>
/// How long a faulted flight is remembered (zero = never). While inside the window, callers
/// are served the same fault instead of starting another flight against a sick dependency.
/// </summary>
private readonly TimeSpan _faultBackoff;
/// <summary>Wall-clock instant until which a faulted <see cref="_current"/> is re-served.</summary>
private DateTimeOffset _faultedUntil;
/// <summary>
/// Bumped by <see cref="Invalidate"/>. A flight records the value it started under and is
/// memoized only if no invalidation happened while it ran.
/// </summary>
private long _generation;
/// <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>
/// <param name="faultBackoff">Negative TTL after a faulted flight; null/zero disables it.</param>
public SingleFlightMemo(TimeSpan ttl, Func<DateTimeOffset>? clock = null, TimeSpan? faultBackoff = null)
{
_ttl = ttl;
_faultBackoff = faultBackoff ?? TimeSpan.Zero;
_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>
/// Marks the memoized value stale, so the next <see cref="GetAsync"/> starts a new flight.
/// A flight already in progress still answers the callers waiting on it, but its result is
/// not memoized: it may have read the state this invalidation reports as changed.
/// </summary>
public void Invalidate()
{
lock (_lock)
{
_generation++;
_freshUntil = default;
_faultedUntil = default;
}
}
/// <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;
long generation = 0;
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 { IsFaulted: true }
&& _clock() < _faultedUntil)
{
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++;
generation = _generation;
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, generation);
}
return cancellationToken.CanBeCanceled ? result.WaitAsync(cancellationToken) : result;
}
private async Task RunAsync(Func<Task<T>> factory, TaskCompletionSource<T> completion, TimeSpan ttl, long generation)
{
try
{
var value = await factory().ConfigureAwait(false);
lock (_lock)
{
// Invalidated while running: answer this flight's callers, but do not serve it
// to anyone else.
_freshUntil = generation == _generation ? _clock() + ttl : default;
}
completion.SetResult(value);
}
catch (Exception ex)
{
// A failure is memoized only for the optional short fault back-off so a sick dependency
// is not retried by every poll; _freshUntil stays in the past.
lock (_lock)
{
_freshUntil = default;
_faultedUntil = _faultBackoff > TimeSpan.Zero ? _clock() + _faultBackoff : default;
}
completion.SetException(ex);
}
}
}