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:
@@ -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
|
||||
|
||||
+11
@@ -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.
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
+11
-6
@@ -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>
|
||||
|
||||
+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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-21
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user