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
@@ -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>());
}
}