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