feat(pending-updates): short-circuit an unchanged fleet on a DB rowversion watermark (#62)
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
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #62: the database-side config watermark behind the pending-updates short-circuit,
|
||||
/// against a real migrated SQL Server database (the <c>AddConfigGraphRowVersions</c>
|
||||
/// migration included). The "peer node" in these tests is a separate raw
|
||||
/// <see cref="SqlConnection"/> that never touches this process's DbContext — the exact shape
|
||||
/// of a write made by the other central node, which a per-process watermark never sees
|
||||
/// (arch-review 2026-09-09 MG-3).
|
||||
/// </summary>
|
||||
public class ConfigGraphWatermarkReaderTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
|
||||
/// <param name="fixture">The shared MS SQL migration fixture.</param>
|
||||
public ConfigGraphWatermarkReaderTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
|
||||
|
||||
private ScadaBridgeDbContext CreateContext(SqlReaderCommandRecorder? recorder = null)
|
||||
{
|
||||
var builder = new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString);
|
||||
if (recorder is not null)
|
||||
builder.AddInterceptors(recorder);
|
||||
return new ScadaBridgeDbContext(builder.Options);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAsync()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var token = await new ConfigGraphWatermarkReader(context).ReadAsync();
|
||||
Assert.NotNull(token);
|
||||
return token!;
|
||||
}
|
||||
|
||||
private void PeerExecute(string sql, params SqlParameter[] parameters)
|
||||
{
|
||||
using var connection = _fixture.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddRange(parameters);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the token is stable with no writes, and moves for a peer-node insert, update
|
||||
/// and delete made on a connection this process's context never saw.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Token_IsStableWithoutWrites_AndMovesForPeerInsertUpdateAndDelete()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var ident = "wm-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var t0 = await ReadAsync();
|
||||
Assert.Equal(t0, await ReadAsync()); // no write → identical token (the short-circuit case)
|
||||
|
||||
// Peer INSERT.
|
||||
PeerExecute("INSERT INTO [Sites] ([Name], [SiteIdentifier]) VALUES (@n, @i);",
|
||||
new SqlParameter("@n", ident), new SqlParameter("@i", ident));
|
||||
var t1 = await ReadAsync();
|
||||
Assert.NotEqual(t0, t1);
|
||||
Assert.Equal(t1, await ReadAsync());
|
||||
|
||||
// Peer UPDATE of a column no application write path ever "bumps" anything for.
|
||||
PeerExecute("UPDATE [Sites] SET [Description] = N'edited by the peer' WHERE [SiteIdentifier] = @i;",
|
||||
new SqlParameter("@i", ident));
|
||||
var t2 = await ReadAsync();
|
||||
Assert.NotEqual(t1, t2);
|
||||
|
||||
// Peer DELETE of a row that is NOT the table's newest — so MAX(RowVersion) stays put and
|
||||
// only the row count can catch it. A second, newer row is inserted first to guarantee that.
|
||||
var newer = ident + "-b";
|
||||
PeerExecute("INSERT INTO [Sites] ([Name], [SiteIdentifier]) VALUES (@n, @i);",
|
||||
new SqlParameter("@n", newer), new SqlParameter("@i", newer));
|
||||
var t3 = await ReadAsync();
|
||||
PeerExecute("DELETE FROM [Sites] WHERE [SiteIdentifier] = @i;", new SqlParameter("@i", ident));
|
||||
var t4 = await ReadAsync();
|
||||
Assert.NotEqual(t3, t4);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an EF write through this process's context also moves the token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Token_MovesForALocalEfWrite_AndTheRowVersionIsNotAConcurrencyToken()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var ident = "wm-" + Guid.NewGuid().ToString("N")[..8];
|
||||
var before = await ReadAsync();
|
||||
|
||||
int id;
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var site = new Site(ident, ident);
|
||||
context.Sites.Add(site);
|
||||
await context.SaveChangesAsync();
|
||||
id = site.Id;
|
||||
}
|
||||
|
||||
var afterInsert = await ReadAsync();
|
||||
Assert.NotEqual(before, afterInsert);
|
||||
|
||||
// A detached Update of a stale copy must still save: the column is change detection only,
|
||||
// never an optimistic-concurrency check that would fail a save the old schema accepted.
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
context.Sites.Update(new Site(ident, ident) { Id = id, Description = "detached update" });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Assert.NotEqual(afterInsert, await ReadAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guard for the covered set: every table the pending-updates evaluator's repository reads
|
||||
/// touch must carry the watermark column, so a new input table cannot silently escape it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task EveryTableThePendingUpdatesReadsTouch_IsCoveredByTheWatermark()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
// Seed one site → template → instance so the split-query child reads actually execute
|
||||
// (EF skips a split query's child statements when the root row is absent).
|
||||
var ident = "wm-" + Guid.NewGuid().ToString("N")[..8];
|
||||
int siteId, templateId, instanceId;
|
||||
await using (var seed = CreateContext())
|
||||
{
|
||||
var site = new Site(ident, ident);
|
||||
var template = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template(ident);
|
||||
seed.Sites.Add(site);
|
||||
seed.Templates.Add(template);
|
||||
await seed.SaveChangesAsync();
|
||||
var instance = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance(ident)
|
||||
{
|
||||
SiteId = site.Id,
|
||||
TemplateId = template.Id,
|
||||
};
|
||||
seed.Instances.Add(instance);
|
||||
await seed.SaveChangesAsync();
|
||||
(siteId, templateId, instanceId) = (site.Id, template.Id, instance.Id);
|
||||
}
|
||||
|
||||
var recorder = new SqlReaderCommandRecorder();
|
||||
await using var context = CreateContext(recorder);
|
||||
var watermark = new TemplateGraphWatermark();
|
||||
var sites = new SiteRepository(context, watermark);
|
||||
var templates = new TemplateEngineRepository(context, watermark);
|
||||
var deployments = new DeploymentManagerRepository(context);
|
||||
var external = new ExternalSystemRepository(context);
|
||||
|
||||
// PendingUpdatesEvaluator → ArtifactDeploymentService.ComputeCurrentArtifactHashesAsync
|
||||
await sites.GetAllSitesAsync();
|
||||
await templates.GetAllSharedScriptsAsync();
|
||||
await external.GetAllExternalSystemsAsync();
|
||||
await external.GetAllDatabaseConnectionsAsync();
|
||||
await deployments.GetAllExternalSystemMethodsAsync();
|
||||
await sites.GetDataConnectionsBySiteIdAsync(siteId);
|
||||
// → DeploymentService.GetInstanceStalenessAsync / FlatteningPipeline.FlattenCoreAsync
|
||||
await deployments.GetDeployedInstanceSummariesAsync();
|
||||
await deployments.GetDeployedRevisionHashesAsync();
|
||||
await deployments.GetDeployedSnapshotsByInstanceIdsAsync([instanceId]);
|
||||
await templates.GetInstanceByIdAsync(instanceId);
|
||||
await templates.GetTemplateWithChildrenAsync(templateId);
|
||||
|
||||
var covered = ConfigGraphRowVersions.EntityTypes
|
||||
.Select(t => context.Model.FindEntityType(t)!.GetTableName()!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var read = recorder.Commands
|
||||
.SelectMany(c => Regex.Matches(c, @"(?:FROM|JOIN)\s+(?:\[\w+\]\.)?\[(\w+)\]").Select(m => m.Groups[1].Value))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Assert.NotEmpty(read);
|
||||
Assert.Empty(read.Except(covered));
|
||||
// Positive control: the regex really does see the tables (a vacuous empty set would pass above).
|
||||
Assert.Contains("Templates", read);
|
||||
Assert.Contains("InstanceAttributeOverrides", read);
|
||||
Assert.Contains("DeployedConfigSnapshots", read);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-SQL-Server provider yields no token, so callers never short-circuit on it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NonSqlServerProvider_ReturnsNull()
|
||||
{
|
||||
await using var context = SqliteTestHelper.CreateInMemoryContext();
|
||||
Assert.Null(await new ConfigGraphWatermarkReader(context).ReadAsync());
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user