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:
+88
-28
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user