perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders

This commit is contained in:
Joseph Doherty
2026-08-14 20:53:38 -04:00
parent ee193cd2bb
commit 8c0b36b2fa
19 changed files with 1554 additions and 64 deletions
@@ -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);
}
}
}