perf(ui): shared KPI cache, live-cache-backed alarm summary, coalesced debug renders
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user