diff --git a/docs/requirements/Component-DeploymentManager.md b/docs/requirements/Component-DeploymentManager.md
index d688a6bd..ececfb32 100644
--- a/docs/requirements/Component-DeploymentManager.md
+++ b/docs/requirements/Component-DeploymentManager.md
@@ -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.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs
index a31d13dd..91e6637f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs
@@ -125,6 +125,17 @@ public interface IDeploymentManagerRepository
/// The current deployment record, or null if no deployment exists.
Task GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default);
///
+ /// Batch form of : the CURRENT (most recent by
+ /// DeployedAt, then highest Id) deployment record of each instance in
+ /// , in ONE untracked query. Used by the bulk-deploy
+ /// up-to-date pre-filter, which reads but never writes these records.
+ ///
+ /// Instances to look up; duplicates are harmless.
+ /// A cancellation token that can be used to cancel the operation.
+ /// Instance id → current record, omitting instances with no record.
+ Task> GetCurrentDeploymentStatusesAsync(
+ IReadOnlyCollection instanceIds, CancellationToken cancellationToken = default);
+ ///
/// Gets a deployment record by deployment ID.
///
/// The deployment ID.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs
index c0f31f4e..8c7f8ce0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs
@@ -190,6 +190,30 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
.FirstOrDefaultAsync(cancellationToken);
}
+ ///
+ public async Task> GetCurrentDeploymentStatusesAsync(
+ IReadOnlyCollection instanceIds, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(instanceIds);
+ if (instanceIds.Count == 0)
+ return new Dictionary();
+
+ 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);
+ }
+
///
public async Task GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
index 16821484..9c217a7d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
@@ -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 _logger;
@@ -109,11 +107,6 @@ public class DeploymentService
/// staleness hash from the (List-normalized) deserialized snapshot — see I-1.
///
/// Notifier for pushing deployment status changes to the UI.
- ///
- /// Current-revision-hash probe backing the bulk deploy's up-to-date pre-filter
- /// (see ). The same seam the bundle importer and
- /// the Deployments page use, so all three agree on what "stale" means.
- ///
/// Deployment manager configuration options.
///
/// 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 options,
IOptions communicationOptions,
ILogger 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(instances.Count);
var skipped = new List();
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(deployable.Count);
var results = new List(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.Success(summary);
}
@@ -1138,8 +1143,8 @@ public class DeploymentService
/// that HEALS such a record runs. Skipping here would strand it forever.
///
/// -
- /// The probe returning (unresolvable template chain)
- /// or throwing means staleness is indeterminate.
+ /// The hash-only re-flatten failing (unresolvable template chain) or
+ /// throwing means staleness is indeterminate.
///
///
///
@@ -1152,33 +1157,43 @@ public class DeploymentService
///
///
///
- /// Cost. 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.
+ /// Cost. The deployed hashes and the current deployment records are read
+ /// ONCE for the batch (, both projections — no
+ /// ConfigurationJson 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.
///
///
/// The candidate instance.
+ /// The batch's pre-loaded deployed hashes and current records.
+ /// The bulk operation's shared flatten session.
+ /// Called once per instance that reaches the re-flatten, for the summary log.
/// Cancellation token.
/// only when the instance is provably unchanged.
- private async Task IsUpToDateAsync(Instance instance, CancellationToken cancellationToken)
+ private async Task 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 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// Instance id → deployed snapshot revision hash.
+ /// Instance id → current deployment record (untracked).
+ private sealed record UpToDatePreFilter(
+ IReadOnlyDictionary DeployedHashes,
+ IReadOnlyDictionary CurrentRecords)
+ {
+ private static readonly UpToDatePreFilter Empty = new(
+ new Dictionary(), new Dictionary());
+
+ /// Loads the batch reads, or an empty filter when no candidate can be up to date.
+ public static async Task LoadAsync(
+ DeploymentService owner, IReadOnlyList 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);
+ }
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs
index a1e310f1..e74bdad9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ReconcileRepositoryTests.cs
@@ -323,4 +323,37 @@ public class ReconcileRepositoryTests : IDisposable
Assert.Equal("SlimEnabled", e.UniqueName);
Assert.Empty(_context.ChangeTracker.Entries());
}
+
+ ///
+ /// 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.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
index ecb75f45..27092d24 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
@@ -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();
private readonly ISiteRepository _siteRepo = Substitute.For();
private readonly IAuditService _audit = Substitute.For();
- private readonly IStaleInstanceProbe _staleProbe = Substitute.For();
+
+ // Batch reads behind the up-to-date pre-filter (one query each per bulk deploy).
+ private readonly Dictionary _deployedHashes = new();
+ private readonly Dictionary _currentRecords = new();
private readonly OperationLockManager _lockManager = new();
/// Initializes the test class with substituted repositories, a flattening pipeline and a stale-instance probe.
@@ -45,6 +47,13 @@ public class DeploySiteAsyncTests : TestKit
.Returns(ci => new Site($"Site {ci.ArgAt(0)}", $"site-{ci.ArgAt(0)}") { Id = ci.ArgAt(0) });
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
+
+ _repo.GetDeployedRevisionHashesAsync(Arg.Any())
+ .Returns(_ => (IReadOnlyDictionary)new Dictionary(_deployedHashes));
+ _repo.GetCurrentDeploymentStatusesAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci => (IReadOnlyDictionary)ci.ArgAt>(0)
+ .Where(_currentRecords.ContainsKey)
+ .ToDictionary(id => id, id => _currentRecords[id]));
}
/// Seeds deployable instances at the test site.
@@ -102,7 +111,6 @@ public class DeploySiteAsyncTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.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())
- .Returns(new DeployedConfigSnapshot($"prior-{instance.Id}", hash, "{}") { InstanceId = instance.Id });
- _repo.GetCurrentDeploymentStatusAsync(instance.Id, Arg.Any())
- .Returns(new DeploymentRecord($"prior-{instance.Id}", "admin")
- {
- InstanceId = instance.Id,
- Status = DeploymentStatus.Success,
- RevisionHash = hash,
- });
- _staleProbe.GetCurrentRevisionHashAsync(instance.Id, Arg.Any())
- .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()).Returns(prior);
+ _pipeline.ComputeRevisionHashAsync(instance.Id, Arg.Any(), Arg.Any())
+ .Returns(Result.Success(hash));
}
///
@@ -534,6 +542,56 @@ public class DeploySiteAsyncTests : TestKit
}
}
+ ///
+ /// 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 ConfigurationJson 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.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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();
+ _pipeline.When(p => p.ComputeRevisionHashAsync(
+ Arg.Any(), Arg.Any(), Arg.Any()))
+ .Do(ci => sessions.Add(ci.ArgAt(1)));
+ _pipeline.When(p => p.FlattenAndValidateAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()))
+ .Do(ci => sessions.Add(ci.ArgAt(3)));
+
+ var applied = new ConcurrentBag();
+ 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());
+ await _repo.Received(1).GetCurrentDeploymentStatusesAsync(
+ Arg.Is>(ids => ids.Order().SequenceEqual(new[] { 2, 3, 4 })),
+ Arg.Any());
+ foreach (var id in new[] { 2, 3 })
+ {
+ await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(id, Arg.Any());
+ await _repo.DidNotReceive().GetCurrentDeploymentStatusAsync(id, Arg.Any());
+ }
+
+ // 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));
+ }
+
///
/// force 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())
- .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()).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()).Returns((string?)null);
+ _pipeline.ComputeRevisionHashAsync(3, Arg.Any(), Arg.Any())
+ .Returns(Result.Failure("template chain unresolvable"));
var applied = new ConcurrentBag();
var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied)));
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
index 42e8d005..8d710680 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
@@ -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.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
index 83642b2a..9d7fbfe7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
@@ -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.Instance),
- Substitute.For(),
options,
DeployCommOptions(),
NullLogger.Instance);
@@ -202,7 +200,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
DeployCommOptions(),
NullLogger.Instance);
@@ -435,7 +432,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
DeployCommOptions(),
NullLogger.Instance);
@@ -1251,7 +1247,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
DeployCommOptions(centralFetchBaseUrl),
NullLogger.Instance);
@@ -1871,7 +1866,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
@@ -1932,7 +1926,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
@@ -1984,7 +1977,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
@@ -2033,7 +2025,6 @@ public class DeploymentServiceTests : TestKit
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
index b3048846..f45e3620 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
@@ -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(), 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.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
index bd316ce9..a000aec7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
@@ -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.Instance),
- Substitute.For(),
Options.Create(new DeploymentManagerOptions()),
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
index 7e6db7f1..3bf83e76 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
@@ -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.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();
- probe.GetCurrentRevisionHashAsync(Arg.Any(), Arg.Any()).Returns((string?)null);
+ pipeline
+ .ComputeRevisionHashAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Result.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.Instance),
- probe,
Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),