fix(deployment): pending-updates tick uses a slim instance projection, hash-only flatten, one untracked mismatch snapshot read and a per-tick secret lookup memo (review 2026-10-21 04/P2)

This commit is contained in:
Joseph Doherty
2026-10-22 06:39:45 -04:00
parent 971a9886b8
commit df67fabe68
17 changed files with 443 additions and 128 deletions
@@ -299,4 +299,28 @@ public class ReconcileRepositoryTests : IDisposable
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep-good"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-gone"));
}
/// <summary>
/// review 2026-10-21 04/P2: the pending-updates instance list is a slim, untracked projection
/// of the deployed (non-NotDeployed) instances.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedInstanceSummaries_ExcludesNotDeployed_Untracked()
{
var siteId = await SeedSiteAsync("SiteSlim");
var enabled = await SeedInstanceAsync("SlimEnabled", siteId, InstanceState.Enabled);
var disabled = await SeedInstanceAsync("SlimDisabled", siteId, InstanceState.Disabled);
await SeedInstanceAsync("SlimNotDeployed", siteId, InstanceState.NotDeployed);
_context.ChangeTracker.Clear();
var summaries = await _repository.GetDeployedInstanceSummariesAsync();
Assert.Equal(new[] { enabled, disabled }.Order(), summaries.Select(i => i.Id).Order());
var e = summaries.Single(i => i.Id == enabled);
Assert.Equal(siteId, e.SiteId);
Assert.Equal(InstanceState.Enabled, e.State);
Assert.Equal("SlimEnabled", e.UniqueName);
Assert.Empty(_context.ChangeTracker.Entries<Instance>());
}
}
@@ -745,6 +745,40 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextHex, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// review 2026-10-21 04/P2: computing current artifact hashes for N sites that reference the
/// same secret reads it from the central store ONCE per evaluation, not once per site — and
/// both sites still hash identically to their own uncached computation.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentArtifactHashes_SharedSecret_IsReadFromTheStoreOncePerEvaluation()
{
var siteA = new Site("Site A", "site-a") { Id = 1 };
var siteB = new Site("Site B", "site-b") { Id = 2 };
foreach (var id in new[] { 1, 2 })
{
_siteRepo.GetDataConnectionsBySiteIdAsync(id, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>
{
new($"GW{id}", "MxGateway", id) { PrimaryConfiguration = """{"ApiKey":"secret:shared-key"}""" }
});
}
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "shared-key"), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<StoredSecret?>(SecretRow("shared-key", 3)));
var service = CreateService();
var hashes = await service.ComputeCurrentArtifactHashesAsync([siteA, siteB]);
Assert.Equal(2, hashes.Count);
await _secretStore.Received(1).GetAsync(
Arg.Is<SecretName>(n => n.Value == "shared-key"), Arg.Any<CancellationToken>());
// Memoisation must not change the answer.
var single = await service.ComputeCurrentArtifactHashesAsync([siteB]);
Assert.Equal(single[2], hashes[2]);
}
// ── Stamp gating on secret application + DeploySiteSecrets audit row ──
/// <summary>Verifies that an old site that reports no applied secrets fails and is not stamped when secrets were shipped.</summary>
@@ -125,21 +125,35 @@ public class DeploymentServiceStalenessTests
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }]
};
/// <summary>Stubs a successful flatten of <paramref name="instanceId"/>.</summary>
/// <summary>Stubs a successful hash-only flatten of <paramref name="instanceId"/>.</summary>
/// <param name="instanceId">The instance.</param>
/// <param name="config">Unused by the hash-only path; kept so call sites read as before.</param>
/// <param name="hash">The hash the re-flatten produces.</param>
private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
_pipeline.ComputeRevisionHashAsync(instanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success(hash));
/// <summary>Stubs a FAILED hash-only flatten of <paramref name="instanceId"/>.</summary>
private void ArrangeFlattenFailure(int instanceId, string error) =>
_pipeline.ComputeRevisionHashAsync(instanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Failure(error));
/// <summary>Snapshots served by the batched, untracked snapshot read.</summary>
private readonly Dictionary<int, DeployedConfigSnapshot> _snapshots = new();
/// <summary>Stubs the fat snapshot read for <paramref name="instanceId"/>.</summary>
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
_repo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
});
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash)
{
_snapshots[instanceId] = new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
};
_repo.GetDeployedSnapshotsByInstanceIdsAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyDictionary<int, DeployedConfigSnapshot>)ci.ArgAt<IReadOnlyCollection<int>>(0)
.Where(_snapshots.ContainsKey)
.ToDictionary(id => id, id => _snapshots[id]));
}
/// <summary>Stubs the (InstanceId, RevisionHash) projection read.</summary>
private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
@@ -164,9 +178,9 @@ public class DeploymentServiceStalenessTests
// One session created, and the SAME instance handed to every flatten —
// that is what makes a template chain shared by N instances walked once.
_pipeline.Received(1).CreateSession();
await _pipeline.Received(1).FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), false, _session);
await _pipeline.Received(1).FlattenAndValidateAsync(2, Arg.Any<CancellationToken>(), false, _session);
await _pipeline.Received(1).FlattenAndValidateAsync(3, Arg.Any<CancellationToken>(), false, _session);
await _pipeline.Received(1).ComputeRevisionHashAsync(1, _session, Arg.Any<CancellationToken>());
await _pipeline.Received(1).ComputeRevisionHashAsync(2, _session, Arg.Any<CancellationToken>());
await _pipeline.Received(1).ComputeRevisionHashAsync(3, _session, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a matching hash is reported as not stale and no snapshot is read.</summary>
@@ -184,6 +198,8 @@ public class DeploymentServiceStalenessTests
Assert.False(result[1]);
// The whole point of the projection read: an up-to-date fleet never
// deserialises a single ConfigurationJson.
await _repo.DidNotReceive().GetDeployedSnapshotsByInstanceIdsAsync(
Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>());
await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>());
}
@@ -228,7 +244,8 @@ public class DeploymentServiceStalenessTests
var result = await _service.GetInstanceStalenessAsync([50]);
Assert.False(result[50]);
await _repo.Received(1).GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any<CancellationToken>());
await _repo.Received(1).GetDeployedSnapshotsByInstanceIdsAsync(
Arg.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 50 })), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a genuine hash mismatch is reported as stale.</summary>
@@ -256,9 +273,7 @@ public class DeploymentServiceStalenessTests
public async Task GetInstanceStalenessAsync_FlattenFails_ReportsUnknown()
{
ArrangeDeployedHashes((1, "sha256:deployed"));
_pipeline.FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("template missing"));
ArrangeFlattenFailure(1, "template missing");
var result = await _service.GetInstanceStalenessAsync([1]);
@@ -279,9 +294,7 @@ public class DeploymentServiceStalenessTests
ArrangeDeployedHashes((1, "sha256:deployed"), (2, "sha256:deployed"));
foreach (var id in new[] { 1, 2 })
{
_pipeline.FlattenAndValidateAsync(
id, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure($"broken template {id}"));
ArrangeFlattenFailure(id, $"broken template {id}");
}
var result = await _service.GetInstanceStalenessAsync([1, 2]);
@@ -327,8 +340,8 @@ public class DeploymentServiceStalenessTests
Assert.True(result.ContainsKey(1));
// Instance 2 has never been deployed — not stale, not unknown, absent.
Assert.False(result.ContainsKey(2));
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
await _pipeline.DidNotReceive().ComputeRevisionHashAsync(
2, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an instance whose snapshot vanishes between reads is omitted from the result.</summary>
@@ -339,8 +352,8 @@ public class DeploymentServiceStalenessTests
// Undeployed between the projection read and the mismatch's snapshot read:
// there is nothing left to be stale against, so report nothing at all.
ArrangeDeployedHashes((1, "sha256:deployed"));
_repo.GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
_repo.GetDeployedSnapshotsByInstanceIdsAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<int, DeployedConfigSnapshot>());
ArrangeFlatten(1, ScalarConfig("5"), "sha256:current");
var result = await _service.GetInstanceStalenessAsync([1]);
@@ -361,8 +374,8 @@ public class DeploymentServiceStalenessTests
var result = await _service.GetInstanceStalenessAsync([1, 1, 1]);
Assert.Single(result);
await _pipeline.Received(1).FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
await _pipeline.Received(1).ComputeRevisionHashAsync(
1, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that empty input returns an empty result without any reads.</summary>
@@ -375,4 +388,51 @@ public class DeploymentServiceStalenessTests
Assert.Empty(result);
await _repo.DidNotReceive().GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>());
}
// ── review 2026-10-21 04/P2 ──
/// <summary>
/// The sweep only ever compares hashes, so it uses the hash-only flatten — never the
/// validating entry point whose result it used to discard.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_UsesHashOnlyFlatten_NeverValidates()
{
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
await _service.GetInstanceStalenessAsync([1]);
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
/// <summary>
/// Every mismatched instance's snapshot comes from ONE untracked batch read, never the
/// tracked per-instance read (which would pin each ConfigurationJson in the scoped tracker).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_Mismatches_ResolvedByOneUntrackedBatchRead()
{
var deployed = ScalarConfig("5");
var deployedHash = _hasher.ComputeHash(deployed);
ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
ArrangeSnapshot(1, deployed, deployedHash);
ArrangeSnapshot(2, deployed, deployedHash);
ArrangeFlatten(1, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
ArrangeFlatten(2, ScalarConfig("8"), _hasher.ComputeHash(ScalarConfig("8")));
var result = await _service.GetInstanceStalenessAsync([1, 2]);
Assert.True(result[1]);
Assert.True(result[2]);
await _repo.Received(1).GetDeployedSnapshotsByInstanceIdsAsync(
Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>());
await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(
Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}
@@ -711,23 +711,24 @@ public class DeploymentServiceTests : TestKit
await _pipeline.Received().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true);
}
/// <summary>Verifies that stale instance probe requests validate scripts false.</summary>
/// <summary>
/// The Transport stale-instance probe only needs the hash, so it uses the hash-only
/// flatten (no validation, no Roslyn compile) rather than the validating entry point.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
public async Task StaleInstanceProbe_UsesHashOnlyFlatten()
{
// The Transport stale-instance probe also only needs the hash — same skip.
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
_pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(new FlattenedConfiguration(), "sha256:probe", ValidationResult.Success())));
_pipeline.ComputeRevisionHashAsync(5, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success("sha256:probe"));
var probe = new StaleInstanceProbe(_pipeline);
var hash = await probe.GetCurrentRevisionHashAsync(5);
Assert.Equal("sha256:probe", hash);
await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, Arg.Any<FlattenSession?>());
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
// ── Comparison must produce a structured diff ──
@@ -118,4 +118,34 @@ public class FlatteningPipelinePreviewTests
await _templateRepo.DidNotReceive().GetCompositionsByTemplateIdAsync(
Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>
/// review 2026-10-21 04/P2: the hash-only entry point returns exactly the hash the
/// validating entry point does (so staleness verdicts cannot drift from the deploy and
/// comparison paths), without the validation-only shared-script and schema-library loads.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeRevisionHash_matches_validating_flatten_without_validation_loads()
{
var hashOnly = await _sut.ComputeRevisionHashAsync(InstanceId);
await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _sharedSchemaRepo.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
var full = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);
Assert.True(hashOnly.IsSuccess, hashOnly.IsSuccess ? "" : hashOnly.Error);
Assert.True(full.IsSuccess, full.IsSuccess ? "" : full.Error);
Assert.Equal(full.Value.RevisionHash, hashOnly.Value);
}
/// <summary>The hash-only entry point fails like the validating one for an unknown instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeRevisionHash_unknown_instance_fails()
{
var r = await _sut.ComputeRevisionHashAsync(999_999);
Assert.True(r.IsFailure);
}
}
@@ -87,7 +87,7 @@ public class PendingUpdatesEvaluatorTests
NullLogger<DeploymentService>.Instance);
_evaluator = new PendingUpdatesEvaluator(
_siteRepo, _templateRepo, _artifactService, _deploymentService,
_siteRepo, _deploymentRepo, _artifactService, _deploymentService,
NullLogger<PendingUpdatesEvaluator>.Instance);
}
@@ -97,9 +97,9 @@ public class PendingUpdatesEvaluatorTests
private void ArrangeSites(params Site[] sites) =>
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
/// <summary>Stubs the instance list read.</summary>
/// <summary>Stubs the slim deployed-instance list read.</summary>
private void ArrangeInstances(params Instance[] instances) =>
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>()).Returns(instances);
_deploymentRepo.GetDeployedInstanceSummariesAsync(Arg.Any<CancellationToken>()).Returns(instances);
/// <summary>Builds an instance in the given lifecycle state at the given site.</summary>
private static Instance MakeInstance(int id, int siteId, InstanceState state) =>
@@ -114,30 +114,35 @@ public class PendingUpdatesEvaluatorTests
/// <summary>Stubs a successful flatten of <paramref name="instanceId"/> at the given hash.</summary>
private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
_pipeline.ComputeRevisionHashAsync(instanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success(hash));
/// <summary>Stubs a FAILED flatten of <paramref name="instanceId"/> — the "unknown" leg.</summary>
private void ArrangeFlattenFailure(int instanceId) =>
_pipeline.FlattenAndValidateAsync(
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("template missing"));
_pipeline.ComputeRevisionHashAsync(instanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Failure("template missing"));
/// <summary>Stubs the (InstanceId, RevisionHash) projection read.</summary>
private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
_deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
/// <summary>Snapshots served by the batched, untracked snapshot read.</summary>
private readonly Dictionary<int, DeployedConfigSnapshot> _snapshots = new();
/// <summary>Stubs the fat snapshot read for <paramref name="instanceId"/>.</summary>
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
_deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any<CancellationToken>())
.Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
});
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash)
{
_snapshots[instanceId] = new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow
};
_deploymentRepo.GetDeployedSnapshotsByInstanceIdsAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyDictionary<int, DeployedConfigSnapshot>)ci.ArgAt<IReadOnlyCollection<int>>(0)
.Where(_snapshots.ContainsKey)
.ToDictionary(id => id, id => _snapshots[id]));
}
/// <summary>
/// Asks the artifact service what the site's hash WOULD be right now — the value a
@@ -282,10 +287,10 @@ public class PendingUpdatesEvaluatorTests
// A NotDeployed instance has nothing to be stale against; flattening it would
// be pure cost on every TTL expiry, so it must never reach the batch.
await _pipeline.Received(1).FlattenAndValidateAsync(
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
await _pipeline.Received(1).ComputeRevisionHashAsync(
1, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>());
await _pipeline.DidNotReceive().ComputeRevisionHashAsync(
2, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>());
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
}
@@ -338,6 +343,27 @@ public class PendingUpdatesEvaluatorTests
Assert.False(snapshot.HasPendingUpdates);
}
/// <summary>
/// review 2026-10-21 04/P2: the instance list is the slim, untracked deployed-instance
/// projection — never the full instance graph with all four override collections.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_ReadsTheSlimInstanceProjection_NotTheFullInstanceGraph()
{
ArrangeSites(new Site("Alpha", "site-a") { Id = 1 });
ArrangeInstances(MakeInstance(1, 1, InstanceState.Enabled));
var config = ScalarConfig("5");
var hash = _hasher.ComputeHash(config);
ArrangeDeployedHashes((1, hash));
ArrangeFlatten(1, config, hash);
await _evaluator.EvaluateAsync();
await _deploymentRepo.Received(1).GetDeployedInstanceSummariesAsync(Arg.Any<CancellationToken>());
await _templateRepo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
}
// ── ordering, ComputedAt, ForSites ───────────────────────────────────────
/// <summary>Verifies that sites are ordered by name, ignoring case.</summary>
@@ -28,13 +28,11 @@ public class StaleInstanceProbeTests
{
_pipeline.CreateSession().Returns(_ => new FlattenSession(_watermark));
_pipeline
.FlattenAndValidateAsync(InstanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.ComputeRevisionHashAsync(InstanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
_flattenCount++;
var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" };
return Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, _currentHash, ValidationResult.Success()));
return Result<string>.Success(_currentHash);
});
}
@@ -148,20 +146,18 @@ public class StaleInstanceProbeTests
// The flatten reads the template through the session (recording the version
// it read it at), and the edit lands before the flatten returns.
_pipeline
.FlattenAndValidateAsync(InstanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.ComputeRevisionHashAsync(InstanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(async call =>
{
_flattenCount++;
var session = call.ArgAt<FlattenSession?>(3)!;
var session = call.ArgAt<FlattenSession?>(1)!;
await session.GetTemplateAsync(TemplateId, _ => Task.FromResult<Template?>(new Template($"T{TemplateId}")));
// A non-structural template edit commits while the flatten is in flight.
_watermark.BumpTemplate(TemplateId);
_currentHash = "sha256:after-edit";
var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" };
return Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, "sha256:before-edit", ValidationResult.Success()));
return Result<string>.Success("sha256:before-edit");
});
var probe = CreateProbe();
@@ -174,13 +170,11 @@ public class StaleInstanceProbeTests
// would have memoised the post-edit version against the pre-edit hash, and
// this call would still answer "sha256:before-edit" without re-flattening.
_pipeline
.FlattenAndValidateAsync(InstanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.ComputeRevisionHashAsync(InstanceId, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
_flattenCount++;
var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" };
return Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, _currentHash, ValidationResult.Success()));
return Result<string>.Success(_currentHash);
});
Assert.Equal("sha256:after-edit", await probe.GetCurrentRevisionHashAsync(InstanceId));