diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor index 7238786c..0051a151 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -291,13 +291,56 @@ .OrderBy(a => a.AttributeName) .ToList(); - /// Attribute composition forest, rebuilt from the live latest-per-name dictionary. - private IReadOnlyList AttributeForest => - DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter); + // ── Tree memoization (arch-review WP2.4) ────────────────────────────────── + // Both forests used to be rebuilt from scratch on EVERY render — and a render + // fired per streamed event. A busy instance therefore rebuilt two whole + // composition trees hundreds of times a second, on the circuit's single + // dispatcher thread. The rebuild now sits behind a version stamp bumped + // whenever the underlying dictionaries change (a coalesced stream flush, + // Connect, Disconnect), so an unrelated re-render (tab switch, toast, status + // strip tick) reuses the forest instead of rebuilding it. Reusing the same + // instance also lets Blazor skip re-rendering the TreeView subtree entirely. + // + // The cached key includes the dictionary Count as well as the version: the + // trees must never go stale if the dictionaries are populated by some path + // that forgets to bump the stamp. + private int _dataVersion; - /// Alarm composition forest, rebuilt from the live latest-per-name dictionary. - private IReadOnlyList AlarmForest => - DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter); + private IReadOnlyList? _attrForestCache; + private (int Version, int Count, string Filter) _attrForestKey = (-1, -1, string.Empty); + + private IReadOnlyList? _alarmForestCache; + private (int Version, int Count, string Filter) _alarmForestKey = (-1, -1, string.Empty); + + /// Attribute composition forest, rebuilt only when the underlying data or filter changed. + private IReadOnlyList AttributeForest + { + get + { + var key = (_dataVersion, _attributeValues.Count, _attrFilter); + if (_attrForestCache is null || _attrForestKey != key) + { + _attrForestCache = DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter); + _attrForestKey = key; + } + return _attrForestCache; + } + } + + /// Alarm composition forest, rebuilt only when the underlying data or filter changed. + private IReadOnlyList AlarmForest + { + get + { + var key = (_dataVersion, _alarmStates.Count, _alarmFilter); + if (_alarmForestCache is null || _alarmForestKey != key) + { + _alarmForestCache = DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter); + _alarmForestKey = key; + } + return _alarmForestCache; + } + } private DebugStreamSession? _session; private ToastNotification _toast = default!; @@ -458,6 +501,9 @@ foreach (var al in session.InitialSnapshot.AlarmStates) _alarmStates[al.AlarmName] = al; + // Snapshot seeding is a data change like any other — bump the stamp so + // the memoized forests rebuild from it. + _dataVersion++; _snapshot = session.InitialSnapshot; _connected = true; @@ -492,6 +538,11 @@ _snapshot = null; _attributeValues.Clear(); _alarmStates.Clear(); + // Drop anything the stream buffered for the session we just tore down, so a + // late flush can't repopulate a disconnected view. + _pendingAttributes.Clear(); + _pendingAlarms.Clear(); + _dataVersion++; } /// @@ -508,37 +559,108 @@ _toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000); } + // ── Render coalescing (arch-review WP2.4) ───────────────────────────────── + // Every streamed event used to marshal onto the dispatcher and call + // StateHasChanged individually, so a chatty instance drove one full render + // (and two full tree rebuilds) per value change. Events now land in a + // thread-safe pending map keyed by name — repeated updates to the same tag + // inside one window collapse to the latest — and exactly ONE dispatcher + // marshal per window drains them, bumps the version stamp and renders once. + private const int RenderCoalesceMs = 250; + + private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingAttributes = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingAlarms = new(); + + /// 0/1 flag: whether a coalesce window is already armed. Interlocked-guarded. + private int _flushArmed; + /// /// Handles one debug-stream event. The callback is invoked on an Akka/gRPC /// thread, but / are /// instances also enumerated by the /// render thread (the tree forests + are /// built from them). Dictionary is not thread-safe (CentralUI-021): a - /// write racing an enumeration can throw or corrupt the buckets. The mutation - /// is therefore marshalled onto the renderer's dispatcher via - /// so every access to the dictionaries — read - /// and write — happens on the render thread. + /// write racing an enumeration can throw or corrupt the buckets. The event is + /// therefore parked in a concurrent pending map here and applied on the + /// renderer's dispatcher by , so every access to + /// the render dictionaries — read and write — still happens on one thread. /// private void HandleStreamEvent(object evt) { // CentralUI-009: the component may have been disposed while this event // was in flight on the Akka/gRPC thread. if (_disposed) return; - _ = SafeInvokeAsync(() => + + switch (evt) { + case AttributeValueChanged av: + _pendingAttributes[av.AttributeName] = av; + break; + case AlarmStateChanged al: + _pendingAlarms[al.AlarmName] = al; + break; + default: + // Unknown event type — nothing to apply, no render needed. + return; + } + + // Arm the window if it isn't already. Losers of the CAS have nothing to do: + // their event is in the pending map and the armed flush will pick it up. + if (Interlocked.CompareExchange(ref _flushArmed, 1, 0) == 0) + { + _ = FlushPendingAsync(); + } + } + + /// + /// Waits out the coalesce window, then applies everything buffered in one + /// dispatcher pass and renders once. + /// + private async Task FlushPendingAsync() + { + try + { + await Task.Delay(RenderCoalesceMs); + } + catch + { + // Delay never faults in practice; if it ever did, fall through and drain. + } + + if (_disposed) + { + Volatile.Write(ref _flushArmed, 0); + return; + } + + await SafeInvokeAsync(() => + { + // Disarm BEFORE draining: an event arriving mid-drain then arms a fresh + // window rather than being stranded until the next unrelated event. + Volatile.Write(ref _flushArmed, 0); if (_disposed) return; - switch (evt) + + var applied = false; + foreach (var key in _pendingAttributes.Keys) { - case AttributeValueChanged av: + if (_pendingAttributes.TryRemove(key, out var av)) + { _attributeValues[av.AttributeName] = av; - break; - case AlarmStateChanged al: - _alarmStates[al.AlarmName] = al; - break; - default: - // Unknown event type — no re-render needed. - return; + applied = true; + } } + foreach (var key in _pendingAlarms.Keys) + { + if (_pendingAlarms.TryRemove(key, out var al)) + { + _alarmStates[al.AlarmName] = al; + applied = true; + } + } + + if (!applied) return; + + _dataVersion++; StateHasChanged(); }); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor index 33b8ccd8..69bedd7a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor @@ -236,13 +236,66 @@ DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged; } + // ── Push coalescing (arch-review WP2.4) ─────────────────────────────────── + // One reload = every deployment record + every instance, re-read and re-filtered. + // The notifier fires per status WRITE, so a site deploy of N instances produced + // 2N+ of those full reloads back to back, per circuit. The reload is now + // leading-edge debounced: the first push after an idle gap still reloads + // immediately (a single deployment stays as responsive as before), and every + // push inside the window is absorbed into ONE trailing reload. + private const int ReloadDebounceMs = 500; + + private DateTimeOffset _lastReloadAt = DateTimeOffset.MinValue; + + /// Coalescing timer for pushes arriving inside the debounce window. Disposed with the component. + private Timer? _coalesceTimer; + private void OnDeploymentStatusChanged(ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusChange change) { // CentralUI-022: a callback racing disposal must not touch the component. if (_disposed || !_autoRefresh) return; + + lock (_coalesceLock) + { + if (_disposed) return; + + var sinceLast = DateTimeOffset.UtcNow - _lastReloadAt; + if (sinceLast >= TimeSpan.FromMilliseconds(ReloadDebounceMs)) + { + // Idle — reload straight away (leading edge). + _lastReloadAt = DateTimeOffset.UtcNow; + } + else + { + // Inside the window: arm one trailing reload for the remainder, and + // let any further push in this window ride it. + if (_coalesceTimer is not null) return; + + var delay = TimeSpan.FromMilliseconds(ReloadDebounceMs) - sinceLast; + _coalesceTimer = new Timer(_ => OnCoalesceElapsed(), null, delay, Timeout.InfiniteTimeSpan); + return; + } + } + _ = DispatchReloadAsync(); } + /// Trailing edge of the debounce: disarm, stamp, and run the one coalesced reload. + private void OnCoalesceElapsed() + { + lock (_coalesceLock) + { + _coalesceTimer?.Dispose(); + _coalesceTimer = null; + if (_disposed) return; + _lastReloadAt = DateTimeOffset.UtcNow; + } + + _ = DispatchReloadAsync(); + } + + private readonly object _coalesceLock = new(); + /// /// Reloads the deployment table on the renderer's dispatcher, guarded /// against the component being disposed mid-flight (CentralUI-022): @@ -361,5 +414,10 @@ // status change reaches this disposed component. _disposed = true; DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged; + lock (_coalesceLock) + { + _coalesceTimer?.Dispose(); + _coalesceTimer = null; + } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index c84aeb59..6571d165 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -1,5 +1,6 @@ @page "/monitoring/alarms" @attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)] +@using Microsoft.AspNetCore.Components.Web.Virtualization @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @@ -196,15 +197,25 @@ @onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity") + @* Row markup is identical on both paths — the same , the same cells, + the same data-test hook. Only WHO enumerates it differs: a plain + foreach for the ordinary case, and Virtualize once the flat table + gets long enough that rendering every row per delta dominates the + circuit (arch-review WP2.4). Virtualize needs spacers or the + browser hoists its
s out of the table and the layout collapses. *@ - @foreach (var row in _visibleRows) + @if (_visibleRows.Count <= VirtualizeThreshold) { - - @row.InstanceUniqueName - @row.Alarm.AlarmName - - @row.Alarm.Condition.Severity - + @foreach (var row in _visibleRows) + { + @AlarmRow(row) + } + } + else + { + + @AlarmRow(row) + } @@ -222,7 +233,23 @@ // P4 (arch-review): the filtered+sorted view is memoized rather than recomputed // on every render. RecomputeVisibleRows() refreshes it whenever the inputs change // (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort). - private IReadOnlyList _visibleRows = Array.Empty(); + // Concrete List because Virtualize binds ICollection, not IReadOnlyList. + private List _visibleRows = new(); + + // Above this many visible rows the flat table switches to Virtualize. Below it the + // plain foreach is cheaper than the virtualization machinery (and needs no JS), and + // an operator filtered down to a handful of alarms should never pay for either. + private const int VirtualizeThreshold = 150; + + // One row template, shared by the plain and virtualized paths so the two can never + // drift visually. + private RenderFragment AlarmRow => row => + @ + @row.InstanceUniqueName + @row.Alarm.AlarmName + + @row.Alarm.Condition.Severity + ; private IReadOnlyList _notReporting = Array.Empty(); private AlarmRollup _rollup = new(0, 0, 0, new Dictionary()); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor index beb3743e..bbcd16a6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor @@ -15,8 +15,7 @@ @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository -@inject CommunicationService CommunicationService -@inject IAuditLogQueryService AuditLogQueryService +@inject IKpiSnapshotCache KpiSnapshots @inject IKpiHistoryQueryService KpiHistory @inject Microsoft.Extensions.Options.IOptions HealthOptions @@ -640,7 +639,9 @@ // Non-fatal — fall back to showing siteId only } - await RefreshNow(); + // First load rides whatever the shared KPI cache already has fresh — a second + // operator opening the dashboard inside the window costs no extra query. + await RefreshAllAsync(forceRefresh: false); // Site Health Trends (M6) load on their own path — never from the // timer tick below — so a trend-query fault can't disturb the live tile @@ -655,7 +656,7 @@ { try { - await RefreshNow(); + await RefreshAllAsync(forceRefresh: false); StateHasChanged(); } finally @@ -769,20 +770,38 @@ ? "Central Cluster" : $"{GetSiteName(siteKey)} ({siteKey})"; - private async Task RefreshNow() + // Per-tile budget for one refresh tick. The underlying KPI Asks carry the + // 30s CommunicationOptions.QueryTimeout, so without this a single hung + // singleton stalled the WHOLE tick (and, with a 10s timer, stacked ticks) + // rather than degrading its own tile. Cancelling here abandons only this + // circuit's wait — the shared KPI flight continues and its result lands in + // the process-level cache for the next tick. + private static readonly TimeSpan TileTimeout = TimeSpan.FromSeconds(5); + + // The operator's explicit "Refresh Now" bypasses the shared cache's freshness + // window; the timer tick and the first load ride whatever is already fresh + // (which is the whole point — N dashboards share one query per window). + private Task RefreshNow() => RefreshAllAsync(forceRefresh: true); + + // Every tile loads concurrently, each with its own short budget, so one slow + // KPI degrades one tile instead of the tick. Every loader swallows its own + // faults, so Task.WhenAll here can never observe an exception. + private async Task RefreshAllAsync(bool forceRefresh) { _siteStates = HealthAggregator.GetAllSiteStates(); - await LoadOutboxKpis(); - await Task.WhenAll(LoadSiteCallKpis(), LoadSiteCallNodeKpis()); - await LoadAuditKpis(); + await Task.WhenAll( + LoadOutboxKpis(forceRefresh), + LoadSiteCallKpis(forceRefresh), + LoadSiteCallNodeKpis(forceRefresh), + LoadAuditKpis(forceRefresh)); } - private async Task LoadOutboxKpis() + private async Task LoadOutboxKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetNotificationKpisAsync( - new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); + using var timeout = new CancellationTokenSource(TileTimeout); + var response = await KpiSnapshots.GetNotificationKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _outboxKpi = response; @@ -795,6 +814,11 @@ _outboxKpiError = response.ErrorMessage ?? "KPI query failed."; } } + catch (OperationCanceledException) + { + _outboxKpiAvailable = false; + _outboxKpiError = "KPI query timed out."; + } catch (Exception ex) { _outboxKpiAvailable = false; @@ -807,12 +831,12 @@ // killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a // response with Success == false (repository fault) and an Ask that threw // (transport fault) both collapse to "unavailable". - private async Task LoadSiteCallKpis() + private async Task LoadSiteCallKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetSiteCallKpisAsync( - new SiteCallKpiRequest(Guid.NewGuid().ToString("N"))); + using var timeout = new CancellationTokenSource(TileTimeout); + var response = await KpiSnapshots.GetSiteCallKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _siteCallKpi = response; @@ -825,6 +849,11 @@ _siteCallKpiError = response.ErrorMessage ?? "KPI query failed."; } } + catch (OperationCanceledException) + { + _siteCallKpiAvailable = false; + _siteCallKpiError = "KPI query timed out."; + } catch (Exception ex) { _siteCallKpiAvailable = false; @@ -834,12 +863,12 @@ // Per-node site-call KPI loader (M5.2). Best-effort; a fault silently // suppresses the per-node sub-table rather than degrading the dashboard. - private async Task LoadSiteCallNodeKpis() + private async Task LoadSiteCallNodeKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetPerNodeSiteCallKpisAsync( - new PerNodeSiteCallKpiRequest(Guid.NewGuid().ToString("N"))); + using var timeout = new CancellationTokenSource(TileTimeout); + var response = await KpiSnapshots.GetPerNodeSiteCallKpisAsync(forceRefresh, timeout.Token); if (response.Success) { _siteCallNodeKpis = response.Nodes; @@ -864,14 +893,20 @@ // Audit KPI loader: wraps the service call so a transient DB outage degrades // the three tiles to em dashes with an inline error rather than killing the // dashboard. Mirrors LoadOutboxKpis's error handling shape. - private async Task LoadAuditKpis() + private async Task LoadAuditKpis(bool forceRefresh) { try { - _auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync(); + using var timeout = new CancellationTokenSource(TileTimeout); + _auditKpi = await KpiSnapshots.GetAuditKpiSnapshotAsync(forceRefresh, timeout.Token); _auditKpiAvailable = true; _auditKpiError = null; } + catch (OperationCanceledException) + { + _auditKpiAvailable = false; + _auditKpiError = "KPI query timed out."; + } catch (Exception ex) { _auditKpiAvailable = false; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor index 93df1e40..a8793aab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor @@ -7,7 +7,7 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi @using ZB.MOM.WW.ScadaBridge.Communication @using ZB.MOM.WW.ScadaBridge.CentralUI.Services -@inject CommunicationService CommunicationService +@inject IKpiSnapshotCache KpiSnapshots @inject ISiteRepository SiteRepository @inject IKpiHistoryQueryService KpiHistory @inject ILogger Logger @@ -250,15 +250,24 @@ Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown."); } - await RefreshAll(); + // First load rides whatever the shared KPI cache already holds fresh; the + // Refresh button forces a new query (see RefreshAll). + await LoadAllAsync(forceRefresh: false); } - private async Task RefreshAll() + // The operator's explicit Refresh bypasses the shared cache's freshness window. + private Task RefreshAll() => LoadAllAsync(forceRefresh: true); + + private async Task LoadAllAsync(bool forceRefresh) { _loading = true; // Race-free despite all tasks mutating component fields: Blazor Server runs // every continuation on the circuit's single-threaded synchronization context. - await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis(), LoadTrends()); + await Task.WhenAll( + LoadGlobalKpis(forceRefresh), + LoadPerSiteKpis(forceRefresh), + LoadPerNodeKpis(forceRefresh), + LoadTrends()); _loading = false; } @@ -320,12 +329,11 @@ } } - private async Task LoadGlobalKpis() + private async Task LoadGlobalKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetNotificationKpisAsync( - new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); + var response = await KpiSnapshots.GetNotificationKpisAsync(forceRefresh); if (response.Success) { _kpi = response; @@ -342,12 +350,11 @@ } } - private async Task LoadPerSiteKpis() + private async Task LoadPerSiteKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetPerSiteNotificationKpisAsync( - new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N"))); + var response = await KpiSnapshots.GetPerSiteNotificationKpisAsync(forceRefresh); if (response.Success) { _perSite = response.Sites; @@ -364,12 +371,11 @@ } } - private async Task LoadPerNodeKpis() + private async Task LoadPerNodeKpis(bool forceRefresh) { try { - var response = await CommunicationService.GetPerNodeNotificationKpisAsync( - new PerNodeNotificationKpiRequest(Guid.NewGuid().ToString("N"))); + var response = await KpiSnapshots.GetPerNodeNotificationKpisAsync(forceRefresh); if (response.Success) { _perNode = response.Nodes; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index b36f106a..6b6a0882 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -188,6 +188,31 @@ public class ScriptAnalysisService private const int SandboxMaxCallSharedDepth = 16; + /// + /// Process-wide cap on concurrently executing Test Runs (arch-review WP2.4). + /// + /// A sandbox run occupies a thread-pool thread for its whole duration — the + /// SandboxScriptHost attribute accessors are synchronous by contract and block on + /// cross-site I/O (GetAwaiter().GetResult()), so the thread is parked, not + /// merely busy, for up to the 10s sandbox timeout. Unbounded, a handful of Designers + /// each hitting Run on a slow-bound script could park enough pool threads to starve the + /// Blazor circuits sharing this process. The gate bounds that: excess runs wait for a + /// slot instead of taking a thread, and the wait is charged to the caller's own timeout. + /// + /// + /// Static because ScriptAnalysisService is registered scoped — a per-instance gate + /// would be per-request and bound nothing. + /// + /// + private static readonly SemaphoreSlim SandboxRunGate = + new(SandboxMaxConcurrentRuns, SandboxMaxConcurrentRuns); + + /// + /// Slot count for . Deliberately small: Test Run is an + /// authoring convenience on a node whose real job is serving circuits. + /// + private const int SandboxMaxConcurrentRuns = 4; + /// /// Compiles and runs a script in the central process. The globals surface /// depends on : template and shared @@ -437,8 +462,15 @@ public class ScriptAnalysisService using var errorScope = captureError.BeginCapture(captured); var stopwatch = Stopwatch.StartNew(); + var gateAcquired = false; try { + // Bound concurrent Test Runs (see SandboxRunGate). The wait is charged to the + // caller's own linked token, so queue time counts against the sandbox timeout + // and a queued run reports Timeout rather than hanging. + await SandboxRunGate.WaitAsync(linkedCts.Token).ConfigureAwait(false); + gateAcquired = true; + // Run on a thread-pool thread with no SynchronizationContext: a // bound script's Instance.SetAttribute / Attributes[...] block // synchronously on cross-site I/O (the API surface is sync by @@ -489,6 +521,10 @@ public class ScriptAnalysisService $"{inner.GetType().Name}: {inner.Message}", SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null); } + finally + { + if (gateAcquired) SandboxRunGate.Release(); + } // outScope / errorScope are disposed by their `using` declarations when the // method returns, restoring the previous capture scope on this call-tree // without touching process-global Console state. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 7ba4f291..2509313a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -99,7 +99,17 @@ public static class ServiceCollectionExtensions // facade over CommunicationService.RequestDebugSnapshotAsync (the same // single-shot Ask the Debug View uses) — and flattens the alarm states. services.AddScoped(); - services.AddScoped(); + // The fan-out itself stays scoped (it holds scoped repositories + the snapshot + // client), but the page resolves it through SharedAlarmSummaryService — a process + // singleton that memoizes the fan-out per site with single-flight, so N circuits + // watching one site cost ONE fan-out per window instead of N (arch-review WP2.4). + services.AddScoped(); + services.AddSingleton(); + + // Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding + // #8's per-circuit half). Singleton on purpose: the memo has to outlive any one + // circuit for N dashboards to share a single SQL round per refresh window. + services.AddSingleton(); // Secured Writes: dispatches the two-person secured-write commands // (submit / approve / reject / list) to the central ManagementActor through the diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs index 0e2bcc9d..6d22d117 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs @@ -128,7 +128,20 @@ public sealed class AlarmSummaryService : IAlarmSummaryService } /// - public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) + public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) => + BuildFromLiveAlarmsCore(alarms); + + /// + public AlarmRollup ComputeRollup(IReadOnlyList rows) => ComputeRollupCore(rows); + + /// + /// Pure flattening of a live-cache snapshot into summary rows. Shared with + /// , which memoizes the fan-out but must produce + /// byte-identical rows on the live path — one implementation, no drift. + /// + /// The live-cache alarm snapshot. + /// The flattened, deterministically ordered rows (never any not-reporting names). + internal static AlarmSummaryResult BuildFromLiveAlarmsCore(IReadOnlyList alarms) { ArgumentNullException.ThrowIfNull(alarms); @@ -147,8 +160,14 @@ public sealed class AlarmSummaryService : IAlarmSummaryService return new AlarmSummaryResult(orderedRows, Array.Empty()); } - /// - public AlarmRollup ComputeRollup(IReadOnlyList rows) + /// + /// Pure roll-up over already-flattened rows. Shared with + /// for the same one-implementation reason as + /// . + /// + /// The rows to roll up. + /// The active/worst-severity/unacked/by-kind roll-up. + internal static AlarmRollup ComputeRollupCore(IReadOnlyList rows) { ArgumentNullException.ThrowIfNull(rows); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiSnapshotCache.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiSnapshotCache.cs new file mode 100644 index 00000000..4785c4a1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiSnapshotCache.cs @@ -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; + +/// +/// 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). +/// +/// Every KPI here is a global, point-in-time aggregate: 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. +/// +/// +/// 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. +/// +/// +public interface IKpiSnapshotCache +{ + /// Global Notification Outbox KPIs (queue depth, stuck, parked, delivered-last-interval). + /// Bypass a still-fresh value (operator "Refresh"); an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized notification KPI response. + Task GetNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// Per-source-site Notification Outbox KPI breakdown. + /// Bypass a still-fresh value; an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized per-site notification KPI response. + Task GetPerSiteNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// Per-node Notification Outbox KPI breakdown (grouped by SourceNode). + /// Bypass a still-fresh value; an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized per-node notification KPI response. + Task GetPerNodeNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// Global Site Call Audit KPIs (buffered, stuck, parked). + /// Bypass a still-fresh value; an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized site call KPI response. + Task GetSiteCallKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// Per-node Site Call Audit KPI breakdown (grouped by SourceNode). + /// Bypass a still-fresh value; an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized per-node site call KPI response. + Task GetPerNodeSiteCallKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// + /// Audit Log KPI snapshot (1h volume + error rate over the central AuditLog table, + /// plus the summed per-site backlog). + /// + /// Bypass a still-fresh value; an in-flight round is still joined. + /// Cancels this caller's wait, never the shared flight. + /// The memoized audit KPI snapshot. + Task GetAuditKpiSnapshotAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs new file mode 100644 index 00000000..f18b56d7 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs @@ -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; + +/// +/// Default — a DI singleton holding one +/// per KPI query. +/// +/// Registered as a singleton on purpose: the whole point is that the memo outlives any one +/// circuit. It depends only on (itself a singleton) and +/// — the audit KPI resolves its scoped query service from +/// a fresh scope per flight, the same shape AuditLogQueryService uses internally so a +/// KPI round never shares a circuit-scoped DbContext. +/// +/// +public sealed class KpiSnapshotCache : IKpiSnapshotCache +{ + /// + /// 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. + /// + internal static readonly TimeSpan Ttl = TimeSpan.FromSeconds(8); + + private readonly CommunicationService _communication; + private readonly IServiceScopeFactory _scopeFactory; + + private readonly SingleFlightMemo _notificationKpis; + private readonly SingleFlightMemo _perSiteNotificationKpis; + private readonly SingleFlightMemo _perNodeNotificationKpis; + private readonly SingleFlightMemo _siteCallKpis; + private readonly SingleFlightMemo _perNodeSiteCallKpis; + private readonly SingleFlightMemo _auditKpis; + + /// + /// Initializes the shared KPI snapshot cache. + /// + /// Central-side comms used to Ask the outbox / site-call singletons. + /// Opens a fresh DI scope per audit-KPI flight. + public KpiSnapshotCache(CommunicationService communication, IServiceScopeFactory scopeFactory) + : this(communication, scopeFactory, Ttl, clock: null) + { + } + + /// + /// Test seam: same cache with an explicit TTL and clock so freshness can be asserted + /// without real delays. + /// + /// Central-side comms used to Ask the outbox / site-call singletons. + /// Opens a fresh DI scope per audit-KPI flight. + /// Freshness window. + /// Clock used for freshness. + internal KpiSnapshotCache( + CommunicationService communication, + IServiceScopeFactory scopeFactory, + TimeSpan ttl, + Func? clock) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + + _notificationKpis = new SingleFlightMemo(ttl, clock); + _perSiteNotificationKpis = new SingleFlightMemo(ttl, clock); + _perNodeNotificationKpis = new SingleFlightMemo(ttl, clock); + _siteCallKpis = new SingleFlightMemo(ttl, clock); + _perNodeSiteCallKpis = new SingleFlightMemo(ttl, clock); + _auditKpis = new SingleFlightMemo(ttl, clock); + } + + /// + public Task GetNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _notificationKpis.GetAsync( + () => _communication.GetNotificationKpisAsync(new NotificationKpiRequest(NewCorrelationId())), + forceRefresh, cancellationToken); + + /// + public Task GetPerSiteNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _perSiteNotificationKpis.GetAsync( + () => _communication.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest(NewCorrelationId())), + forceRefresh, cancellationToken); + + /// + public Task GetPerNodeNotificationKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _perNodeNotificationKpis.GetAsync( + () => _communication.GetPerNodeNotificationKpisAsync(new PerNodeNotificationKpiRequest(NewCorrelationId())), + forceRefresh, cancellationToken); + + /// + public Task GetSiteCallKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _siteCallKpis.GetAsync( + () => _communication.GetSiteCallKpisAsync(new SiteCallKpiRequest(NewCorrelationId())), + forceRefresh, cancellationToken); + + /// + public Task GetPerNodeSiteCallKpisAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _perNodeSiteCallKpis.GetAsync( + () => _communication.GetPerNodeSiteCallKpisAsync(new PerNodeSiteCallKpiRequest(NewCorrelationId())), + forceRefresh, cancellationToken); + + /// + public Task GetAuditKpiSnapshotAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _auditKpis.GetAsync(LoadAuditKpisAsync, forceRefresh, cancellationToken); + + private async Task LoadAuditKpisAsync() + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var queryService = scope.ServiceProvider.GetRequiredService(); + return await queryService.GetKpiSnapshotAsync(); + } + + private static string NewCorrelationId() => Guid.NewGuid().ToString("N"); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs new file mode 100644 index 00000000..29a6359e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs @@ -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; + +/// +/// 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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs new file mode 100644 index 00000000..a95731e8 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs @@ -0,0 +1,136 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// A process-level memo slot: one value, a time-to-live, and single-flight production. +/// +/// Blazor Server runs one circuit per connected browser, so a page that polls on a timer +/// runs its query per circuit. 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 same +/// , and callers arriving after it completes are served the +/// memoized value until the TTL expires. +/// +/// +/// 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. +/// +/// +/// The memoized value type. +internal sealed class SingleFlightMemo +{ + private readonly object _lock = new(); + private readonly TimeSpan _ttl; + private readonly Func _clock; + + /// The current flight, completed or not. Null until the first call. + private Task? _current; + + /// Wall-clock instant after which is stale. Only meaningful when it completed successfully. + private DateTimeOffset _freshUntil; + + /// + /// Initializes a memo slot. + /// + /// How long a successfully produced value stays fresh. + /// Clock used for freshness, injectable so tests need no real delay. + public SingleFlightMemo(TimeSpan ttl, Func? clock = null) + { + _ttl = ttl; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + /// + /// Number of times the factory has actually been invoked. Diagnostics and tests only. + /// + public int FlightCount { get; private set; } + + /// + /// Returns the memoized value, starting a flight if none is fresh. + /// + /// Produces a fresh value. Invoked outside the slot's lock. + /// + /// When , a completed-but-still-fresh value is discarded and a new + /// flight starts. An in-flight round is still joined rather than duplicated, so a + /// burst of operator "Refresh" clicks remains one round trip. + /// + /// + /// Cancels this caller's wait only — never the shared flight, which other callers may still + /// be awaiting. + /// + /// + /// Freshness window for this 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). + /// + /// The memoized (or freshly produced) value. + public Task GetAsync( + Func> factory, + bool forceRefresh = false, + CancellationToken cancellationToken = default, + TimeSpan? ttlOverride = null) + { + ArgumentNullException.ThrowIfNull(factory); + + TaskCompletionSource? started = null; + Task 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(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> factory, TaskCompletionSource 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); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs new file mode 100644 index 00000000..86678da3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewRenderCoalescingTests.cs @@ -0,0 +1,144 @@ +using System.Collections; +using System.Reflection; +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; +using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Regression tests for the Debug View render coalescing (arch-review WP2.4). Every streamed +/// event used to marshal onto the circuit dispatcher and call StateHasChanged on its +/// own, so a chatty instance drove one full render — and two full composition-tree rebuilds — +/// per value change. Events are now buffered and applied once per ~250 ms window: a burst +/// costs a handful of renders instead of one per event, and no event is dropped. +/// +public class DebugViewRenderCoalescingTests : BunitContext +{ + private IRenderedComponent RenderPage() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + var repo = Substitute.For(); + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync().Returns(new List()); + Services.AddSingleton(repo); + Services.AddSingleton(siteRepo); + + var comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + Services.AddSingleton(comms); + + var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); + var debugStream = new DebugStreamService( + comms, new ServiceCollection().BuildServiceProvider(), grpcFactory, + NullLogger.Instance); + Services.AddSingleton(debugStream); + + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); + var stubAuth = new StubAuthStateProvider( + new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(stubAuth); + Services.AddScoped(_ => new SiteScopeService(stubAuth)); + + return Render(); + } + + private sealed class StubAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState _state; + public StubAuthStateProvider(AuthenticationState state) => _state = state; + public override Task GetAuthenticationStateAsync() + => Task.FromResult(_state); + } + + private static MethodInfo HandleStreamEvent => typeof(DebugViewPage).GetMethod( + "HandleStreamEvent", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static IDictionary AttributeValues(DebugViewPage c) => (IDictionary) + typeof(DebugViewPage).GetField("_attributeValues", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!; + + private static void Send(DebugViewPage page, string name, object value) => + HandleStreamEvent.Invoke(page, new object[] + { + new AttributeValueChanged("Inst-1", name, name, value, "Good", DateTimeOffset.UtcNow), + }); + + [Fact] + public void BurstOfEvents_RendersOnce_ButAppliesEveryEvent() + { + var cut = RenderPage(); + var dict = AttributeValues(cut.Instance); + var rendersBefore = cut.RenderCount; + + const int burst = 300; + for (var i = 0; i < burst; i++) + { + Send(cut.Instance, $"Tag.{i}", i); + } + + cut.WaitForState(() => dict.Count == burst, TimeSpan.FromSeconds(5)); + + // Pre-fix this was one render per event. The window may close mid-burst, so allow a + // few flushes — the point is that it is a small constant, not O(events). + var renders = cut.RenderCount - rendersBefore; + Assert.InRange(renders, 1, 5); + } + + [Fact] + public void RepeatedUpdatesToOneTag_CollapseToTheLatestValue() + { + var cut = RenderPage(); + var dict = AttributeValues(cut.Instance); + + for (var i = 0; i < 50; i++) + { + Send(cut.Instance, "Pump.Speed", i); + } + + cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(5)); + var applied = (AttributeValueChanged)dict["Pump.Speed"]!; + Assert.Equal(49, applied.Value); + } + + [Fact] + public void EventArrivingAfterAFlush_StillArmsANewWindow() + { + var cut = RenderPage(); + var dict = AttributeValues(cut.Instance); + + Send(cut.Instance, "First", 1); + cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(5)); + + // The window disarms on drain; a later event must arm a fresh one rather than + // sitting in the pending map until some unrelated event happens to arrive. + Send(cut.Instance, "Second", 2); + cut.WaitForState(() => dict.Count == 2, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void EventsAfterDispose_AreDroppedWithoutThrowing() + { + var cut = RenderPage(); + cut.Instance.Dispose(); + + var ex = Record.Exception(() => Send(cut.Instance, "Late", 1)); + + Assert.Null(ex); + Assert.Empty(AttributeValues(cut.Instance)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs new file mode 100644 index 00000000..83e578b5 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs @@ -0,0 +1,127 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). One +/// notifier callback reloads EVERY deployment record plus EVERY instance, and the notifier +/// fires per status write — so a multi-instance deploy produced a stampede of full reloads +/// per circuit. The reload is now leading-edge debounced: the first push after an idle gap +/// is still immediate, and a burst behind it collapses into one trailing reload. +/// +public class DeploymentsReloadDebounceTests : BunitContext +{ + private IDeploymentManagerRepository _deployRepo = null!; + private ITemplateEngineRepository _templateRepo = null!; + private DeploymentStatusNotifier _notifier = null!; + + private void RegisterServices() + { + _deployRepo = Substitute.For(); + _templateRepo = Substitute.For(); + _notifier = new DeploymentStatusNotifier(NullLogger.Instance); + + _templateRepo.GetAllInstancesAsync(Arg.Any()) + .Returns(new List { new("Inst-1") { Id = 1, SiteId = 1 } }); + _deployRepo.GetAllDeploymentRecordsAsync(Arg.Any()) + .Returns(new List()); + + Services.AddSingleton(_deployRepo); + Services.AddSingleton(_templateRepo); + Services.AddSingleton(_notifier); + + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); + var stubAuth = new StubAuthStateProvider( + new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(stubAuth); + Services.AddScoped(_ => new SiteScopeService(stubAuth)); + } + + private sealed class StubAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState _state; + public StubAuthStateProvider(AuthenticationState state) => _state = state; + public override Task GetAuthenticationStateAsync() + => Task.FromResult(_state); + } + + [Fact] + public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads() + { + RegisterServices(); + var cut = Render(); + cut.WaitForAssertion(() => + _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + _deployRepo.ClearReceivedCalls(); + + // A 40-instance site deploy: every status write raises the notifier. + for (var i = 0; i < 40; i++) + { + _notifier.NotifyStatusChanged( + new DeploymentStatusChange($"dep-{i}", 1, DeploymentStatus.InProgress)); + } + + // Leading edge fires at once; the rest ride one trailing reload. + cut.WaitForAssertion(() => + _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + + // Let the trailing window close before counting. + Thread.Sleep(900); + + var reloads = _deployRepo.ReceivedCalls() + .Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.GetAllDeploymentRecordsAsync)); + Assert.InRange(reloads, 1, 4); + } + + [Fact] + public void FirstPushAfterIdle_ReloadsImmediately() + { + RegisterServices(); + var cut = Render(); + cut.WaitForAssertion(() => + _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + _deployRepo.ClearReceivedCalls(); + + // Idle since the initial load — the leading edge must not wait out the window. + Thread.Sleep(600); + _notifier.NotifyStatusChanged( + new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)); + + cut.WaitForAssertion( + () => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any()), + TimeSpan.FromMilliseconds(400)); + } + + [Fact] + public void DisposeDuringACoalesceWindow_DoesNotReload() + { + RegisterServices(); + var cut = Render(); + cut.WaitForAssertion(() => + _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + + // Two pushes: the first takes the leading edge, the second arms the trailing timer. + _notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.InProgress)); + _notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-2", 1, DeploymentStatus.InProgress)); + + cut.Instance.Dispose(); + _deployRepo.ClearReceivedCalls(); + + // The armed timer must be disposed with the component, not fire against it. + Thread.Sleep(900); + _deployRepo.DidNotReceive().GetAllDeploymentRecordsAsync(Arg.Any()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs new file mode 100644 index 00000000..a51d9c9b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Security; +using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring; + +/// +/// Covers the Alarm Summary flat table's two rendering paths (arch-review WP2.4). A short +/// list renders every row with a plain foreach; past the threshold the same row template is +/// handed to Virtualize so a site with thousands of alarms stops materialising the +/// whole table into the circuit's render tree on every delta. Row markup — including the +/// data-test hook the operator tests key off — is identical either way. +/// +public class AlarmSummaryVirtualizeTests : BunitContext +{ + private readonly IAlarmSummaryService _summary = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + + private void Arrange(int rowCount) + { + JSInterop.Mode = JSRuntimeMode.Loose; + + var rows = Enumerable.Range(0, rowCount) + .Select(i => new AlarmSummaryRow( + $"inst-{i:D4}", + new AlarmStateChanged($"inst-{i:D4}", $"alarm-{i:D4}", AlarmState.Active, i, DateTimeOffset.UtcNow))) + .ToList(); + + _summary.GetSiteAlarmsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty()))); + _summary.ComputeRollup(Arg.Any>()) + .Returns(new AlarmRollup(rowCount, rowCount, 0, new Dictionary())); + Services.AddSingleton(_summary); + + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List + { + new("Site 1", "site1") { Id = 1 }, + })); + Services.AddSingleton(_siteRepo); + Services.AddSingleton(new InertLiveCache()); + + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "tester"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), + }; + Services.AddSingleton( + new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")))); + Services.AddAuthorizationCore(); + } + + private IRenderedComponent RenderWithSiteSelected() + { + var cut = Render(); + cut.Find("[data-test='alarm-summary-site']").Change("1"); + return cut; + } + + [Fact] + public void ShortList_RendersEveryRowInline() + { + Arrange(rowCount: 20); + + var cut = RenderWithSiteSelected(); + + cut.WaitForAssertion(() => + Assert.Equal(20, cut.FindAll("tr[data-test='alarm-summary-row']").Count)); + // The row-count line is unchanged by either path. + Assert.Contains("Showing 20 of 20", cut.Markup); + } + + [Fact] + public void LongList_VirtualizesWithoutRenderingEveryRow() + { + Arrange(rowCount: 2000); + + var cut = RenderWithSiteSelected(); + + cut.WaitForAssertion(() => Assert.Contains("Showing 2000 of 2000", cut.Markup)); + + var rendered = cut.FindAll("tr[data-test='alarm-summary-row']").Count; + Assert.InRange(rendered, 1, 1999); + // Rows still carry the same shape — instance name, alarm name, severity. + Assert.Contains("alarm-", cut.Markup); + } + + /// Never goes live, so the page keeps its poll snapshot for these tests. + private sealed class InertLiveCache : ISiteAlarmLiveCache + { + public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp(); + + public IReadOnlyList GetCurrentAlarms(int siteId) => + Array.Empty(); + + public bool IsLive(int siteId) => false; + + private sealed class NoOp : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index 04cf08e7..c7905911 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -64,6 +64,14 @@ public class HealthPageTests : BunitContext _comms.SetSiteCallAudit(siteCallAudit); Services.AddSingleton(_comms); + // arch-review WP2.4 — the page no longer queries CommunicationService / + // IAuditLogQueryService per circuit; it reads the process-level memoized + // KPI cache. The real cache is registered here (not a substitute) so the + // scripted-actor seam above is still what actually answers. + Services.AddSingleton(sp => new KpiSnapshotCache( + sp.GetRequiredService(), + sp.GetRequiredService())); + var aggregator = Substitute.For(); aggregator.GetAllSiteStates() .Returns(new Dictionary()); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index 8ee1f85c..4b393783 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -71,6 +71,13 @@ public class NotificationKpisPageTests : BunitContext Services.AddSingleton(_comms); + // arch-review WP2.4 — the page reads the process-level memoized KPI cache + // rather than querying CommunicationService per circuit. The real cache is + // registered so the scripted-actor seam above still answers the queries. + Services.AddSingleton(sp => new KpiSnapshotCache( + sp.GetRequiredService(), + sp.GetRequiredService())); + var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(new List diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs new file mode 100644 index 00000000..83c4873a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SharedAlarmSummaryServiceTests.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// Unit tests for (arch-review WP2.4). The Alarm +/// Summary page polls per circuit; the shared façade must collapse those into ONE per-site +/// fan-out per freshness window, and must widen that window while the live alarm cache is +/// serving the site (where the poll only still supplies the not-reporting list). +/// +public class SharedAlarmSummaryServiceTests : IDisposable +{ + private const int SiteId = 7; + private const string SiteIdentifier = "plant-a"; + + private static readonly DateTimeOffset T0 = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero); + + private readonly ITemplateEngineRepository _instanceRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly IInstanceSnapshotClient _snapshotClient = Substitute.For(); + private readonly FakeLiveCache _liveCache = new(); + private readonly ServiceProvider _provider; + + private DateTimeOffset _now = T0; + + public SharedAlarmSummaryServiceTests() + { + _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any()) + .Returns(new Site("Plant A", SiteIdentifier) { Id = SiteId }); + _instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()) + .Returns(new List + { + new("inst-a") { Id = 1, SiteId = SiteId, State = InstanceState.Enabled }, + }); + _snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-a", Arg.Any()) + .Returns(new DebugViewSnapshot( + "inst-a", + Array.Empty(), + new[] { new AlarmStateChanged("inst-a", "A-alarm", AlarmState.Active, 500, T0) }, + T0)); + + var services = new ServiceCollection(); + services.AddSingleton(_instanceRepo); + services.AddSingleton(_siteRepo); + services.AddSingleton(_snapshotClient); + services.AddScoped(); + _provider = services.BuildServiceProvider(); + } + + private SharedAlarmSummaryService CreateSut(TimeSpan liveCacheTtl) => + new(_provider.GetRequiredService(), _liveCache, liveCacheTtl, () => _now); + + [Fact] + public async Task ConcurrentCircuits_ShareOneFanOut() + { + var sut = CreateSut(TimeSpan.FromSeconds(60)); + + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => sut.GetSiteAlarmsAsync(SiteId))); + + await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(SiteId, Arg.Any()); + Assert.All(results, r => Assert.Single(r.Alarms)); + } + + [Fact] + public async Task ColdLiveCache_RefreshesWithinThePageTick() + { + var sut = CreateSut(TimeSpan.FromSeconds(60)); + _liveCache.Live = false; + + await sut.GetSiteAlarmsAsync(SiteId); + // The page polls every 15s and, while the cache is cold, the poll is its + // authoritative rebuild — so the memo must have expired by then. + _now = T0.AddSeconds(15); + await sut.GetSiteAlarmsAsync(SiteId); + + await _instanceRepo.Received(2).GetInstancesBySiteIdAsync(SiteId, Arg.Any()); + } + + [Fact] + public async Task LiveCacheServing_WidensTheWindowToTheReconcileInterval() + { + var sut = CreateSut(TimeSpan.FromSeconds(60)); + _liveCache.Live = true; + + await sut.GetSiteAlarmsAsync(SiteId); + _now = T0.AddSeconds(30); + await sut.GetSiteAlarmsAsync(SiteId); + + // Live deltas own the rows; only the not-reporting list still comes from the + // fan-out, so a 30s-old answer is fine and costs no second fan-out. + await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(SiteId, Arg.Any()); + + _now = T0.AddSeconds(61); + await sut.GetSiteAlarmsAsync(SiteId); + await _instanceRepo.Received(2).GetInstancesBySiteIdAsync(SiteId, Arg.Any()); + } + + [Fact] + public async Task DifferentSites_DoNotShareAMemoSlot() + { + const int otherSite = 8; + _siteRepo.GetSiteByIdAsync(otherSite, Arg.Any()) + .Returns(new Site("Plant B", "plant-b") { Id = otherSite }); + _instanceRepo.GetInstancesBySiteIdAsync(otherSite, Arg.Any()) + .Returns(new List()); + + var sut = CreateSut(TimeSpan.FromSeconds(60)); + + await sut.GetSiteAlarmsAsync(SiteId); + await sut.GetSiteAlarmsAsync(otherSite); + + await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(SiteId, Arg.Any()); + await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(otherSite, Arg.Any()); + } + + [Fact] + public void PureMethods_MatchTheDirectImplementation() + { + var sut = CreateSut(TimeSpan.FromSeconds(60)); + var direct = new AlarmSummaryService(_instanceRepo, _siteRepo, _snapshotClient); + var alarms = new List + { + new("inst-b", "B-alarm", AlarmState.Active, 900, T0), + new("inst-a", "A-alarm", AlarmState.Normal, 100, T0), + }; + + var shared = sut.BuildFromLiveAlarms(alarms); + var expected = direct.BuildFromLiveAlarms(alarms); + + Assert.Equal( + expected.Alarms.Select(r => r.Alarm.AlarmName), + shared.Alarms.Select(r => r.Alarm.AlarmName)); + var expectedRollup = direct.ComputeRollup(expected.Alarms); + var sharedRollup = sut.ComputeRollup(shared.Alarms); + Assert.Equal(expectedRollup.TotalActive, sharedRollup.TotalActive); + Assert.Equal(expectedRollup.WorstSeverity, sharedRollup.WorstSeverity); + Assert.Equal(expectedRollup.UnackedCount, sharedRollup.UnackedCount); + Assert.Equal(expectedRollup.CountsByKind, sharedRollup.CountsByKind); + } + + public void Dispose() => _provider.Dispose(); + + /// Liveness-only stub — the façade consults nothing else on the live cache. + private sealed class FakeLiveCache : ISiteAlarmLiveCache + { + public bool Live { get; set; } + + public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp(); + + public IReadOnlyList GetCurrentAlarms(int siteId) => + Array.Empty(); + + public bool IsLive(int siteId) => Live; + + private sealed class NoOp : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs new file mode 100644 index 00000000..0dd5ad5a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs @@ -0,0 +1,166 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// Unit tests for — the primitive behind the process-level +/// KPI snapshot cache and the shared Alarm Summary fan-out (arch-review WP2.4). The two +/// properties the whole optimisation rests on are tested here: single-flight (N +/// concurrent circuits asking the same question produce ONE query) and TTL (a query +/// answered inside the freshness window is served from memory, and one outside it is not). +/// +public class SingleFlightMemoTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task ConcurrentCallers_ShareOneFlight() + { + var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(10), () => T0); + + // Ten circuits ask while the first flight is still in progress. + var waiters = Enumerable.Range(0, 10) + .Select(_ => memo.GetAsync(() => + { + Interlocked.Increment(ref invocations); + return released.Task; + })) + .ToArray(); + + released.SetResult(42); + var results = await Task.WhenAll(waiters); + + Assert.Equal(1, invocations); + Assert.Equal(1, memo.FlightCount); + Assert.All(results, r => Assert.Equal(42, r)); + } + + [Fact] + public async Task WithinTtl_ServesTheMemoizedValue_WithoutRequerying() + { + var now = T0; + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(8), () => now); + + var first = await memo.GetAsync(() => Task.FromResult(++invocations)); + + now = T0.AddSeconds(7); + var second = await memo.GetAsync(() => Task.FromResult(++invocations)); + + Assert.Equal(1, first); + Assert.Equal(1, second); + Assert.Equal(1, memo.FlightCount); + } + + [Fact] + public async Task AfterTtl_RequeriesOnce() + { + var now = T0; + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(8), () => now); + + await memo.GetAsync(() => Task.FromResult(++invocations)); + + now = T0.AddSeconds(9); + var refreshed = await memo.GetAsync(() => Task.FromResult(++invocations)); + + Assert.Equal(2, refreshed); + Assert.Equal(2, memo.FlightCount); + } + + [Fact] + public async Task ForceRefresh_BypassesAFreshValue() + { + var now = T0; + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => now); + + await memo.GetAsync(() => Task.FromResult(++invocations)); + var forced = await memo.GetAsync(() => Task.FromResult(++invocations), forceRefresh: true); + + Assert.Equal(2, forced); + Assert.Equal(2, memo.FlightCount); + } + + [Fact] + public async Task ForceRefresh_StillJoinsAnInFlightRound() + { + var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => T0); + + var first = memo.GetAsync(() => + { + Interlocked.Increment(ref invocations); + return released.Task; + }); + // A burst of operator "Refresh" clicks must not multiply the query. + var forcedA = memo.GetAsync(() => Task.FromResult(-1), forceRefresh: true); + var forcedB = memo.GetAsync(() => Task.FromResult(-1), forceRefresh: true); + + released.SetResult(7); + Assert.Equal(7, await first); + Assert.Equal(7, await forcedA); + Assert.Equal(7, await forcedB); + Assert.Equal(1, invocations); + } + + [Fact] + public async Task Failure_IsNotMemoized_AndTheNextCallerRetries() + { + var now = T0; + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => now); + + await Assert.ThrowsAsync(() => + memo.GetAsync(() => + { + invocations++; + return Task.FromException(new InvalidOperationException("KPI query failed")); + })); + + // Same instant, well inside the TTL — a cached fault would starve the tiles for a + // whole window, so the next caller must produce a new flight. + var recovered = await memo.GetAsync(() => Task.FromResult(++invocations)); + + Assert.Equal(2, recovered); + Assert.Equal(2, memo.FlightCount); + } + + [Fact] + public async Task TtlOverride_AppliesToThatFlight() + { + var now = T0; + var invocations = 0; + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(5), () => now); + + // Produced under a 60s override — still fresh 30s later despite the 5s default. + await memo.GetAsync(() => Task.FromResult(++invocations), ttlOverride: TimeSpan.FromSeconds(60)); + + now = T0.AddSeconds(30); + var second = await memo.GetAsync(() => Task.FromResult(++invocations)); + + Assert.Equal(1, second); + Assert.Equal(1, memo.FlightCount); + } + + [Fact] + public async Task CallerCancellation_DoesNotCancelTheSharedFlight() + { + var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => T0); + + using var giveUp = new CancellationTokenSource(); + var impatient = memo.GetAsync(() => released.Task, cancellationToken: giveUp.Token); + var patient = memo.GetAsync(() => Task.FromResult(-1)); + + giveUp.Cancel(); + await Assert.ThrowsAnyAsync(() => impatient); + + // The abandoned wait must not have taken the round down with it. + released.SetResult(99); + Assert.Equal(99, await patient); + } +}