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:
Joseph Doherty
2026-11-09 13:30:38 -05:00
parent c7d868c52e
commit 1ee2e171cf
16 changed files with 102 additions and 33 deletions
@@ -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;
@@ -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;
@@ -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,
@@ -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;
}
@@ -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);