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);
+ }
+}