diff --git a/docs/requirements/Component-DeploymentManager.md b/docs/requirements/Component-DeploymentManager.md index d8b0720f..683c6c2c 100644 --- a/docs/requirements/Component-DeploymentManager.md +++ b/docs/requirements/Component-DeploymentManager.md @@ -551,14 +551,20 @@ the evaluator swallows an exception: a faulted evaluation propagates so the cache's single-flight memo discards the flight and the Central UI renders the aggregate as unavailable, rather than manufacturing a false "up to date". -The Central UI holds the snapshot behind a process-wide singleton -`IPendingUpdatesCache` (`PendingUpdatesCache`, mirroring `KpiSnapshotCache`): one +The snapshot is held behind a process-wide singleton `IPendingUpdatesCache` +(`PendingUpdatesCache`, `DeploymentManager.PendingUpdates`, registered by +`AddDeploymentManager`, mirroring the Central UI's `KpiSnapshotCache`). It lives +here rather than in CentralUI so the Central UI circuits **and** the management +API's `GetPendingUpdatesCommand` (CLI `health pending-updates`) share one memo +(Gitea #91 — before, every CLI call ran a full uncached flatten). It is one `SingleFlightMemo` keyed by `ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl` (default 60 s, validated positive by `DeploymentManagerOptionsValidator`), each flight running the -evaluator in its own DI scope. However many Blazor circuits poll +evaluator in its own DI scope (the primitive, `SingleFlightMemo`, lives in +Commons `Caching`). However many Blazor circuits or CLI calls hit `GetAsync(forceRefresh)`, the fleet is flattened **at most once per TTL**, and not -at all when no Deployer/Admin circuit is open. See +at all when nobody asks. A deployment that finishes on this node drops the memo +at once; one run through the peer central clears within the TTL. See `docs/plans/2026-09-14-pending-updates-design.md` for the full design, including the Sites-page badge/re-check UI and the rail-footer indicator (Component-CentralUI.md → *Site & Data Connection Management* and *Technology*). diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index e343e359..776e7568 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -227,7 +227,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B - **GetHealthSummary**: Query current health status of all sites. - **GetSiteHealth**: Query detailed health for a specific site. -- **GetPendingUpdates**: Fleet-wide pending-updates counts (stale instances / sites with stale artifacts) with a per-site breakdown. Deployer role; site-scoped callers see only their permitted sites (Gitea #63). +- **GetPendingUpdates**: Fleet-wide pending-updates counts (stale instances / sites with stale artifacts) with a per-site breakdown. Deployer role; site-scoped callers see only their permitted sites (Gitea #63). Served from the same process-wide `IPendingUpdatesCache` the Central UI reads, so repeated calls inside the cache TTL cost one fleet flatten (Gitea #91). ### Remote Queries diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 0a20c9a7..ef57df59 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -120,9 +120,8 @@ 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(); + // IPendingUpdatesCache (the fleet-wide pending-updates memo) is registered by + // AddDeploymentManager, not here, so the management API shares it (Gitea #91). // 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/KpiSnapshotCache.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs index f18b56d7..c73d0d1f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiSnapshotCache.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Caching; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs index 3156ef6a..e16f5afa 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SharedAlarmSummaryService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Caching; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Communication; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Caching/SingleFlightMemo.cs similarity index 98% rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs rename to src/ZB.MOM.WW.ScadaBridge.Commons/Caching/SingleFlightMemo.cs index 11bbe3fb..a772a363 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SingleFlightMemo.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Caching/SingleFlightMemo.cs @@ -1,4 +1,4 @@ -namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; +namespace ZB.MOM.WW.ScadaBridge.Commons.Caching; /// /// A process-level memo slot: one value, a time-to-live, and single-flight production. @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// /// /// The memoized value type. -internal sealed class SingleFlightMemo +public sealed class SingleFlightMemo { private readonly object _lock = new(); private readonly TimeSpan _ttl; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesCache.cs similarity index 71% rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs rename to src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesCache.cs index c8a27ef3..69489023 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IPendingUpdatesCache.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesCache.cs @@ -1,18 +1,19 @@ -using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; - -namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; /// -/// 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 . +/// Process-level memoized fleet-wide pending-updates snapshot shared by every consumer on this +/// central node — every Blazor circuit (rail indicator, Sites page, Health tiles) AND the +/// management API's GetPendingUpdatesCommand (CLI health pending-updates) — the same +/// single-flight TTL shape as the Central UI's KPI snapshot cache, applied to +/// . It lives in DeploymentManager rather than CentralUI so +/// ManagementService can share it (Gitea #91): before, every CLI call ran a full uncached flatten. /// /// 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 +/// Deployer/Administrator circuits watching the rail indicator or the Sites page, plus any CLI +/// caller, would each re-flatten the fleet. 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. +/// callers 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, diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesCache.cs similarity index 97% rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs rename to src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesCache.cs index 86587b99..91b4df54 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PendingUpdatesCache.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesCache.cs @@ -1,10 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; -using ZB.MOM.WW.ScadaBridge.DeploymentManager; -using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; +using ZB.MOM.WW.ScadaBridge.Commons.Caching; -namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; /// /// Default — a DI singleton holding one diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs index 297fccc8..395a49de 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs @@ -90,9 +90,15 @@ public static class ServiceCollectionExtensions // Fleet-wide "is anything waiting to be deployed?" aggregate behind the // Central UI rail indicator and the Sites-page artifact badges. Scoped because // it reads through the same per-request DbContext as the services it composes; - // the Central UI's singleton single-flight cache runs it in its own DI scope so - // the fleet is flattened at most once per cache TTL. + // the singleton single-flight cache below runs it in its own DI scope so the + // fleet is flattened at most once per cache TTL. services.AddScoped(); + + // Process-wide memo over the evaluator, shared by the Central UI (rail indicator, + // Sites page, Health tiles) and the management API's GetPendingUpdatesCommand (CLI + // `health pending-updates`), so neither runs an uncached fleet flatten per call + // (Gitea #91). Singleton: the memo must outlive any one circuit or request. + services.AddSingleton(); return services; } diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj index c75024c4..7d06e988 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index b3875b1c..d8c20085 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -3186,8 +3186,10 @@ public class ManagementActor : ReceiveActor /// private static async Task HandleGetPendingUpdates(IServiceProvider sp, AuthenticatedUser user) { - var evaluator = sp.GetRequiredService(); - var snapshot = await evaluator.EvaluateAsync(); + // The process-wide memo the Central UI also reads (Gitea #91): N CLI calls inside one + // cache TTL cost at most one fleet flatten, and a finished deployment on this node + // drops the memo so the CLI never reports a pre-deploy count after a local deploy. + var snapshot = await sp.GetRequiredService().GetAsync(); if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) { var permitted = new HashSet(user.PermittedSiteIds); 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 437c11a9..ee40266c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -137,7 +137,7 @@ public class HealthPageTests : BunitContext // Gitea #63: the pending-updates tiles read the shared fleet cache and size scope via // SiteScopeService. The signed-in test user is an Administrator. Services.AddScoped(); - var pending = Substitute.For(); + var pending = Substitute.For(); pending.GetAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult( new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot( DateTimeOffset.UtcNow, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Caching/SingleFlightMemoTests.cs similarity index 98% rename from tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs rename to tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Caching/SingleFlightMemoTests.cs index aaa5802d..d8f064dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/SingleFlightMemoTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Caching/SingleFlightMemoTests.cs @@ -1,6 +1,6 @@ -using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Caching; -namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Caching; /// /// Unit tests for — the primitive behind the process-level diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesCacheTests.cs similarity index 98% rename from tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs rename to tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesCacheTests.cs index 858d33ee..abbfb723 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/PendingUpdatesCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesCacheTests.cs @@ -3,15 +3,14 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; -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; +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Unit tests for — the same single-flight TTL contract as -/// proves for the primitive, exercised here through the +/// SingleFlightMemoTests (Commons.Tests) 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs index cd6bcf36..efbf72b5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ServiceCollectionExtensionsTests.cs @@ -81,4 +81,26 @@ public class ServiceCollectionExtensionsTests Assert.IsType(fromRoot); Assert.Same(fromRoot, fromScope); } + + /// + /// Gitea #91: the pending-updates cache is registered by AddDeploymentManager (not the + /// Central UI) as ONE process-wide singleton, so the UI circuits and the management API's + /// GetPendingUpdatesCommand share a single memo. + /// + [Fact] + public void AddDeploymentManager_RegistersPendingUpdatesCache_AsSingleton() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDeploymentManager(); + + using var provider = services.BuildServiceProvider(); + + var fromRoot = provider.GetRequiredService(); + using var scope = provider.CreateScope(); + var fromScope = scope.ServiceProvider.GetRequiredService(); + + Assert.IsType(fromRoot); + Assert.Same(fromRoot, fromScope); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 6d8fdcbf..7f4b823c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1848,7 +1848,7 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } - private void RegisterPendingUpdates() + private ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator RegisterPendingUpdates() { var evaluator = Substitute.For(); evaluator.EvaluateAsync(Arg.Any()).Returns(Task.FromResult( @@ -1859,6 +1859,35 @@ public class ManagementActorTests : TestKit, IDisposable new(2, "S2", "Site 2", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.Current, null, 10, 6, 0), ]))); _services.AddScoped(_ => evaluator); + + // The real process-wide cache the Host registers via AddDeploymentManager (Gitea #91). + _services.AddSingleton(Microsoft.Extensions.Options.Options.Create( + new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentManagerOptions())); + _services.AddSingleton( + new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier( + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + _services.AddSingleton< + ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesCache, + ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesCache>(); + return evaluator; + } + + /// + /// Gitea #91: repeated CLI calls inside one cache TTL share the process-wide + /// pending-updates memo instead of each running a full fleet flatten. + /// + [Fact] + public void GetPendingUpdates_RepeatedCalls_ShareOneFleetEvaluation() + { + var evaluator = RegisterPendingUpdates(); + var actor = CreateActor(); + + actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + evaluator.Received(1).EvaluateAsync(Arg.Any()); } /// Gitea #63: a system-wide Deployer gets fleet totals.