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
@@ -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,
@@ -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
@@ -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
@@ -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>