116 lines
5.3 KiB
C#
116 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|