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:
Joseph Doherty
2026-10-22 06:46:03 -04:00
parent df67fabe68
commit 32e63eaa96
11 changed files with 247 additions and 78 deletions
@@ -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>
@@ -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>