a4806576af
Owner decision (b), 2026-11-09. PendingUpdatesEvaluator re-flattened every deployed instance on each cache-TTL expiry even when nothing had changed. - Migration AddConfigGraphRowVersions: a non-concurrency rowversion column + index on the 18 configuration tables the evaluation reads (Templates + 5 member tables, Instances + 4 override/binding tables, DataConnections, DeployedConfigSnapshots, Sites, SharedScripts, ExternalSystemDefinitions, ExternalSystemMethods, DatabaseConnectionDefinitions). SQL Server only in the model; SQLite test contexts are untouched. Script: docs/plans/sql/AddConfigGraphRowVersions.sql. Joins the coordinated central-pair migration window (#107 item 4). - IConfigGraphWatermarkReader / ConfigGraphWatermarkReader: ONE query of MAX(RowVersion) + COUNT_BIG(*) per table (count catches deletes, which raise no rowversion). rowversion is engine-bumped for any writer, so a peer central node's edit moves it — not the per-process watermark MG-3 removed. - The secret store (outside the DB) gets its own stamp: SHA-256 over ListAsync(includeDeleted: true) metadata. - Evaluator re-serves the last CLEAN snapshot while watermark + secret stamp are unchanged; every PendingUpdatesFullEvaluationEvery'th (default 10, validated >= 1, 1 = off) is full regardless — the safety net for a same-KEK re-wrap or an input table missing from the set. Always full when the token is unavailable/unreadable, after an evaluation that could not compute a site's artifact hash, on Re-check, and after a deploy completion. It only re-serves a real earlier answer, never manufactures one. Tests: SQL Server (real migrated DB) — token stable without writes, moves for a peer-connection INSERT/UPDATE and a non-max DELETE, moves for a local EF write, a detached Update still saves (not a concurrency token), and a guard that every table the evaluator's repository reads touch is covered. Evaluator — reuse without flatten (re-served answer still shows the stale instance), watermark move, 4 secret changes, every-Nth, 5 no-token modes, unclean evaluation not reused, cache Re-check and deploy completion force full. Mutation-proven: token compare, clean check, cache force flag, row count, and an uncovered read each turn tests red. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
255 lines
13 KiB
C#
255 lines
13 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #62: <see cref="PendingUpdatesEvaluator"/> skips the fleet flatten while the
|
|
/// database-side config watermark and the secret-store stamp are unchanged, and never
|
|
/// otherwise. "Did a full evaluation happen" is observed as a call to the flatten seam
|
|
/// (<see cref="IFlatteningPipeline.ComputeRevisionHashAsync"/>) — the cost being avoided.
|
|
/// The watermark here is a substitute; the real reader's peer-node detection is proven
|
|
/// against SQL Server in <c>ConfigGraphWatermarkReaderTests</c>.
|
|
/// </summary>
|
|
public class PendingUpdatesShortCircuitTests
|
|
{
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
|
private readonly IConfigGraphWatermarkReader _watermark = Substitute.For<IConfigGraphWatermarkReader>();
|
|
private readonly ISecretStore _secrets = Substitute.For<ISecretStore>();
|
|
private readonly PendingUpdatesShortCircuitState _state = new();
|
|
private string _token = "wm-1";
|
|
private List<SecretMetadata> _secretRows = [Secret("db-pass", revision: 1)];
|
|
|
|
/// <summary>Wires one site with one deployed, up-to-date instance.</summary>
|
|
public PendingUpdatesShortCircuitTests()
|
|
{
|
|
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns([new Site("Alpha", "site-a") { Id = 1 }]);
|
|
_deploymentRepo.GetDeployedInstanceSummariesAsync(Arg.Any<CancellationToken>())
|
|
.Returns([new Instance("Inst7") { Id = 7, SiteId = 1, State = InstanceState.Enabled }]);
|
|
_deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<int, string> { [7] = "h7" });
|
|
_pipeline.ComputeRevisionHashAsync(7, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
|
|
.Returns(Result<string>.Success("h7"));
|
|
_watermark.ReadAsync(Arg.Any<CancellationToken>()).Returns(_ => _token);
|
|
_secrets.ListAsync(true, Arg.Any<CancellationToken>()).Returns(_ => (IReadOnlyList<SecretMetadata>)_secretRows);
|
|
}
|
|
|
|
private static SecretMetadata Secret(string name, long revision, string kek = "kek-1") => new()
|
|
{
|
|
Name = new SecretName(name),
|
|
ContentType = SecretContentType.Text,
|
|
KekId = kek,
|
|
Revision = revision,
|
|
CreatedUtc = new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero),
|
|
UpdatedUtc = new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero).AddMinutes(revision),
|
|
};
|
|
|
|
private PendingUpdatesEvaluator Evaluator(int every = 10, bool wired = true)
|
|
{
|
|
var comms = new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance);
|
|
var options = Options.Create(new DeploymentManagerOptions { PendingUpdatesFullEvaluationEvery = every });
|
|
var artifacts = new ArtifactDeploymentService(
|
|
_siteRepo, _deploymentRepo, Substitute.For<ITemplateEngineRepository>(), Substitute.For<IExternalSystemRepository>(),
|
|
comms, Substitute.For<IAuditService>(), options, NullLogger<ArtifactDeploymentService>.Instance,
|
|
new ArtifactRevisionHashService(), new SiteSecretsCollector(_secrets));
|
|
var deployments = new DeploymentService(
|
|
_deploymentRepo, _siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms,
|
|
new OperationLockManager(), Substitute.For<IAuditService>(), new DiffService(), new RevisionHashService(),
|
|
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
|
options, Options.Create(new CommunicationOptions()), NullLogger<DeploymentService>.Instance);
|
|
return wired
|
|
? new PendingUpdatesEvaluator(_siteRepo, _deploymentRepo, artifacts, deployments,
|
|
NullLogger<PendingUpdatesEvaluator>.Instance, _watermark, _secrets, _state, options)
|
|
: new PendingUpdatesEvaluator(_siteRepo, _deploymentRepo, artifacts, deployments,
|
|
NullLogger<PendingUpdatesEvaluator>.Instance);
|
|
}
|
|
|
|
private int FlattenCalls() => _pipeline.ReceivedCalls()
|
|
.Count(c => c.GetMethodInfo().Name == nameof(IFlatteningPipeline.ComputeRevisionHashAsync));
|
|
|
|
/// <summary>Verifies that an unchanged watermark and secret stamp re-serve the snapshot without flattening.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Unchanged_ReservesTheSnapshot_WithoutAFleetFlatten()
|
|
{
|
|
// A stale instance, so the re-served answer is visibly "something pending", not a blank one.
|
|
_pipeline.ComputeRevisionHashAsync(7, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
|
|
.Returns(Result<string>.Success("h7-edited"));
|
|
_deploymentRepo.GetDeployedSnapshotsByInstanceIdsAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<int, Commons.Entities.Deployment.DeployedConfigSnapshot>
|
|
{
|
|
[7] = new("d", "h7", """{"InstanceUniqueName":"Inst7"}""") { InstanceId = 7 },
|
|
});
|
|
var evaluator = Evaluator();
|
|
|
|
var first = await evaluator.EvaluateAsync();
|
|
var second = await evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(1, FlattenCalls());
|
|
Assert.Equal(1, first.StaleInstanceCount);
|
|
Assert.Equal(first.Sites, second.Sites);
|
|
Assert.True(second.HasPendingUpdates);
|
|
Assert.True(second.ComputedAt >= first.ComputedAt);
|
|
}
|
|
|
|
/// <summary>Verifies that a moved database watermark (e.g. a peer-node edit) forces a full evaluation.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task WatermarkMoved_ForcesAFullEvaluation()
|
|
{
|
|
var evaluator = Evaluator();
|
|
await evaluator.EvaluateAsync();
|
|
|
|
_token = "wm-2"; // the shared DB's rowversion/count moved — whichever node wrote it
|
|
await evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(2, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>Verifies that a secret-store change (revision, tombstone, re-wrap under a new KEK) forces a full evaluation.</summary>
|
|
/// <param name="change">Which secret change to apply.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("revision")]
|
|
[InlineData("added")]
|
|
[InlineData("deleted")]
|
|
[InlineData("kek")]
|
|
public async Task SecretStoreChanged_ForcesAFullEvaluation(string change)
|
|
{
|
|
var evaluator = Evaluator();
|
|
await evaluator.EvaluateAsync();
|
|
|
|
_secretRows = change switch
|
|
{
|
|
"revision" => [Secret("db-pass", revision: 2)],
|
|
"added" => [Secret("db-pass", revision: 1), Secret("api-key", revision: 1)],
|
|
"deleted" => [Secret("db-pass", revision: 1) with { IsDeleted = true }],
|
|
_ => [Secret("db-pass", revision: 1, kek: "kek-2")],
|
|
};
|
|
await evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(2, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>Verifies that every Nth evaluation is full even when nothing changed (the safety net).</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task EveryNthEvaluation_IsFull_EvenWhenUnchanged()
|
|
{
|
|
var evaluator = Evaluator(every: 3);
|
|
|
|
for (var i = 0; i < 7; i++)
|
|
await evaluator.EvaluateAsync();
|
|
|
|
// Evaluations 1, 4 and 7 are full; 2, 3, 5, 6 reuse.
|
|
Assert.Equal(3, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>Verifies that an unavailable watermark, a failing secret-store listing, or N=1 never short-circuit.</summary>
|
|
/// <param name="mode">Which unavailability to simulate.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("no-watermark")]
|
|
[InlineData("watermark-throws")]
|
|
[InlineData("secret-list-throws")]
|
|
[InlineData("every-1")]
|
|
[InlineData("unwired")]
|
|
public async Task NoTrustworthyToken_EveryEvaluationIsFull(string mode)
|
|
{
|
|
switch (mode)
|
|
{
|
|
case "no-watermark": _watermark.ReadAsync(Arg.Any<CancellationToken>()).Returns((string?)null); break;
|
|
case "watermark-throws": _watermark.ReadAsync(Arg.Any<CancellationToken>()).ThrowsAsync(new TimeoutException()); break;
|
|
case "secret-list-throws": _secrets.ListAsync(true, Arg.Any<CancellationToken>()).ThrowsAsync(new InvalidOperationException("store down")); break;
|
|
}
|
|
|
|
var evaluator = Evaluator(every: mode == "every-1" ? 1 : 10, wired: mode != "unwired");
|
|
await evaluator.EvaluateAsync();
|
|
await evaluator.EvaluateAsync();
|
|
await evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(3, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an evaluation whose artifact hash could not be computed (a secret-store read
|
|
/// failure while resolving a site's secret reference) is not reused, so the transient
|
|
/// Out-of-date answer clears on the very next evaluation.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UncleanEvaluation_IsNotReused()
|
|
{
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns([new DataConnection("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:db-pass"}""" }]);
|
|
_secrets.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).ThrowsAsync(new TimeoutException("store down"));
|
|
var evaluator = Evaluator();
|
|
|
|
await evaluator.EvaluateAsync();
|
|
await evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(2, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>Verifies that the cache's Re-check (forceRefresh) bypasses the short-circuit.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CacheForceRefresh_BypassesTheShortCircuit()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(_state);
|
|
services.AddScoped<IPendingUpdatesEvaluator>(_ => Evaluator());
|
|
await using var provider = services.BuildServiceProvider();
|
|
using var cache = new PendingUpdatesCache(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
Options.Create(new DeploymentManagerOptions()),
|
|
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
|
|
|
|
await cache.GetAsync();
|
|
await cache.GetAsync(forceRefresh: true); // unchanged token, but the operator asked: full
|
|
Assert.Equal(2, FlattenCalls());
|
|
|
|
await cache.GetAsync(forceRefresh: true);
|
|
Assert.Equal(3, FlattenCalls());
|
|
}
|
|
|
|
/// <summary>Verifies that a deploy completion makes the next evaluation full.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeployCompletion_MakesTheNextEvaluationFull()
|
|
{
|
|
var notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(_state);
|
|
services.AddScoped<IPendingUpdatesEvaluator>(_ => Evaluator());
|
|
await using var provider = services.BuildServiceProvider();
|
|
using var cache = new PendingUpdatesCache(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
Options.Create(new DeploymentManagerOptions()),
|
|
notifier);
|
|
|
|
await cache.GetAsync();
|
|
notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-7", 7, DeploymentStatus.Success));
|
|
await cache.GetAsync();
|
|
|
|
Assert.Equal(2, FlattenCalls());
|
|
}
|
|
}
|