fix(deployment): bulk-deploy up-to-date pre-filter uses batch hash/status reads and the operation's shared flatten session; completion log reports pre-filter cost (review 2026-10-21 04/P1)
This commit is contained in:
@@ -177,6 +177,18 @@ deliberately stopped; they are reported in the summary's `SkippedInstances` with
|
||||
the reason "Disabled — bulk deploy preserves disabled state; deploy individually
|
||||
to re-enable", and the single-instance deploy remains the explicit re-enable path.
|
||||
|
||||
**Up-to-date instances are skipped too (unless `force`), at batch cost.** The
|
||||
pre-filter reads the deployed-hash projection (`GetDeployedRevisionHashesAsync`) and
|
||||
the Enabled candidates' current deployment records
|
||||
(`GetCurrentDeploymentStatusesAsync`) ONCE each — no snapshot `ConfigurationJson`
|
||||
is materialised — and re-hashes each candidate with the hash-only flatten
|
||||
(`IFlatteningPipeline.ComputeRevisionHashAsync`) on the bulk operation's **one
|
||||
shared `FlattenSession`**, the same session phase 1 then prepares on, so a template
|
||||
chain is walked once across both (review 2026-10-21 04/P1; previously each probe
|
||||
built a private session, loaded the tracked snapshot and queried the status per
|
||||
instance). The completion log reports the pre-filter's probe count, elapsed time
|
||||
and chain loads alongside the totals.
|
||||
|
||||
**Reconciliation is bounded per batch.** Phase 1 is serial, so an unreachable site
|
||||
would otherwise cost the batch one full communication-layer `QueryTimeout` (30 s)
|
||||
per instance before a single deploy was sent — hours for a large site, past the
|
||||
@@ -259,10 +271,8 @@ The system maintains two views per instance:
|
||||
These are compared to determine staleness and generate diffs.
|
||||
|
||||
The comparison path is **hash-only by design**. When the Template-Derived
|
||||
Configuration is re-flattened for a comparison (the Deployments comparison page)
|
||||
or for the Transport stale-instance probe
|
||||
(`IStaleInstanceProbe.GetCurrentRevisionHashAsync`, used per-instance across a
|
||||
bundle import), the pipeline is invoked with `validateScripts: false`, which
|
||||
Configuration is re-flattened for a comparison (the Deployments comparison page),
|
||||
the pipeline is invoked with `validateScripts: false`, which
|
||||
skips **only** the expensive Roslyn script-compilation stage
|
||||
(`ValidationService.ValidateScriptCompilation` — a non-collectible assembly load
|
||||
per script). Structural and semantic validation still run. These read-only paths
|
||||
@@ -551,6 +561,6 @@ The Deployment Manager sends the following commands to sites via the Communicati
|
||||
|
||||
### Authoritative staleness and bulk deadlines
|
||||
|
||||
`StaleInstanceProbe` no longer memoizes revision hashes against process-local watermarks. Every probe starts a fresh flatten session, so a configuration edit through another central is visible to the next deployment skip decision. This trades extra flattening work for correctness across shared-database writers.
|
||||
`StaleInstanceProbe` no longer memoizes revision hashes against process-local watermarks. Every probe starts a fresh flatten session, so a configuration edit through another central is visible to the next deployment skip decision. This trades extra flattening work for correctness across shared-database writers. The probe (used per instance across a bundle import), the bulk-deploy pre-filter and the pending-updates sweep all use the hash-only `IFlatteningPipeline.ComputeRevisionHashAsync` — flatten + hash, no validation — whose hash equals `FlattenAndValidateAsync`'s, so all three agree with the deploy gate on what "stale" means. The bulk pre-filter shares its operation's session (a within-operation share, not the removed cross-operation memo).
|
||||
|
||||
Management bulk deployment uses `LongRunningCommandTimeout` as an overall scheduling/send deadline. Cancellation stops scheduling more instances, fails unfinished eligible rows, and releases prepared operation locks. Completed and deliberately skipped outcomes remain in the response. HTTP Ask reserves an additional60 seconds for cleanup and response; the bulk CLI defers its timeout to this server-owned deadline.
|
||||
|
||||
+11
@@ -125,6 +125,17 @@ public interface IDeploymentManagerRepository
|
||||
/// <returns>The current deployment record, or null if no deployment exists.</returns>
|
||||
Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Batch form of <see cref="GetCurrentDeploymentStatusAsync"/>: the CURRENT (most recent by
|
||||
/// <c>DeployedAt</c>, then highest <c>Id</c>) deployment record of each instance in
|
||||
/// <paramref name="instanceIds"/>, in ONE untracked query. Used by the bulk-deploy
|
||||
/// up-to-date pre-filter, which reads but never writes these records.
|
||||
/// </summary>
|
||||
/// <param name="instanceIds">Instances to look up; duplicates are harmless.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>Instance id → current record, omitting instances with no record.</returns>
|
||||
Task<IReadOnlyDictionary<int, DeploymentRecord>> GetCurrentDeploymentStatusesAsync(
|
||||
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Gets a deployment record by deployment ID.
|
||||
/// </summary>
|
||||
/// <param name="deploymentId">The deployment ID.</param>
|
||||
|
||||
+24
@@ -190,6 +190,30 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<int, DeploymentRecord>> GetCurrentDeploymentStatusesAsync(
|
||||
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(instanceIds);
|
||||
if (instanceIds.Count == 0)
|
||||
return new Dictionary<int, DeploymentRecord>();
|
||||
|
||||
var ids = instanceIds.Distinct().ToArray();
|
||||
|
||||
// "Current" = no newer record for the same instance, with the same (DeployedAt, Id)
|
||||
// tie-break GetCurrentDeploymentStatusAsync uses. Resolved in SQL so only one row per
|
||||
// instance comes back however long each instance's deploy history is.
|
||||
var records = await _dbContext.DeploymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(d => ids.Contains(d.InstanceId))
|
||||
.Where(d => !_dbContext.DeploymentRecords.Any(o =>
|
||||
o.InstanceId == d.InstanceId
|
||||
&& (o.DeployedAt > d.DeployedAt || (o.DeployedAt == d.DeployedAt && o.Id > d.Id))))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return records.ToDictionary(d => d.InstanceId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
@@ -46,7 +45,6 @@ public class DeploymentService
|
||||
private readonly DiffService _diffService;
|
||||
private readonly RevisionHashService _revisionHashService;
|
||||
private readonly IDeploymentStatusNotifier _statusNotifier;
|
||||
private readonly IStaleInstanceProbe _staleInstanceProbe;
|
||||
private readonly DeploymentManagerOptions _options;
|
||||
private readonly CommunicationOptions _commOptions;
|
||||
private readonly ILogger<DeploymentService> _logger;
|
||||
@@ -109,11 +107,6 @@ public class DeploymentService
|
||||
/// staleness hash from the (List-normalized) deserialized snapshot — see I-1.
|
||||
/// </param>
|
||||
/// <param name="statusNotifier">Notifier for pushing deployment status changes to the UI.</param>
|
||||
/// <param name="staleInstanceProbe">
|
||||
/// Current-revision-hash probe backing the bulk deploy's up-to-date pre-filter
|
||||
/// (see <see cref="DeploySiteAsync"/>). The same seam the bundle importer and
|
||||
/// the Deployments page use, so all three agree on what "stale" means.
|
||||
/// </param>
|
||||
/// <param name="options">Deployment manager configuration options.</param>
|
||||
/// <param name="communicationOptions">
|
||||
/// Central-site communication options. The notify-and-fetch deploy path reads
|
||||
@@ -133,7 +126,6 @@ public class DeploymentService
|
||||
DiffService diffService,
|
||||
RevisionHashService revisionHashService,
|
||||
IDeploymentStatusNotifier statusNotifier,
|
||||
IStaleInstanceProbe staleInstanceProbe,
|
||||
IOptions<DeploymentManagerOptions> options,
|
||||
IOptions<CommunicationOptions> communicationOptions,
|
||||
ILogger<DeploymentService> logger)
|
||||
@@ -147,7 +139,6 @@ public class DeploymentService
|
||||
_diffService = diffService;
|
||||
_revisionHashService = revisionHashService;
|
||||
_statusNotifier = statusNotifier;
|
||||
_staleInstanceProbe = staleInstanceProbe;
|
||||
_options = options.Value;
|
||||
_commOptions = communicationOptions.Value;
|
||||
_logger = logger;
|
||||
@@ -961,9 +952,18 @@ public class DeploymentService
|
||||
//
|
||||
// Unchanged instances are then filtered out for the mirror-image reason:
|
||||
// see IsUpToDateAsync.
|
||||
// ONE flatten session for the whole operation — the up-to-date pre-filter and
|
||||
// phase 1 both flatten through it, so a template chain shared by N instances is
|
||||
// walked once across the pre-filter AND the prepare. Sharing it within one
|
||||
// operation does not reopen MG-3 (that was a cross-operation process memo).
|
||||
var session = _flatteningPipeline.CreateSession();
|
||||
|
||||
var deployable = new List<Instance>(instances.Count);
|
||||
var skipped = new List<SkippedInstanceResult>();
|
||||
var disabledCount = 0;
|
||||
var preFilter = force ? null : await UpToDatePreFilter.LoadAsync(this, instances, cancellationToken);
|
||||
var preFilterTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||
var preFilterProbed = 0;
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -973,7 +973,8 @@ public class DeploymentService
|
||||
skipped.Add(new SkippedInstanceResult(instance.Id, instance.UniqueName, DisabledSkipReason));
|
||||
disabledCount++;
|
||||
}
|
||||
else if (!force && await IsUpToDateAsync(instance, cancellationToken))
|
||||
else if (preFilter != null
|
||||
&& await IsUpToDateAsync(instance, preFilter, session, () => preFilterProbed++, cancellationToken))
|
||||
{
|
||||
skipped.Add(new SkippedInstanceResult(instance.Id, instance.UniqueName, UpToDateSkipReason));
|
||||
}
|
||||
@@ -983,10 +984,11 @@ public class DeploymentService
|
||||
}
|
||||
}
|
||||
|
||||
preFilterTimer.Stop();
|
||||
var preFilterChainLoads = session.ChainLoads;
|
||||
var upToDateCount = skipped.Count - disabledCount;
|
||||
|
||||
// ---- Phase 1: prepare, serially, on ONE shared flatten session. ----
|
||||
var session = _flatteningPipeline.CreateSession();
|
||||
// ---- Phase 1: prepare, serially, on the SAME shared flatten session. ----
|
||||
var reconcileGate = new BulkReconcileGate(_options.SiteDeploymentReconcileTimeout);
|
||||
var prepared = new List<PreparedDeployment>(deployable.Count);
|
||||
var results = new List<InstanceDeploymentResult>(deployable.Count);
|
||||
@@ -1094,9 +1096,12 @@ public class DeploymentService
|
||||
_logger.LogInformation(
|
||||
"Bulk deployment of site {SiteId} complete: {SuccessCount} succeeded, {FailureCount} failed, " +
|
||||
"{DisabledCount} skipped as Disabled, {UpToDateCount} skipped as up-to-date (force={Force}) " +
|
||||
"({ChainLoads} template-chain load(s) across {InstanceCount} instance(s))",
|
||||
"({ChainLoads} template-chain load(s) across {InstanceCount} deployed instance(s); " +
|
||||
"up-to-date pre-filter probed {ProbedCount} instance(s) in {PreFilterMs} ms with " +
|
||||
"{PreFilterChainLoads} of those chain load(s))",
|
||||
site.SiteIdentifier, summary.SuccessCount, summary.FailureCount, disabledCount, upToDateCount,
|
||||
force, session.ChainLoads, deployable.Count);
|
||||
force, session.ChainLoads, deployable.Count,
|
||||
preFilterProbed, preFilterTimer.ElapsedMilliseconds, preFilterChainLoads);
|
||||
|
||||
return Result<SiteDeploymentSummary>.Success(summary);
|
||||
}
|
||||
@@ -1138,8 +1143,8 @@ public class DeploymentService
|
||||
/// that HEALS such a record runs. Skipping here would strand it forever.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The probe returning <see langword="null"/> (unresolvable template chain)
|
||||
/// or throwing means staleness is indeterminate.
|
||||
/// The hash-only re-flatten failing (unresolvable template chain) or
|
||||
/// throwing means staleness is indeterminate.
|
||||
/// </item>
|
||||
/// </list>
|
||||
///
|
||||
@@ -1152,33 +1157,43 @@ public class DeploymentService
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Cost.</b> The probe re-flattens on its own session rather than the
|
||||
/// batch's, so a fully-stale site pays one extra flatten per instance before
|
||||
/// prepare re-flattens it for real. Each request checks authoritative database
|
||||
/// state because a peer central node can mutate configuration without bumping
|
||||
/// this process's watermark. Unchanged instances still avoid site round-trips.
|
||||
/// <b>Cost.</b> The deployed hashes and the current deployment records are read
|
||||
/// ONCE for the batch (<see cref="UpToDatePreFilter"/>, both projections — no
|
||||
/// <c>ConfigurationJson</c> is materialised), and the current hash is a hash-only
|
||||
/// flatten on the batch's own shared session, so a stale instance's later
|
||||
/// prepare reuses the template chain this walked. Each request still reads
|
||||
/// authoritative database state because a peer central node can mutate
|
||||
/// configuration without bumping this process's watermark.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="instance">The candidate instance.</param>
|
||||
/// <param name="preFilter">The batch's pre-loaded deployed hashes and current records.</param>
|
||||
/// <param name="session">The bulk operation's shared flatten session.</param>
|
||||
/// <param name="onProbe">Called once per instance that reaches the re-flatten, for the summary log.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns><see langword="true"/> only when the instance is provably unchanged.</returns>
|
||||
private async Task<bool> IsUpToDateAsync(Instance instance, CancellationToken cancellationToken)
|
||||
private async Task<bool> IsUpToDateAsync(
|
||||
Instance instance,
|
||||
UpToDatePreFilter preFilter,
|
||||
FlattenSession session,
|
||||
Action onProbe,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (instance.State != InstanceState.Enabled)
|
||||
return false;
|
||||
|
||||
var snapshot = await _repository.GetDeployedSnapshotByInstanceIdAsync(instance.Id, cancellationToken);
|
||||
if (snapshot == null)
|
||||
if (!preFilter.DeployedHashes.TryGetValue(instance.Id, out var deployedHash))
|
||||
return false;
|
||||
|
||||
var prior = await _repository.GetCurrentDeploymentStatusAsync(instance.Id, cancellationToken);
|
||||
if (prior != null && ShouldQuerySiteBeforeRedeploy(prior))
|
||||
if (preFilter.CurrentRecords.TryGetValue(instance.Id, out var prior)
|
||||
&& ShouldQuerySiteBeforeRedeploy(prior))
|
||||
return false;
|
||||
|
||||
string? currentHash;
|
||||
onProbe();
|
||||
Result<string> current;
|
||||
try
|
||||
{
|
||||
currentHash = await _staleInstanceProbe.GetCurrentRevisionHashAsync(instance.Id, cancellationToken);
|
||||
current = await _flatteningPipeline.ComputeRevisionHashAsync(instance.Id, session, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -1191,8 +1206,39 @@ public class DeploymentService
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentHash != null
|
||||
&& string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal);
|
||||
return current.IsSuccess
|
||||
&& string.Equals(current.Value, deployedHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The batch-level reads behind the bulk-deploy up-to-date pre-filter: every
|
||||
/// deployed snapshot's revision hash (projection) and each Enabled candidate's
|
||||
/// current deployment record, each fetched in ONE query rather than per instance.
|
||||
/// </summary>
|
||||
/// <param name="DeployedHashes">Instance id → deployed snapshot revision hash.</param>
|
||||
/// <param name="CurrentRecords">Instance id → current deployment record (untracked).</param>
|
||||
private sealed record UpToDatePreFilter(
|
||||
IReadOnlyDictionary<int, string> DeployedHashes,
|
||||
IReadOnlyDictionary<int, DeploymentRecord> CurrentRecords)
|
||||
{
|
||||
private static readonly UpToDatePreFilter Empty = new(
|
||||
new Dictionary<int, string>(), new Dictionary<int, DeploymentRecord>());
|
||||
|
||||
/// <summary>Loads the batch reads, or an empty filter when no candidate can be up to date.</summary>
|
||||
public static async Task<UpToDatePreFilter> LoadAsync(
|
||||
DeploymentService owner, IReadOnlyList<Instance> instances, CancellationToken cancellationToken)
|
||||
{
|
||||
var enabledIds = instances
|
||||
.Where(i => i.State == InstanceState.Enabled)
|
||||
.Select(i => i.Id)
|
||||
.ToList();
|
||||
if (enabledIds.Count == 0)
|
||||
return Empty;
|
||||
|
||||
var hashes = await owner._repository.GetDeployedRevisionHashesAsync(cancellationToken);
|
||||
var records = await owner._repository.GetCurrentDeploymentStatusesAsync(enabledIds, cancellationToken);
|
||||
return new UpToDatePreFilter(hashes, records);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -323,4 +323,37 @@ public class ReconcileRepositoryTests : IDisposable
|
||||
Assert.Equal("SlimEnabled", e.UniqueName);
|
||||
Assert.Empty(_context.ChangeTracker.Entries<Instance>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// review 2026-10-21 04/P1: the batch current-status read returns, per instance, the same
|
||||
/// record GetCurrentDeploymentStatusAsync would (latest DeployedAt, then highest Id), in
|
||||
/// one untracked query.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetCurrentDeploymentStatuses_MatchesPerInstanceCurrentRecord()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("SiteStatus");
|
||||
var a = await SeedInstanceAsync("StatusA", siteId);
|
||||
var b = await SeedInstanceAsync("StatusB", siteId);
|
||||
var c = await SeedInstanceAsync("StatusC", siteId);
|
||||
var t0 = DateTimeOffset.UtcNow.AddHours(-1);
|
||||
|
||||
_context.DeploymentRecords.AddRange(
|
||||
new DeploymentRecord("a-old", "admin") { InstanceId = a, Status = DeploymentStatus.Success, DeployedAt = t0 },
|
||||
new DeploymentRecord("a-new", "admin") { InstanceId = a, Status = DeploymentStatus.InProgress, DeployedAt = t0.AddMinutes(5) },
|
||||
// Tie on DeployedAt: the higher Id (inserted later) wins.
|
||||
new DeploymentRecord("b-1", "admin") { InstanceId = b, Status = DeploymentStatus.Success, DeployedAt = t0 },
|
||||
new DeploymentRecord("b-2", "admin") { InstanceId = b, Status = DeploymentStatus.Failed, DeployedAt = t0 });
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var current = await _repository.GetCurrentDeploymentStatusesAsync(new[] { a, b, c });
|
||||
|
||||
Assert.Equal(2, current.Count);
|
||||
Assert.Equal("a-new", current[a].DeploymentId);
|
||||
Assert.Equal("b-2", current[b].DeploymentId);
|
||||
Assert.False(current.ContainsKey(c));
|
||||
Assert.Equal((await _repository.GetCurrentDeploymentStatusAsync(b))!.DeploymentId, current[b].DeploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
@@ -35,7 +34,10 @@ public class DeploySiteAsyncTests : TestKit
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
||||
private readonly IStaleInstanceProbe _staleProbe = Substitute.For<IStaleInstanceProbe>();
|
||||
|
||||
// Batch reads behind the up-to-date pre-filter (one query each per bulk deploy).
|
||||
private readonly Dictionary<int, string> _deployedHashes = new();
|
||||
private readonly Dictionary<int, DeploymentRecord> _currentRecords = new();
|
||||
private readonly OperationLockManager _lockManager = new();
|
||||
|
||||
/// <summary>Initializes the test class with substituted repositories, a flattening pipeline and a stale-instance probe.</summary>
|
||||
@@ -45,6 +47,13 @@ public class DeploySiteAsyncTests : TestKit
|
||||
.Returns(ci => new Site($"Site {ci.ArgAt<int>(0)}", $"site-{ci.ArgAt<int>(0)}") { Id = ci.ArgAt<int>(0) });
|
||||
|
||||
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
|
||||
|
||||
_repo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_ => (IReadOnlyDictionary<int, string>)new Dictionary<int, string>(_deployedHashes));
|
||||
_repo.GetCurrentDeploymentStatusesAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => (IReadOnlyDictionary<int, DeploymentRecord>)ci.ArgAt<IReadOnlyCollection<int>>(0)
|
||||
.Where(_currentRecords.ContainsKey)
|
||||
.ToDictionary(id => id, id => _currentRecords[id]));
|
||||
}
|
||||
|
||||
/// <summary>Seeds <paramref name="count"/> deployable instances at the test site.</summary>
|
||||
@@ -102,7 +111,6 @@ public class DeploySiteAsyncTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
_staleProbe,
|
||||
options,
|
||||
Options.Create(new CommunicationOptions
|
||||
{
|
||||
@@ -458,17 +466,17 @@ public class DeploySiteAsyncTests : TestKit
|
||||
var hash = $"sha256:{instance.Id}";
|
||||
instance.State = InstanceState.Enabled;
|
||||
|
||||
_repo.GetDeployedSnapshotByInstanceIdAsync(instance.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(new DeployedConfigSnapshot($"prior-{instance.Id}", hash, "{}") { InstanceId = instance.Id });
|
||||
_repo.GetCurrentDeploymentStatusAsync(instance.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(new DeploymentRecord($"prior-{instance.Id}", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
Status = DeploymentStatus.Success,
|
||||
RevisionHash = hash,
|
||||
});
|
||||
_staleProbe.GetCurrentRevisionHashAsync(instance.Id, Arg.Any<CancellationToken>())
|
||||
.Returns(hash);
|
||||
_deployedHashes[instance.Id] = hash;
|
||||
var prior = new DeploymentRecord($"prior-{instance.Id}", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
Status = DeploymentStatus.Success,
|
||||
RevisionHash = hash,
|
||||
};
|
||||
_currentRecords[instance.Id] = prior;
|
||||
_repo.GetCurrentDeploymentStatusAsync(instance.Id, Arg.Any<CancellationToken>()).Returns(prior);
|
||||
_pipeline.ComputeRevisionHashAsync(instance.Id, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result<string>.Success(hash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -534,6 +542,56 @@ public class DeploySiteAsyncTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// review 2026-10-21 04/P1: the up-to-date pre-filter costs a fixed number of batch reads
|
||||
/// (deployed-hash projection + one current-record query) — never a per-instance snapshot
|
||||
/// load with its <c>ConfigurationJson</c> or a per-instance status query — and its
|
||||
/// hash-only re-flattens run on the bulk operation's ONE shared session, the same one
|
||||
/// phase 1 prepares on.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploySiteAsync_PreFilter_UsesBatchReadsAndTheSharedSession()
|
||||
{
|
||||
var instances = ArrangeInstances(4);
|
||||
ArrangeUpToDate(instances.Single(i => i.Id == 2));
|
||||
ArrangeUpToDate(instances.Single(i => i.Id == 3));
|
||||
// Instance 4 is Enabled but its config changed: it is probed, found stale, deployed.
|
||||
ArrangeUpToDate(instances.Single(i => i.Id == 4));
|
||||
_deployedHashes[4] = "sha256:old-4";
|
||||
|
||||
var sessions = new List<FlattenSession?>();
|
||||
_pipeline.When(p => p.ComputeRevisionHashAsync(
|
||||
Arg.Any<int>(), Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => sessions.Add(ci.ArgAt<FlattenSession?>(1)));
|
||||
_pipeline.When(p => p.FlattenAndValidateAsync(
|
||||
Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>()))
|
||||
.Do(ci => sessions.Add(ci.ArgAt<FlattenSession?>(3)));
|
||||
|
||||
var applied = new ConcurrentBag<string>();
|
||||
var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied)));
|
||||
|
||||
var result = await CreateService(commActor, maxParallelism: 4).DeploySiteAsync(SiteId, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(new[] { "Inst-01", "Inst-04" }, applied.Order().ToArray());
|
||||
|
||||
await _repo.Received(1).GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>());
|
||||
await _repo.Received(1).GetCurrentDeploymentStatusesAsync(
|
||||
Arg.Is<IReadOnlyCollection<int>>(ids => ids.Order().SequenceEqual(new[] { 2, 3, 4 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
foreach (var id in new[] { 2, 3 })
|
||||
{
|
||||
await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(id, Arg.Any<CancellationToken>());
|
||||
await _repo.DidNotReceive().GetCurrentDeploymentStatusAsync(id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// One session for the whole operation: pre-filter probes and prepare flattens alike.
|
||||
_pipeline.Received(1).CreateSession();
|
||||
Assert.NotEmpty(sessions);
|
||||
Assert.All(sessions, s => Assert.Same(sessions[0], s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>force</c> restores the unconditional behaviour for operators whose
|
||||
/// site state central cannot see — a restored data volume or a rebuilt node
|
||||
@@ -609,17 +667,19 @@ public class DeploySiteAsyncTests : TestKit
|
||||
// 2: prior record stuck InProgress. Prepare is where query-before-redeploy
|
||||
// reconciliation HEALS such a record; skipping here strands it forever.
|
||||
ArrangeUpToDate(instances.Single(i => i.Id == 2));
|
||||
_repo.GetCurrentDeploymentStatusAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new DeploymentRecord("prior-2", "admin")
|
||||
{
|
||||
InstanceId = 2,
|
||||
Status = DeploymentStatus.InProgress,
|
||||
RevisionHash = "sha256:2",
|
||||
});
|
||||
var stuck = new DeploymentRecord("prior-2", "admin")
|
||||
{
|
||||
InstanceId = 2,
|
||||
Status = DeploymentStatus.InProgress,
|
||||
RevisionHash = "sha256:2",
|
||||
};
|
||||
_currentRecords[2] = stuck;
|
||||
_repo.GetCurrentDeploymentStatusAsync(2, Arg.Any<CancellationToken>()).Returns(stuck);
|
||||
|
||||
// 3: staleness indeterminate — the probe cannot compute a hash.
|
||||
// 3: staleness indeterminate — the hash-only flatten cannot compute a hash.
|
||||
ArrangeUpToDate(instances.Single(i => i.Id == 3));
|
||||
_staleProbe.GetCurrentRevisionHashAsync(3, Arg.Any<CancellationToken>()).Returns((string?)null);
|
||||
_pipeline.ComputeRevisionHashAsync(3, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result<string>.Failure("template chain unresolvable"));
|
||||
|
||||
var applied = new ConcurrentBag<string>();
|
||||
var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied)));
|
||||
|
||||
@@ -7,7 +7,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
@@ -71,7 +70,6 @@ public class DeploymentServiceStalenessTests
|
||||
new DiffService(),
|
||||
_hasher,
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||
|
||||
@@ -9,7 +9,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
@@ -56,7 +55,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
options,
|
||||
DeployCommOptions(),
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
@@ -202,7 +200,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
|
||||
DeployCommOptions(),
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
@@ -435,7 +432,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
|
||||
DeployCommOptions(),
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
@@ -1251,7 +1247,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
|
||||
DeployCommOptions(centralFetchBaseUrl),
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
@@ -1871,7 +1866,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5),
|
||||
@@ -1932,7 +1926,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5),
|
||||
@@ -1984,7 +1977,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5),
|
||||
@@ -2033,7 +2025,6 @@ public class DeploymentServiceTests : TestKit
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5),
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
@@ -63,7 +62,7 @@ public class DeploymentStatusNotifierTests : TestKit
|
||||
_service = new DeploymentService(
|
||||
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
|
||||
new DiffService(), new RevisionHashService(), _notifier,
|
||||
Substitute.For<IStaleInstanceProbe>(), options,
|
||||
options,
|
||||
// Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the
|
||||
// deploy path reach the send (and, here, the catch block) rather than
|
||||
// tripping the fail-fast configuration guard.
|
||||
|
||||
@@ -7,7 +7,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
@@ -81,7 +80,6 @@ public class PendingUpdatesEvaluatorTests
|
||||
new DiffService(),
|
||||
_hasher,
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
Substitute.For<IStaleInstanceProbe>(),
|
||||
Options.Create(new DeploymentManagerOptions()),
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
|
||||
@@ -13,7 +13,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
@@ -199,11 +198,12 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
comms.SetCommunicationActor(Sys.ActorOf(Props.Create(() => new AcceptingSiteActor())));
|
||||
|
||||
// Null probe result = "staleness indeterminate", so the up-to-date
|
||||
// A failed hash-only flatten = "staleness indeterminate", so the up-to-date
|
||||
// pre-filter never skips: this test is about the persistence cascade and
|
||||
// needs every instance to actually run.
|
||||
var probe = Substitute.For<IStaleInstanceProbe>();
|
||||
probe.GetCurrentRevisionHashAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns((string?)null);
|
||||
pipeline
|
||||
.ComputeRevisionHashAsync(Arg.Any<int>(), Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result<string>.Failure("indeterminate"));
|
||||
|
||||
return new DeploymentService(
|
||||
repository, siteRepository, pipeline, comms, new OperationLockManager(),
|
||||
@@ -211,7 +211,6 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
|
||||
new DiffService(),
|
||||
new RevisionHashService(),
|
||||
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
||||
probe,
|
||||
Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5),
|
||||
|
||||
Reference in New Issue
Block a user