refactor(deployment): move PendingUpdatesCache to DeploymentManager so the CLI shares it (ScadaBridge#91)
`health pending-updates` (#63) called IPendingUpdatesEvaluator.EvaluateAsync directly from ManagementActor because the cache lived in CentralUI, so every CLI call was a full uncached fleet flatten. IPendingUpdatesCache/PendingUpdatesCache now live in DeploymentManager.PendingUpdates and are registered by AddDeploymentManager; the SingleFlightMemo<T> primitive moves to Commons.Caching (public) so both CentralUI's KPI / alarm-summary caches and DeploymentManager can use it. HandleGetPendingUpdates reads the shared cache; the UI circuits and the CLI now share one memo per node. Tests: ManagementActor repeated-calls test (fails against the direct evaluator call: 2 evaluations instead of 1), AddDeploymentManager singleton-registration test; the cache and memo tests move with their types. (ScadaBridge#91) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -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<PendingUpdatesSnapshot>` 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<T>`, 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*).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -120,9 +120,8 @@ public static class ServiceCollectionExtensions
|
||||
// circuit for N dashboards to share a single SQL round per refresh window.
|
||||
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
|
||||
|
||||
// Same single-flight TTL shape as IKpiSnapshotCache, over the fleet-wide
|
||||
// pending-updates flatten (IPendingUpdatesEvaluator) instead of the KPI Asks.
|
||||
services.AddSingleton<IPendingUpdatesCache, PendingUpdatesCache>();
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The memoized value type.</typeparam>
|
||||
internal sealed class SingleFlightMemo<T>
|
||||
public sealed class SingleFlightMemo<T>
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeSpan _ttl;
|
||||
+10
-9
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level memoized fleet-wide pending-updates snapshot shared by every Blazor circuit
|
||||
/// on this node — the same single-flight TTL shape as <see cref="IKpiSnapshotCache"/>, applied
|
||||
/// to <see cref="IPendingUpdatesEvaluator"/>.
|
||||
/// 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 <c>GetPendingUpdatesCommand</c> (CLI <c>health pending-updates</c>) — the same
|
||||
/// single-flight TTL shape as the Central UI's KPI snapshot cache, applied to
|
||||
/// <see cref="IPendingUpdatesEvaluator"/>. It lives in DeploymentManager rather than CentralUI so
|
||||
/// ManagementService can share it (Gitea #91): before, every CLI call ran a full uncached flatten.
|
||||
/// <para>
|
||||
/// <see cref="IPendingUpdatesEvaluator.EvaluateAsync"/> 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
|
||||
/// <c>DeploymentManagerOptions.PendingUpdatesCacheTtl</c> (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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A failed evaluation is never memoized: the fault is surfaced to every caller of that flight,
|
||||
+2
-3
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IPendingUpdatesCache"/> — a DI <b>singleton</b> holding one
|
||||
@@ -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<IPendingUpdatesEvaluator, PendingUpdatesEvaluator>();
|
||||
|
||||
// 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<IPendingUpdatesCache, PendingUpdatesCache>();
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -15,6 +15,10 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
|
||||
@@ -3186,8 +3186,10 @@ public class ManagementActor : ReceiveActor
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleGetPendingUpdates(IServiceProvider sp, AuthenticatedUser user)
|
||||
{
|
||||
var evaluator = sp.GetRequiredService<IPendingUpdatesEvaluator>();
|
||||
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<IPendingUpdatesCache>().GetAsync();
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permitted = new HashSet<string>(user.PermittedSiteIds);
|
||||
|
||||
@@ -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<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
var pending = Substitute.For<IPendingUpdatesCache>();
|
||||
var pending = Substitute.For<ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesCache>();
|
||||
pending.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(
|
||||
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SingleFlightMemo{T}"/> — the primitive behind the process-level
|
||||
+2
-3
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PendingUpdatesCache"/> — the same single-flight TTL contract as
|
||||
/// <see cref="SingleFlightMemoTests"/> proves for the primitive, exercised here through the
|
||||
/// <c>SingleFlightMemoTests</c> (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,
|
||||
/// <c>forceRefresh</c> bypasses a fresh value but still joins an in-flight round, and a
|
||||
+22
@@ -81,4 +81,26 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.IsType<DeploymentStatusNotifier>(fromRoot);
|
||||
Assert.Same(fromRoot, fromScope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDeploymentManager_RegistersPendingUpdatesCache_AsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var fromRoot = provider.GetRequiredService<PendingUpdates.IPendingUpdatesCache>();
|
||||
using var scope = provider.CreateScope();
|
||||
var fromScope = scope.ServiceProvider.GetRequiredService<PendingUpdates.IPendingUpdatesCache>();
|
||||
|
||||
Assert.IsType<PendingUpdates.PendingUpdatesCache>(fromRoot);
|
||||
Assert.Same(fromRoot, fromScope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator>();
|
||||
evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).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<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>(
|
||||
new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance));
|
||||
_services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesCache,
|
||||
ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesCache>();
|
||||
return evaluator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #91: repeated CLI calls inside one cache TTL share the process-wide
|
||||
/// pending-updates memo instead of each running a full fleet flatten.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetPendingUpdates_RepeatedCalls_ShareOneFleetEvaluation()
|
||||
{
|
||||
var evaluator = RegisterPendingUpdates();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Gitea #63: a system-wide Deployer gets fleet totals.</summary>
|
||||
|
||||
Reference in New Issue
Block a user