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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user