perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders
This commit is contained in:
@@ -128,7 +128,20 @@ public sealed class AlarmSummaryService : IAlarmSummaryService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> alarms)
|
||||
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> alarms) =>
|
||||
BuildFromLiveAlarmsCore(alarms);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows) => ComputeRollupCore(rows);
|
||||
|
||||
/// <summary>
|
||||
/// Pure flattening of a live-cache snapshot into summary rows. Shared with
|
||||
/// <see cref="SharedAlarmSummaryService"/>, which memoizes the fan-out but must produce
|
||||
/// byte-identical rows on the live path — one implementation, no drift.
|
||||
/// </summary>
|
||||
/// <param name="alarms">The live-cache alarm snapshot.</param>
|
||||
/// <returns>The flattened, deterministically ordered rows (never any not-reporting names).</returns>
|
||||
internal static AlarmSummaryResult BuildFromLiveAlarmsCore(IReadOnlyList<AlarmStateChanged> alarms)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alarms);
|
||||
|
||||
@@ -147,8 +160,14 @@ public sealed class AlarmSummaryService : IAlarmSummaryService
|
||||
return new AlarmSummaryResult(orderedRows, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows)
|
||||
/// <summary>
|
||||
/// Pure roll-up over already-flattened rows. Shared with
|
||||
/// <see cref="SharedAlarmSummaryService"/> for the same one-implementation reason as
|
||||
/// <see cref="BuildFromLiveAlarmsCore"/>.
|
||||
/// </summary>
|
||||
/// <param name="rows">The rows to roll up.</param>
|
||||
/// <returns>The active/worst-severity/unacked/by-kind roll-up.</returns>
|
||||
internal static AlarmRollup ComputeRollupCore(IReadOnlyList<AlarmSummaryRow> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on
|
||||
/// this node (arch-review WP2.4, finding #8 — the per-circuit polling half).
|
||||
/// <para>
|
||||
/// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user,
|
||||
/// site scope, or role, so N circuits asking for it inside one refresh window are asking
|
||||
/// the identical question. Without memoization, ten Health dashboards meant ten Asks →
|
||||
/// ten aggregate SQL round trips every ten seconds. Each accessor is independently
|
||||
/// memoized with a short TTL and single-flight production, so the fleet costs ONE round
|
||||
/// trip per KPI per window regardless of viewer count.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Faults are never memoized: a failed query is re-attempted by the next caller, and the
|
||||
/// caller still sees the exception exactly as it would from a direct query — the pages'
|
||||
/// existing per-tile "unavailable" degradation is unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IKpiSnapshotCache
|
||||
{
|
||||
/// <summary>Global Notification Outbox KPIs (queue depth, stuck, parked, delivered-last-interval).</summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value (operator "Refresh"); an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized notification KPI response.</returns>
|
||||
Task<NotificationKpiResponse> GetNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Per-source-site Notification Outbox KPI breakdown.</summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value; an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized per-site notification KPI response.</returns>
|
||||
Task<PerSiteNotificationKpiResponse> GetPerSiteNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Per-node Notification Outbox KPI breakdown (grouped by <c>SourceNode</c>).</summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value; an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized per-node notification KPI response.</returns>
|
||||
Task<PerNodeNotificationKpiResponse> GetPerNodeNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Global Site Call Audit KPIs (buffered, stuck, parked).</summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value; an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized site call KPI response.</returns>
|
||||
Task<SiteCallKpiResponse> GetSiteCallKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Per-node Site Call Audit KPI breakdown (grouped by <c>SourceNode</c>).</summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value; an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized per-node site call KPI response.</returns>
|
||||
Task<PerNodeSiteCallKpiResponse> GetPerNodeSiteCallKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log KPI snapshot (1h volume + error rate over the central <c>AuditLog</c> table,
|
||||
/// plus the summed per-site backlog).
|
||||
/// </summary>
|
||||
/// <param name="forceRefresh">Bypass a still-fresh value; an in-flight round is still joined.</param>
|
||||
/// <param name="cancellationToken">Cancels this caller's wait, never the shared flight.</param>
|
||||
/// <returns>The memoized audit KPI snapshot.</returns>
|
||||
Task<AuditLogKpiSnapshot> GetAuditKpiSnapshotAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IKpiSnapshotCache"/> — a DI <b>singleton</b> holding one
|
||||
/// <see cref="SingleFlightMemo{T}"/> per KPI query.
|
||||
/// <para>
|
||||
/// Registered as a singleton on purpose: the whole point is that the memo outlives any one
|
||||
/// circuit. It depends only on <see cref="CommunicationService"/> (itself a singleton) and
|
||||
/// <see cref="IServiceScopeFactory"/> — the audit KPI resolves its scoped query service from
|
||||
/// a fresh scope per flight, the same shape <c>AuditLogQueryService</c> uses internally so a
|
||||
/// KPI round never shares a circuit-scoped <c>DbContext</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class KpiSnapshotCache : IKpiSnapshotCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Freshness window. Sized just under the Health dashboard's 10s tick so each tick still
|
||||
/// yields ONE fresh query for the whole node (rather than one per circuit), while a
|
||||
/// circuit whose tick lands mid-window is served the memoized value instead of duplicating it.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan Ttl = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
private readonly SingleFlightMemo<NotificationKpiResponse> _notificationKpis;
|
||||
private readonly SingleFlightMemo<PerSiteNotificationKpiResponse> _perSiteNotificationKpis;
|
||||
private readonly SingleFlightMemo<PerNodeNotificationKpiResponse> _perNodeNotificationKpis;
|
||||
private readonly SingleFlightMemo<SiteCallKpiResponse> _siteCallKpis;
|
||||
private readonly SingleFlightMemo<PerNodeSiteCallKpiResponse> _perNodeSiteCallKpis;
|
||||
private readonly SingleFlightMemo<AuditLogKpiSnapshot> _auditKpis;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the shared KPI snapshot cache.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side comms used to Ask the outbox / site-call singletons.</param>
|
||||
/// <param name="scopeFactory">Opens a fresh DI scope per audit-KPI flight.</param>
|
||||
public KpiSnapshotCache(CommunicationService communication, IServiceScopeFactory scopeFactory)
|
||||
: this(communication, scopeFactory, Ttl, clock: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: same cache with an explicit TTL and clock so freshness can be asserted
|
||||
/// without real delays.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side comms used to Ask the outbox / site-call singletons.</param>
|
||||
/// <param name="scopeFactory">Opens a fresh DI scope per audit-KPI flight.</param>
|
||||
/// <param name="ttl">Freshness window.</param>
|
||||
/// <param name="clock">Clock used for freshness.</param>
|
||||
internal KpiSnapshotCache(
|
||||
CommunicationService communication,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TimeSpan ttl,
|
||||
Func<DateTimeOffset>? clock)
|
||||
{
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
|
||||
_notificationKpis = new SingleFlightMemo<NotificationKpiResponse>(ttl, clock);
|
||||
_perSiteNotificationKpis = new SingleFlightMemo<PerSiteNotificationKpiResponse>(ttl, clock);
|
||||
_perNodeNotificationKpis = new SingleFlightMemo<PerNodeNotificationKpiResponse>(ttl, clock);
|
||||
_siteCallKpis = new SingleFlightMemo<SiteCallKpiResponse>(ttl, clock);
|
||||
_perNodeSiteCallKpis = new SingleFlightMemo<PerNodeSiteCallKpiResponse>(ttl, clock);
|
||||
_auditKpis = new SingleFlightMemo<AuditLogKpiSnapshot>(ttl, clock);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<NotificationKpiResponse> GetNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_notificationKpis.GetAsync(
|
||||
() => _communication.GetNotificationKpisAsync(new NotificationKpiRequest(NewCorrelationId())),
|
||||
forceRefresh, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<PerSiteNotificationKpiResponse> GetPerSiteNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_perSiteNotificationKpis.GetAsync(
|
||||
() => _communication.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest(NewCorrelationId())),
|
||||
forceRefresh, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<PerNodeNotificationKpiResponse> GetPerNodeNotificationKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_perNodeNotificationKpis.GetAsync(
|
||||
() => _communication.GetPerNodeNotificationKpisAsync(new PerNodeNotificationKpiRequest(NewCorrelationId())),
|
||||
forceRefresh, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SiteCallKpiResponse> GetSiteCallKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_siteCallKpis.GetAsync(
|
||||
() => _communication.GetSiteCallKpisAsync(new SiteCallKpiRequest(NewCorrelationId())),
|
||||
forceRefresh, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<PerNodeSiteCallKpiResponse> GetPerNodeSiteCallKpisAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_perNodeSiteCallKpis.GetAsync(
|
||||
() => _communication.GetPerNodeSiteCallKpisAsync(new PerNodeSiteCallKpiRequest(NewCorrelationId())),
|
||||
forceRefresh, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AuditLogKpiSnapshot> GetAuditKpiSnapshotAsync(
|
||||
bool forceRefresh = false, CancellationToken cancellationToken = default) =>
|
||||
_auditKpis.GetAsync(LoadAuditKpisAsync, forceRefresh, cancellationToken);
|
||||
|
||||
private async Task<AuditLogKpiSnapshot> LoadAuditKpisAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var queryService = scope.ServiceProvider.GetRequiredService<IAuditLogQueryService>();
|
||||
return await queryService.GetKpiSnapshotAsync();
|
||||
}
|
||||
|
||||
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/> (arch-review WP2.4).
|
||||
/// <para>
|
||||
/// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug
|
||||
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
|
||||
/// meant ten independent fan-outs per 15s across the same instances — on top of the shared
|
||||
/// live-alarm aggregator already seeding and reconciling from the identical fan-out. The
|
||||
/// question is not user-specific, so this service collapses it: one memo slot per site,
|
||||
/// single-flight, so the node costs ONE fan-out per site per window no matter how many
|
||||
/// viewers are watching.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The freshness window follows the live cache.</b> While
|
||||
/// <see cref="ISiteAlarmLiveCache.IsLive"/> is true the page deliberately ignores the poll's
|
||||
/// alarm rows (the live deltas own them — arch-review R2 N5), so the only thing the poll
|
||||
/// still supplies is the not-reporting list, and the window widens to the aggregator's own
|
||||
/// reconcile interval. While the cache is cold the poll is the page's full-rebuild safety
|
||||
/// net, so the window stays just under the page's 15s tick and every tick gets fresh data.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SharedAlarmSummaryService : IAlarmSummaryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Freshness window while the live alarm cache is NOT serving this site: the poll is the
|
||||
/// page's authoritative rebuild path, so it must stay under the 15s page tick.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan ColdCacheTtl = TimeSpan.FromSeconds(12);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ISiteAlarmLiveCache _liveCache;
|
||||
private readonly TimeSpan _liveCacheTtl;
|
||||
private readonly Func<DateTimeOffset>? _clock;
|
||||
|
||||
private readonly ConcurrentDictionary<int, SingleFlightMemo<AlarmSummaryResult>> _bySite = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the shared alarm summary façade.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Opens a fresh DI scope per fan-out (fresh repositories, off any circuit scope).</param>
|
||||
/// <param name="liveCache">The shared live alarm cache, consulted only for its per-site liveness.</param>
|
||||
/// <param name="options">Communication options; supplies the aggregator reconcile interval.</param>
|
||||
public SharedAlarmSummaryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISiteAlarmLiveCache liveCache,
|
||||
IOptions<CommunicationOptions> options)
|
||||
: this(scopeFactory, liveCache, (options ?? throw new ArgumentNullException(nameof(options)))
|
||||
.Value.LiveAlarmCacheReconcileInterval, clock: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: same façade with an explicit live-cache window and clock.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Opens a fresh DI scope per fan-out.</param>
|
||||
/// <param name="liveCache">The shared live alarm cache.</param>
|
||||
/// <param name="liveCacheTtl">Freshness window used while the live cache is serving the site.</param>
|
||||
/// <param name="clock">Clock used for freshness.</param>
|
||||
internal SharedAlarmSummaryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISiteAlarmLiveCache liveCache,
|
||||
TimeSpan liveCacheTtl,
|
||||
Func<DateTimeOffset>? clock)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_liveCache = liveCache ?? throw new ArgumentNullException(nameof(liveCache));
|
||||
// Never shorter than the cold window — a misconfigured reconcile interval must not
|
||||
// silently make the memo useless.
|
||||
_liveCacheTtl = liveCacheTtl > ColdCacheTtl ? liveCacheTtl : ColdCacheTtl;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AlarmSummaryResult> GetSiteAlarmsAsync(
|
||||
int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var memo = _bySite.GetOrAdd(
|
||||
siteId,
|
||||
_ => new SingleFlightMemo<AlarmSummaryResult>(ColdCacheTtl, _clock));
|
||||
|
||||
var ttl = _liveCache.IsLive(siteId) ? _liveCacheTtl : ColdCacheTtl;
|
||||
|
||||
return memo.GetAsync(
|
||||
() => FanOutAsync(siteId),
|
||||
forceRefresh: false,
|
||||
cancellationToken,
|
||||
ttlOverride: ttl);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList<AlarmStateChanged> alarms) =>
|
||||
AlarmSummaryService.BuildFromLiveAlarmsCore(alarms);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AlarmRollup ComputeRollup(IReadOnlyList<AlarmSummaryRow> rows) =>
|
||||
AlarmSummaryService.ComputeRollupCore(rows);
|
||||
|
||||
/// <summary>
|
||||
/// One shared fan-out. Deliberately runs with no caller cancellation token: the flight is
|
||||
/// shared, so one circuit navigating away must not cancel the round the others are awaiting.
|
||||
/// </summary>
|
||||
private async Task<AlarmSummaryResult> FanOutAsync(int siteId)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = scope.ServiceProvider.GetRequiredService<AlarmSummaryService>();
|
||||
return await inner.GetSiteAlarmsAsync(siteId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -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