diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 0adab83e..65a01c5d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -120,6 +120,10 @@ public static class ServiceCollectionExtensions // circuit for N dashboards to share a single SQL round per refresh window. services.AddSingleton(); + // Same single-flight TTL shape as IKpiSnapshotCache, over the fleet-wide + // pending-updates flatten (IPendingUpdatesEvaluator) instead of the KPI Asks. + services.AddSingleton(); + // Secured Writes: dispatches the two-person secured-write commands // (submit / approve / reject / list) to the central ManagementActor through the // in-process ManagementActorHolder seam — the same Ask path the HTTP /management diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs new file mode 100644 index 00000000..6a47f691 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Process-level memoized fleet-wide pending-updates snapshot shared by every Blazor circuit +/// on this node — the same single-flight TTL shape as , applied +/// to . +/// +/// flattens every site and every deployed +/// instance, so it is a whole-fleet read, not a per-circuit one. Without memoization, N +/// Deployer/Administrator circuits watching the rail indicator or the Sites page would each +/// re-flatten the fleet on their own poll. This cache collapses them: the freshness window is +/// DeploymentManagerOptions.PendingUpdatesCacheTtl (default 60s), and however many +/// circuits ask inside one window, the fleet is evaluated at most once. +/// +/// +/// A failed evaluation is never memoized: the fault is surfaced to every caller of that flight, +/// and the next caller starts a fresh one rather than re-serving the fault for the rest of the +/// TTL window. +/// +/// +public interface IPendingUpdatesCache +{ + /// + /// Returns the memoized fleet-wide pending-updates snapshot, evaluating a fresh one if none + /// is still within the TTL window. + /// + /// + /// When , discards a still-fresh value and starts a new evaluation + /// (the operator's "Re-check" action). An evaluation already in flight is still joined + /// rather than duplicated, so a burst of Re-check clicks remains one fleet flatten. + /// + /// Cancels this caller's wait only, never the shared flight. + /// The memoized (or freshly evaluated) fleet-wide snapshot. + Task GetAsync(bool forceRefresh = false, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs new file mode 100644 index 00000000..a7d3a13d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default — a DI singleton holding one +/// over . +/// +/// Registered as a singleton on purpose: the memo has to outlive any one circuit for N +/// dashboards to share a single fleet flatten per refresh window. It depends only on +/// — the evaluator is scoped (it opens repositories), so +/// every flight resolves it from a fresh scope, off any circuit-scoped context. +/// +/// +public sealed class PendingUpdatesCache : IPendingUpdatesCache +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly SingleFlightMemo _memo; + + /// + /// Initializes the shared pending-updates cache. + /// + /// Opens a fresh DI scope per fleet evaluation. + /// Supplies . + public PendingUpdatesCache(IServiceScopeFactory scopeFactory, IOptions options) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + ArgumentNullException.ThrowIfNull(options); + + _memo = new SingleFlightMemo(options.Value.PendingUpdatesCacheTtl); + } + + /// + public Task GetAsync( + bool forceRefresh = false, CancellationToken cancellationToken = default) => + _memo.GetAsync(EvaluateAsync, forceRefresh, cancellationToken); + + private async Task EvaluateAsync() + { + await using var scope = _scopeFactory.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().EvaluateAsync(); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs new file mode 100644 index 00000000..d98d70aa --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs @@ -0,0 +1,129 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// Unit tests for — the same single-flight TTL contract as +/// proves for the primitive, exercised here through the +/// full DI-scope-per-flight shape: N concurrent callers before the evaluator resolves must +/// share ONE fleet flatten, a value produced inside the TTL is served without re-evaluating, +/// forceRefresh bypasses a fresh value but still joins an in-flight round, and a +/// faulted evaluation is never memoized. +/// +public class PendingUpdatesCacheTests : IDisposable +{ + private static readonly DateTimeOffset T0 = new(2026, 9, 14, 9, 0, 0, TimeSpan.Zero); + + private readonly IPendingUpdatesEvaluator _evaluator = Substitute.For(); + private readonly ServiceProvider _provider; + + public PendingUpdatesCacheTests() + { + var services = new ServiceCollection(); + services.AddScoped(_ => _evaluator); + _provider = services.BuildServiceProvider(); + } + + // A generous TTL (1h) so every test's timing is driven by task completion, not the clock. + private static IOptions Options(TimeSpan? ttl = null) => + Microsoft.Extensions.Options.Options.Create( + new DeploymentManagerOptions { PendingUpdatesCacheTtl = ttl ?? TimeSpan.FromHours(1) }); + + private PendingUpdatesCache CreateSut(TimeSpan? ttl = null) => + new(_provider.GetRequiredService(), Options(ttl)); + + private static PendingUpdatesSnapshot Snapshot() => + new(T0, Array.Empty()); + + [Fact] + public async Task ConcurrentCallers_ShareOneEvaluation() + { + var released = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _evaluator.EvaluateAsync(Arg.Any()).Returns(released.Task); + var sut = CreateSut(); + + var waiters = Enumerable.Range(0, 10).Select(_ => sut.GetAsync()).ToArray(); + released.SetResult(Snapshot()); + var results = await Task.WhenAll(waiters); + + await _evaluator.Received(1).EvaluateAsync(Arg.Any()); + Assert.All(results, r => Assert.Same(results[0], r)); + } + + [Fact] + public async Task WithinTtl_ServesTheMemoizedValue_WithoutReEvaluating() + { + _evaluator.EvaluateAsync(Arg.Any()).Returns(Snapshot()); + var sut = CreateSut(); + + var first = await sut.GetAsync(); + var second = await sut.GetAsync(); + + await _evaluator.Received(1).EvaluateAsync(Arg.Any()); + Assert.Same(first, second); + } + + [Fact] + public async Task ForceRefresh_BypassesAFreshValue() + { + var snapshotA = Snapshot(); + var snapshotB = new PendingUpdatesSnapshot(T0.AddMinutes(1), Array.Empty()); + _evaluator.EvaluateAsync(Arg.Any()).Returns(snapshotA, snapshotB); + var sut = CreateSut(); + + var first = await sut.GetAsync(); + var forced = await sut.GetAsync(forceRefresh: true); + + await _evaluator.Received(2).EvaluateAsync(Arg.Any()); + Assert.Same(snapshotA, first); + Assert.Same(snapshotB, forced); + } + + [Fact] + public async Task ForceRefresh_StillJoinsAnInFlightRound() + { + var released = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _evaluator.EvaluateAsync(Arg.Any()).Returns(released.Task); + var sut = CreateSut(); + + var first = sut.GetAsync(); + var forcedA = sut.GetAsync(forceRefresh: true); + var forcedB = sut.GetAsync(forceRefresh: true); + + var snapshot = Snapshot(); + released.SetResult(snapshot); + + Assert.Same(snapshot, await first); + Assert.Same(snapshot, await forcedA); + Assert.Same(snapshot, await forcedB); + await _evaluator.Received(1).EvaluateAsync(Arg.Any()); + } + + [Fact] + public async Task FailedEvaluation_SurfacesToTheCaller_AndIsNotMemoized() + { + _evaluator.EvaluateAsync(Arg.Any()) + .Returns( + Task.FromException(new InvalidOperationException("flatten failed")), + Task.FromResult(Snapshot())); + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => sut.GetAsync()); + + // Same instant, well inside the TTL — a cached fault would starve every dashboard + // for the rest of the window, so the next caller must start a new evaluation. + var recovered = await sut.GetAsync(); + + Assert.NotNull(recovered); + await _evaluator.Received(2).EvaluateAsync(Arg.Any()); + } + + public void Dispose() => _provider.Dispose(); +}