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; /// /// Process-level memoizing façade over (arch-review WP2.4). /// /// The Alarm Summary page polls every 15s per circuit, 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. /// /// /// The freshness window follows the live cache. While /// 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. /// /// public sealed class SharedAlarmSummaryService : IAlarmSummaryService { /// /// 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. /// internal static readonly TimeSpan ColdCacheTtl = TimeSpan.FromSeconds(12); private readonly IServiceScopeFactory _scopeFactory; private readonly ISiteAlarmLiveCache _liveCache; private readonly TimeSpan _liveCacheTtl; private readonly Func? _clock; private readonly ConcurrentDictionary> _bySite = new(); /// /// Initializes the shared alarm summary façade. /// /// Opens a fresh DI scope per fan-out (fresh repositories, off any circuit scope). /// The shared live alarm cache, consulted only for its per-site liveness. /// Communication options; supplies the aggregator reconcile interval. public SharedAlarmSummaryService( IServiceScopeFactory scopeFactory, ISiteAlarmLiveCache liveCache, IOptions options) : this(scopeFactory, liveCache, (options ?? throw new ArgumentNullException(nameof(options))) .Value.LiveAlarmCacheReconcileInterval, clock: null) { } /// /// Test seam: same façade with an explicit live-cache window and clock. /// /// Opens a fresh DI scope per fan-out. /// The shared live alarm cache. /// Freshness window used while the live cache is serving the site. /// Clock used for freshness. internal SharedAlarmSummaryService( IServiceScopeFactory scopeFactory, ISiteAlarmLiveCache liveCache, TimeSpan liveCacheTtl, Func? 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; } /// public Task GetSiteAlarmsAsync( int siteId, CancellationToken cancellationToken = default) { var memo = _bySite.GetOrAdd( siteId, _ => new SingleFlightMemo(ColdCacheTtl, _clock)); var ttl = _liveCache.IsLive(siteId) ? _liveCacheTtl : ColdCacheTtl; return memo.GetAsync( () => FanOutAsync(siteId), forceRefresh: false, cancellationToken, ttlOverride: ttl); } /// public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) => AlarmSummaryService.BuildFromLiveAlarmsCore(alarms); /// public AlarmRollup ComputeRollup(IReadOnlyList rows) => AlarmSummaryService.ComputeRollupCore(rows); /// /// 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. /// private async Task FanOutAsync(int siteId) { await using var scope = _scopeFactory.CreateAsyncScope(); var inner = scope.ServiceProvider.GetRequiredService(); return await inner.GetSiteAlarmsAsync(siteId, CancellationToken.None); } }