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); } } }