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
@@ -464,12 +464,17 @@ cadence, agreeing exactly with the per-instance Topology badge
- All the flattens share **one `FlattenSession`** for the batch, so a template
chain used by N instances is walked once — the same trick `DeploySiteAsync`
uses.
uses — and each is **hash-only** (`IFlatteningPipeline.ComputeRevisionHashAsync`):
no structural/semantic validation and none of the shared-script / schema-library
loads that feed it, since staleness only ever compares the hash (identical to
`FlattenAndValidateAsync`'s `RevisionHash`). `StaleInstanceProbe` uses the same
entry point.
- The deployed side is read as the `(InstanceId, RevisionHash)` **projection**
(`IDeploymentManagerRepository.GetDeployedRevisionHashesAsync`), never the fat
`ConfigurationJson` for the whole fleet.
- **Mismatch-only normalisation**: only when the projected hash disagrees with
the re-flatten is the full `DeployedConfigSnapshot` read and the I-1 List-value
the re-flatten is the full `DeployedConfigSnapshot` read — in **one untracked
batch** for every mismatched instance — and the I-1 List-value
normalisation applied (see *Deployed vs. Template-Derived State* above) — the
same normalisation `GetDeploymentComparisonAsync` applies, so the aggregate
never disagrees with the Topology badge on an old-form List snapshot, without
@@ -491,7 +496,11 @@ cadence, agreeing exactly with the per-instance Topology badge
`ArtifactsDeployedAt`, `DeployedInstanceCount`, `StaleInstanceCount`,
`UnknownInstanceCount`) and fleet totals `StaleInstanceCount` /
`StaleArtifactSiteCount`. Only instances with a deployed snapshot (`State !=
NotDeployed`, the same gate Topology uses) are evaluated. **`NeverDeployed` is
NotDeployed`, the same gate Topology uses) are evaluated; the instance list is a
slim untracked projection (`GetDeployedInstanceSummariesAsync`: id, site, state —
no override collections), and `ComputeCurrentArtifactHashesAsync` reads each
referenced secret from the central store once per evaluation, however many sites
reference it. **`NeverDeployed` is
deliberately not a pending update** — a freshly-created site the operator has not
gotten to yet has nothing drifted, so it is surfaced on the Sites page but never
counted toward the fleet totals or the rail indicator's badge count. Nothing in
@@ -383,6 +383,17 @@ public interface IDeploymentManagerRepository
IReadOnlyList<PendingDeployment> candidates, DateTimeOffset nowUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists every instance that is not <see cref="Types.Enums.InstanceState.NotDeployed"/> as a
/// slim, untracked projection — only <c>Id</c>, <c>UniqueName</c>, <c>SiteId</c>,
/// <c>TemplateId</c> and <c>State</c> are populated; override and binding collections are
/// empty. For the pending-updates sweep, which needs only identity, site and state, and
/// used to load every instance with all four override collections, tracked.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The deployed (Enabled or Disabled) instances, as slim projections.</returns>
Task<IReadOnlyList<Instance>> GetDeployedInstanceSummariesAsync(CancellationToken cancellationToken = default);
// Instance lookups for deployment pipeline
/// <summary>
/// Gets an instance by ID.
@@ -724,6 +724,23 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
r.CreatedAtUtc, r.ExpiresAtUtc) { Id = r.Id });
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetDeployedInstanceSummariesAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.AsNoTracking()
.Where(i => i.State != InstanceState.NotDeployed)
.Select(i => new Instance(i.UniqueName)
{
Id = i.Id,
SiteId = i.SiteId,
TemplateId = i.TemplateId,
State = i.State
})
.ToListAsync(cancellationToken);
}
// --- Instance lookups for deployment pipeline ---
/// <inheritdoc />
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -152,7 +153,8 @@ public class ArtifactDeploymentService
int siteId,
GlobalArtifactSnapshot globals,
CancellationToken cancellationToken,
string? deploymentId)
string? deploymentId,
IDictionary<string, StoredSecret?>? secretLookupCache = null)
{
var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
@@ -160,7 +162,8 @@ public class ArtifactDeploymentService
var dataConnectionArtifacts = dataConnections.Select(dc =>
new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).ToList();
var collected = await _secretsCollector.CollectAsync(dataConnectionArtifacts, cancellationToken);
var collected = await _secretsCollector.CollectAsync(
dataConnectionArtifacts, cancellationToken, secretLookupCache);
var command = new DeployArtifactsCommand(
deploymentId ?? Guid.NewGuid().ToString("N"),
@@ -439,10 +442,12 @@ public class ArtifactDeploymentService
// a non-empty Missing list means this site's command must never reach
// _communicationService — see the fan-out loop below.
var siteCommands = new Dictionary<int, (DeployArtifactsCommand Command, IReadOnlyList<SecretReference> Missing, string? SecretStoreError)>();
// One secret-store lookup per referenced name for the whole sweep, not per site.
var secretLookups = new Dictionary<string, StoredSecret?>(StringComparer.Ordinal);
foreach (var site in sites)
{
siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(
site.Id, globals, cancellationToken, deploymentId);
site.Id, globals, cancellationToken, deploymentId, secretLookups);
}
// Deploy to each site in parallel with per-site timeout.
@@ -723,8 +728,9 @@ public class ArtifactDeploymentService
/// </returns>
/// <remarks>
/// The global artifact queries run ONCE for the whole sweep (the same hoist
/// <see cref="DeployToAllSitesAsync"/> performs); only the per-site data-connection query
/// runs per site. The commands are built with the constant deployment id <c>"current"</c>,
/// <see cref="DeployToAllSitesAsync"/> performs), and each referenced secret is read from
/// the central store once per call however many sites reference it; only the per-site
/// data-connection query runs per site. The commands are built with the constant deployment id <c>"current"</c>,
/// which is harmless because <see cref="ArtifactRevisionHashService"/> excludes both the
/// deployment id and the command timestamp from the hash — that exclusion is what makes a
/// computed-now hash comparable with one stamped by a past deploy.
@@ -751,6 +757,10 @@ public class ArtifactDeploymentService
return hashes;
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
// Per-evaluation memo: a secret referenced by N sites is read from the central store
// once per pending-updates tick, not once per site.
var secretLookups = new Dictionary<string, StoredSecret?>(StringComparer.Ordinal);
foreach (var site in sites)
{
// The Missing tuple element is deliberately unused here: ComputeHash reads
@@ -758,7 +768,7 @@ public class ArtifactDeploymentService
// reference still folds a distinguishable marker into the hash — the site reads
// *Out of date* instead of this call failing the whole batch.
var (command, _, secretStoreError) = await BuildDeployArtifactsCommandAsync(
site.Id, globals, cancellationToken, deploymentId: "current");
site.Id, globals, cancellationToken, deploymentId: "current", secretLookups);
// A central secret-store outage leaves this site's current hash
// unknowable. Omitting it from the map makes the site read *Out of date* — the
@@ -1811,12 +1811,15 @@ public class DeploymentService
/// <para>
/// <b>How it stays cheap.</b> All the flattens share ONE
/// <see cref="FlattenSession"/>, so a template chain used by N instances is
/// walked once (the same trick <see cref="DeploySiteAsync"/> uses). The deployed
/// walked once (the same trick <see cref="DeploySiteAsync"/> uses), and each one is
/// hash-only (<see cref="IFlatteningPipeline.ComputeRevisionHashAsync"/>) — no
/// validation pass whose result would be discarded. The deployed
/// side is read as the <c>(InstanceId, RevisionHash)</c> projection
/// (<see cref="IDeploymentManagerRepository.GetDeployedRevisionHashesAsync"/>),
/// never the fat <c>ConfigurationJson</c> for the whole fleet. Only when that
/// projected hash DISAGREES with the re-flatten is the snapshot read and the
/// I-1 List normalization applied — which is what makes the aggregate agree with
/// projected hash DISAGREES with the re-flatten is the snapshot read — in ONE
/// untracked batch for all such instances — and the
/// I-1 List normalization applied, which is what makes the aggregate agree with
/// the badge on an old-form List snapshot without paying for it fleet-wide.
/// </para>
///
@@ -1862,6 +1865,10 @@ public class DeploymentService
var firstFailureInstanceId = 0;
string? firstFailureError = null;
// Instances whose re-flattened hash disagrees with the projected deployed hash,
// resolved together after the loop with ONE untracked snapshot read.
var mismatched = new Dictionary<int, string>();
foreach (var instanceId in instanceIds.Distinct())
{
cancellationToken.ThrowIfCancellationRequested();
@@ -1871,10 +1878,10 @@ public class DeploymentService
evaluated++;
// Read-only path: hash only, so skip the Roslyn compile — the deploy
// gate remains the authoritative compile.
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: false, session);
// Read-only path: hash only — no validation (its result was always discarded
// here), so no shared-script/schema loads and no structural/semantic pass.
var currentResult = await _flatteningPipeline.ComputeRevisionHashAsync(
instanceId, session, cancellationToken);
if (currentResult.IsFailure)
{
_logger.LogDebug(
@@ -1891,22 +1898,34 @@ public class DeploymentService
continue;
}
var currentHash = currentResult.Value.RevisionHash;
var currentHash = currentResult.Value;
if (string.Equals(currentHash, deployedHash, StringComparison.Ordinal))
{
staleness[instanceId] = false;
continue;
}
mismatched[instanceId] = currentHash;
}
if (mismatched.Count > 0)
{
// Mismatch only: pay the fat snapshot read + I-1 normalization, so an
// old-form List snapshot reads exactly as the Topology badge renders it.
var snapshot = await _repository.GetDeployedSnapshotByInstanceIdAsync(instanceId, cancellationToken);
if (snapshot == null)
continue;
// One untracked batch read — the snapshots' JSON is hashed and dropped,
// never held by the scoped change tracker.
var snapshots = await _repository.GetDeployedSnapshotsByInstanceIdsAsync(
mismatched.Keys.ToList(), cancellationToken);
foreach (var (instanceId, currentHash) in mismatched)
{
// Vanished between the projection read and now: undeployed concurrently.
if (!snapshots.TryGetValue(instanceId, out var snapshot))
continue;
var normalizedDeployedHash = ComputeDeployedHash(snapshot, instanceId, out _);
staleness[instanceId] =
!string.Equals(normalizedDeployedHash, currentHash, StringComparison.Ordinal);
var normalizedDeployedHash = ComputeDeployedHash(snapshot, instanceId, out _);
staleness[instanceId] =
!string.Equals(normalizedDeployedHash, currentHash, StringComparison.Ordinal);
}
}
if (failedCount > 0)
@@ -76,30 +76,11 @@ public class FlatteningPipeline : IFlatteningPipeline
// those to one load each.
session ??= CreateSession();
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
var flattened = await FlattenCoreAsync(instanceId, session, cancellationToken);
if (flattened.IsFailure)
return Result<FlatteningPipelineResult>.Failure(flattened.Error);
var graph = await LoadTemplateGraphAsync(instance.TemplateId, session, cancellationToken);
if (graph is null)
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
var (templateChain, compositionMap, composedChains) = graph.Value;
// Load data connections for the site
var dataConnections = await session.GetDataConnectionsAsync(
instance.SiteId,
siteId => LoadDataConnectionsAsync(siteId, cancellationToken));
// Flatten
var flattenResult = _flatteningService.Flatten(
instance, templateChain, compositionMap, composedChains, dataConnections);
if (flattenResult.IsFailure)
return Result<FlatteningPipelineResult>.Failure(flattenResult.Error);
var config = flattenResult.Value;
var (config, dataConnections) = flattened.Value;
// Load shared scripts for semantic validation. Session-global: identical
// for every instance in a batch, so hoisted to one query per session
@@ -180,6 +161,65 @@ public class FlatteningPipeline : IFlatteningPipeline
new FlatteningPipelineResult(config, hash, validation));
}
/// <inheritdoc />
public async Task<Result<string>> ComputeRevisionHashAsync(
int instanceId,
FlattenSession? session = null,
CancellationToken cancellationToken = default)
{
session ??= CreateSession();
// Flatten + hash only. The read-only staleness callers discard the validation result
// (a flatten whose validation has errors still has a hash, and FlattenAndValidateAsync
// returns it), so the structural/semantic validation, and the shared-script and
// schema-library loads that exist only to feed it, are pure cost here. The hash is
// computed over the same flattened config by the same service, so it is identical to
// FlattenAndValidateAsync's RevisionHash.
var flattened = await FlattenCoreAsync(instanceId, session, cancellationToken);
if (flattened.IsFailure)
return Result<string>.Failure(flattened.Error);
return Result<string>.Success(_revisionHashService.ComputeHash(flattened.Value.Config));
}
/// <summary>
/// The part of the pipeline every caller needs: load the instance and its template graph
/// (through <paramref name="session"/>), resolve the site's data connections, and flatten.
/// </summary>
/// <returns>The flattened configuration plus the data connections it was resolved against.</returns>
private async Task<Result<(FlattenedConfiguration Config, IReadOnlyDictionary<int, DataConnection> DataConnections)>>
FlattenCoreAsync(int instanceId, FlattenSession session, CancellationToken cancellationToken)
{
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<(FlattenedConfiguration, IReadOnlyDictionary<int, DataConnection>)>.Failure(
$"Instance with ID {instanceId} not found.");
var graph = await LoadTemplateGraphAsync(instance.TemplateId, session, cancellationToken);
if (graph is null)
return Result<(FlattenedConfiguration, IReadOnlyDictionary<int, DataConnection>)>.Failure(
"Template chain is empty.");
var (templateChain, compositionMap, composedChains) = graph.Value;
// Load data connections for the site
var dataConnections = await session.GetDataConnectionsAsync(
instance.SiteId,
siteId => LoadDataConnectionsAsync(siteId, cancellationToken));
// Flatten
var flattenResult = _flatteningService.Flatten(
instance, templateChain, compositionMap, composedChains, dataConnections);
if (flattenResult.IsFailure)
return Result<(FlattenedConfiguration, IReadOnlyDictionary<int, DataConnection>)>.Failure(
flattenResult.Error);
return Result<(FlattenedConfiguration, IReadOnlyDictionary<int, DataConnection>)>.Success(
(flattenResult.Value, dataConnections));
}
/// <inheritdoc />
public async Task<Result<FlattenedConfiguration>> PreviewAsync(
int templateId,
@@ -39,6 +39,28 @@ public interface IFlatteningPipeline
bool validateScripts = true,
FlattenSession? session = null);
/// <summary>
/// Flattens an instance and returns ONLY its revision hash — no validation, and none of
/// the shared-script / schema-library loads that feed it. For the read-only staleness
/// paths (the pending-updates sweep, the bulk-deploy up-to-date pre-filter,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport.IStaleInstanceProbe"/>),
/// which only ever compare the hash. The hash equals
/// <see cref="FlatteningPipelineResult.RevisionHash"/> from
/// <see cref="FlattenAndValidateAsync"/> for the same configuration (a flatten that
/// fails validation still has a hash there too).
/// </summary>
/// <param name="instanceId">Id of the instance to flatten.</param>
/// <param name="session">
/// Optional shared flatten session, as for <see cref="FlattenAndValidateAsync"/>; pass the
/// batch's session so a template chain shared by N instances is walked once.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The revision hash, or a failure result if the instance could not be flattened.</returns>
Task<Result<string>> ComputeRevisionHashAsync(
int instanceId,
FlattenSession? session = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Flattens <paramref name="templateId"/> as if an instance with no overrides and no
/// bindings existed at <paramref name="siteId"/>. Attribute list only — no validation,
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
public sealed class PendingUpdatesEvaluator : IPendingUpdatesEvaluator
{
private readonly ISiteRepository _siteRepository;
private readonly ITemplateEngineRepository _templateRepository;
private readonly IDeploymentManagerRepository _deploymentRepository;
private readonly ArtifactDeploymentService _artifactDeploymentService;
private readonly DeploymentService _deploymentService;
private readonly ILogger<PendingUpdatesEvaluator> _logger;
@@ -42,19 +42,22 @@ public sealed class PendingUpdatesEvaluator : IPendingUpdatesEvaluator
/// Initializes a new evaluator.
/// </summary>
/// <param name="siteRepository">Repository the site list is read from.</param>
/// <param name="templateRepository">Repository the instance list is read from.</param>
/// <param name="deploymentRepository">
/// Repository the slim deployed-instance list (id, site, state — no override collections)
/// is read from.
/// </param>
/// <param name="artifactDeploymentService">Computes each site's current artifact revision hash.</param>
/// <param name="deploymentService">Evaluates instance staleness in one batched flatten session.</param>
/// <param name="logger">Logger instance.</param>
public PendingUpdatesEvaluator(
ISiteRepository siteRepository,
ITemplateEngineRepository templateRepository,
IDeploymentManagerRepository deploymentRepository,
ArtifactDeploymentService artifactDeploymentService,
DeploymentService deploymentService,
ILogger<PendingUpdatesEvaluator> logger)
{
_siteRepository = siteRepository;
_templateRepository = templateRepository;
_deploymentRepository = deploymentRepository;
_artifactDeploymentService = artifactDeploymentService;
_deploymentService = deploymentService;
_logger = logger;
@@ -66,14 +69,16 @@ public sealed class PendingUpdatesEvaluator : IPendingUpdatesEvaluator
var stopwatch = Stopwatch.StartNew();
var sites = await _siteRepository.GetAllSitesAsync(cancellationToken);
var instances = await _templateRepository.GetAllInstancesAsync(cancellationToken);
// Slim, untracked projection: identity, site and state are all this pass reads.
var instances = await _deploymentRepository.GetDeployedInstanceSummariesAsync(cancellationToken);
var currentArtifactHashes =
await _artifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites, cancellationToken);
// Only instances the operator has actually deployed can be stale; a
// NotDeployed instance has nothing to compare against, and flattening it
// would be pure cost on every TTL expiry.
// would be pure cost on every TTL expiry. The repository already filters them
// out in SQL; the in-memory filter keeps that contract explicit here.
var deployedInstances = instances.Where(i => i.State != InstanceState.NotDeployed).ToList();
var staleness = await _deploymentService.GetInstanceStalenessAsync(
deployedInstances.Select(i => i.Id).ToList(), cancellationToken);
@@ -39,6 +39,13 @@ public sealed class SiteSecretsCollector(ISecretStore store)
/// </summary>
/// <param name="connections">The site's data-connection artifacts to scan.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <param name="lookupCache">
/// Optional per-sweep memo of store lookups, keyed by secret name (a <see langword="null"/>
/// value records "absent"). A multi-site caller passes ONE dictionary for the whole sweep so
/// a secret referenced by N sites is read from the store once, not N times. Only successful
/// lookups are recorded; a store read failure is never cached. Not thread-safe — the
/// callers build their per-site commands serially.
/// </param>
/// <returns>
/// The resolved rows (mapped field-for-field from <see cref="StoredSecret"/>) and the
/// references that were absent or tombstoned in the store. A store read failure is
@@ -48,7 +55,8 @@ public sealed class SiteSecretsCollector(ISecretStore store)
/// secret never touches the store at all.
/// </returns>
public async Task<SiteSecretsCollection> CollectAsync(
IEnumerable<DataConnectionArtifact> connections, CancellationToken ct)
IEnumerable<DataConnectionArtifact> connections, CancellationToken ct,
IDictionary<string, StoredSecret?>? lookupCache = null)
{
var secrets = new List<SecretArtifact>();
var missing = new List<SecretReference>();
@@ -58,7 +66,12 @@ public sealed class SiteSecretsCollector(ISecretStore store)
StoredSecret? row;
try
{
row = await store.GetAsync(new SecretName(reference.Name), ct);
if (lookupCache is null || !lookupCache.TryGetValue(reference.Name, out row))
{
row = await store.GetAsync(new SecretName(reference.Name), ct);
if (lookupCache is not null)
lookupCache[reference.Name] = row;
}
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
@@ -19,9 +19,9 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
/// <inheritdoc />
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
{
var session = _flatteningPipeline.CreateSession();
var result = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken,
validateScripts: false, session).ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
// Hash only: staleness never reads the validation result.
var result = await _flatteningPipeline.ComputeRevisionHashAsync(
instanceId, _flatteningPipeline.CreateSession(), cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
}
@@ -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));