perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -17,4 +17,43 @@ public class DeploymentManagerOptions
/// <summary>Timeout for acquiring an operation lock on an instance.</summary>
public TimeSpan OperationLockTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum number of instance deployments whose SITE ROUND-TRIP runs
/// concurrently during a bulk <c>DeploySiteAsync</c>. The flatten/validate and
/// persistence phases stay serial regardless (they share one non-thread-safe
/// <c>DbContext</c>); only the network wait is fanned out.
///
/// <para>
/// Bounded rather than unbounded so a large site cannot open one in-flight
/// gRPC command per instance against a two-node site pair. Four mirrors the
/// conservative end of the site's own apply concurrency; raise it only with
/// evidence from the target-scale load test.
/// </para>
/// </summary>
public int SiteDeploymentMaxParallelism { get; set; } = 4;
/// <summary>
/// Per-instance deadline applied to the site round-trip inside a bulk
/// <c>DeploySiteAsync</c>. One unreachable or wedged instance is recorded as a
/// failed row and the rest of the batch continues — the bulk operation never
/// inherits a single instance's hang.
/// </summary>
public TimeSpan SiteDeploymentTimeoutPerInstance { get; set; } = TimeSpan.FromSeconds(120);
/// <summary>
/// Retention window for TERMINAL deployment records (<c>Success</c>,
/// <c>Failed</c>). Deployments are insert-only — one row per attempt, forever —
/// so without a window the table grows without bound and every unfiltered
/// deployment query gets slower with the age of the system.
///
/// <para>
/// Deliberately generous (one year) because deployment history is
/// operator-facing forensics, and deliberately terminal-only: an
/// <c>InProgress</c> or <c>Pending</c> row is never purged regardless of age,
/// because it is exactly the row the query-before-redeploy reconciliation
/// path needs to find. Set to <see cref="TimeSpan.Zero"/> to disable purging.
/// </para>
/// </summary>
public TimeSpan TerminalDeploymentRecordRetention { get; set; } = TimeSpan.FromDays(365);
}
@@ -27,5 +27,20 @@ public sealed class DeploymentManagerOptionsValidator : OptionsValidatorBase<Dep
builder.RequireThat(options.OperationLockTimeout > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:OperationLockTimeout must be a positive duration " +
$"(was {options.OperationLockTimeout}); it bounds acquiring an instance operation lock.");
builder.RequireThat(options.SiteDeploymentMaxParallelism > 0,
$"ScadaBridge:DeploymentManager:SiteDeploymentMaxParallelism must be at least 1 " +
$"(was {options.SiteDeploymentMaxParallelism}); it is the SemaphoreSlim bound on a bulk " +
$"site deployment's site round-trips, and a non-positive value would deadlock the fan-out.");
builder.RequireThat(options.SiteDeploymentTimeoutPerInstance > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:SiteDeploymentTimeoutPerInstance must be a positive duration " +
$"(was {options.SiteDeploymentTimeoutPerInstance}); it bounds each instance's site round-trip " +
$"inside a bulk site deployment.");
builder.RequireThat(options.TerminalDeploymentRecordRetention >= TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:TerminalDeploymentRecordRetention must not be negative " +
$"(was {options.TerminalDeploymentRecordRetention}); use TimeSpan.Zero to disable purging of " +
$"terminal deployment records.");
}
}
@@ -57,6 +57,24 @@ public class DeploymentService
/// </summary>
private const string TimeoutFailurePrefix = "Communication failure:";
/// <summary>Rows deleted per terminal-record purge batch.</summary>
private const int TerminalPurgeBatchSize = 500;
/// <summary>Batches deleted per opportunistic sweep, bounding one caller's latency cost.</summary>
private const int TerminalPurgeBatchesPerSweep = 4;
/// <summary>Minimum interval between opportunistic terminal-record sweeps.</summary>
private static readonly TimeSpan TerminalPurgeMinInterval = TimeSpan.FromHours(6);
/// <summary>
/// Ticks of the last opportunistic terminal-record sweep, process-wide.
/// <see cref="DeploymentService"/> is scoped, so this must be static to
/// rate-limit across requests; <see cref="Interlocked"/> makes the
/// claim-the-sweep step a compare-and-swap so concurrent readers do not all
/// sweep at once.
/// </summary>
private static long _lastTerminalPurgeTicks;
/// <summary>
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
/// </summary>
@@ -161,28 +179,113 @@ public class DeploymentService
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
// Single-instance deploy = the same three phases the bulk path runs, with a
// batch of one and a private flatten session. Composing both entry points
// out of the same phase helpers is what keeps deployment identity,
// idempotency, lock coverage and optimistic concurrency from drifting apart
// between them.
var prepared = await PrepareDeploymentAsync(
instanceId, user, session: null, cancellationToken);
if (prepared.EarlyResult is { } early)
return early;
using (prepared.LockHandle)
{
var outcome = await SendDeploymentAsync(prepared, cancellationToken);
return await FinalizeDeploymentAsync(prepared, outcome, user, cancellationToken);
}
}
/// <summary>
/// Phase 1 of a deployment (serial, database-bound): validate the state
/// transition, take the per-instance operation lock, mint the deployment id,
/// flatten + validate, run query-before-redeploy reconciliation, stage the
/// <c>PendingDeployment</c> row and insert the <c>InProgress</c>
/// <see cref="DeploymentRecord"/>.
///
/// <para>
/// Everything here touches the scoped, non-thread-safe <c>DbContext</c>, so it
/// must run serially — a bulk site deploy loops this phase before fanning out
/// the phase-2 network waits. On success the returned
/// <see cref="PreparedDeployment"/> owns the operation lock; the CALLER is
/// responsible for disposing it once phase 3 has completed.
/// </para>
/// </summary>
/// <param name="instanceId">Instance being deployed.</param>
/// <param name="user">User attributed on the deployment record and audit rows.</param>
/// <param name="session">
/// Shared flatten session for a batch, or <see langword="null"/> for a private
/// one. Sharing it across a batch is what collapses N identical template-chain
/// walks into one.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A prepared deployment ready to send, or one carrying
/// <see cref="PreparedDeployment.EarlyResult"/> when the deploy resolved before
/// any site round-trip (validation failure, or a reconciled prior deployment).
/// </returns>
private async Task<PreparedDeployment> PrepareDeploymentAsync(
int instanceId,
string user,
FlattenSession? session,
CancellationToken cancellationToken)
{
// Load instance
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<DeploymentRecord>.Failure($"Instance with ID {instanceId} not found.");
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure($"Instance with ID {instanceId} not found."));
// Validate state transition
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
if (transitionError != null)
return Result<DeploymentRecord>.Failure(transitionError);
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(transitionError));
// Acquire per-instance operation lock
using var lockHandle = await _lockManager.AcquireAsync(
var lockHandle = await _lockManager.AcquireAsync(
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
try
{
return await PrepareUnderLockAsync(instance, user, session, lockHandle, cancellationToken);
}
catch
{
// The lock is handed to the caller only on the success path; any fault
// during preparation must release it here or a failed prepare would
// wedge the instance until the process restarts.
lockHandle.Dispose();
throw;
}
}
/// <summary>
/// The body of <see cref="PrepareDeploymentAsync"/> that runs while the
/// per-instance operation lock is held. Split out so the lock can be released
/// on every fault path with one try/catch rather than a nested one per step.
/// </summary>
private async Task<PreparedDeployment> PrepareUnderLockAsync(
Instance instance,
string user,
FlattenSession? session,
IDisposable lockHandle,
CancellationToken cancellationToken)
{
var instanceId = instance.Id;
// Generate unique deployment ID
var deploymentId = Guid.NewGuid().ToString("N");
// Flatten configuration (captures template state at this point in time)
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: true, session);
if (flattenResult.IsFailure)
return Result<DeploymentRecord>.Failure($"Validation failed: {flattenResult.Error}");
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(
Result<DeploymentRecord>.Failure($"Validation failed: {flattenResult.Error}"));
}
var flattenedConfig = flattenResult.Value.Configuration;
var revisionHash = flattenResult.Value.RevisionHash;
@@ -200,8 +303,9 @@ public class DeploymentService
validationResult.Errors.Count,
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
return Result<DeploymentRecord>.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}");
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}"));
}
// Serialize for transmission (also the payload stored in the deployed
@@ -218,7 +322,10 @@ public class DeploymentService
var reconciled = await TryReconcileWithSiteAsync(
instance, revisionHash, configJson, user, cancellationToken);
if (reconciled != null)
return Result<DeploymentRecord>.Success(reconciled);
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Success(reconciled));
}
// Notify-and-fetch: the site fetches the staged config from
// CentralFetchBaseUrl, so a deploy is impossible without it. Fail fast
@@ -227,8 +334,11 @@ public class DeploymentService
// confusing downstream site-fetch failure (and no InProgress record is
// stranded).
if (string.IsNullOrEmpty(_commOptions.CentralFetchBaseUrl))
return Result<DeploymentRecord>.Failure(
"CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch).");
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(
"CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch)."));
}
// Create the deployment record directly in InProgress.
//
@@ -275,12 +385,8 @@ public class DeploymentService
deploymentId, instance.UniqueName, revisionHash, user, stagedAt,
_commOptions.CentralFetchBaseUrl, token);
_logger.LogInformation(
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId} (notify-and-fetch)",
deploymentId, instance.UniqueName, siteId);
// Cleanup of the staged PendingDeployment is TTL-based ONLY — the row
// is deliberately NOT deleted on success or in the catch. On a
// is deliberately NOT deleted on success or on failure. On a
// central-side Ask timeout the site may have applied AND told the
// standby node to fetch; deleting now would 404 that in-flight
// standby fetch and break failover. Supersession bounds pending rows
@@ -288,8 +394,89 @@ public class DeploymentService
// leaving rows for TTL purge is safe. Expired rows are swept by the
// central PendingDeploymentPurgeActor singleton on its
// CommunicationOptions.PendingDeploymentPurgeInterval cadence.
var response = await _communicationService.RefreshDeploymentAsync(siteId, command, cancellationToken);
return new PreparedDeployment(
instance, deploymentId, record, revisionHash, configJson,
siteId, command, lockHandle, EarlyResult: null);
}
catch (Exception ex)
{
// Staging failed before anything was sent. Record the failure exactly
// as the post-send path does (never leave the record InProgress) and
// release the lock — there is no phase 2 or 3 to run.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, ex);
lockHandle.Dispose();
return PreparedDeployment.Resolved(FailureResultFor(ex));
}
}
/// <summary>
/// Phase 2 of a deployment (parallelisable, network-bound): the
/// <c>RefreshDeploymentCommand</c> round-trip to the site. Touches no
/// repository and mutates no shared state, which is what makes it safe to fan
/// out across a batch while phases 1 and 3 stay serial.
/// </summary>
/// <param name="prepared">The prepared deployment to send.</param>
/// <param name="cancellationToken">Cancellation token, already carrying any per-instance deadline.</param>
/// <returns>The site's response, or the exception that prevented one.</returns>
private async Task<SendOutcome> SendDeploymentAsync(
PreparedDeployment prepared,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId} (notify-and-fetch)",
prepared.DeploymentId, prepared.Instance.UniqueName, prepared.SiteIdentifier);
var response = await _communicationService.RefreshDeploymentAsync(
prepared.SiteIdentifier, prepared.Command, cancellationToken);
return new SendOutcome(response, null);
}
catch (Exception ex)
{
return new SendOutcome(null, ex);
}
}
/// <summary>
/// Phase 3 of a deployment (serial, database-bound): commit the terminal
/// status, apply post-success side effects, and write the audit row.
/// </summary>
/// <param name="prepared">The prepared deployment whose send just completed.</param>
/// <param name="outcome">The site response or the fault from phase 2.</param>
/// <param name="user">User attributed on the audit rows.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment result for the caller.</returns>
private async Task<Result<DeploymentRecord>> FinalizeDeploymentAsync(
PreparedDeployment prepared,
SendOutcome outcome,
string user,
CancellationToken cancellationToken)
{
var instance = prepared.Instance;
var record = prepared.Record;
var deploymentId = prepared.DeploymentId;
var instanceId = instance.Id;
if (outcome.Error is { } sendError)
{
// Any fault out of the site round-trip (timeout, cancellation,
// transport, serialization) must leave the deployment record as
// Failed -- the design requires an interrupted deployment to be
// treated as failed, never stuck in InProgress.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, sendError);
_logger.LogError(sendError,
"Deployment {DeploymentId} for instance {Instance} failed",
deploymentId, instance.UniqueName);
return FailureResultFor(sendError);
}
var response = outcome.Response!;
try
{
// Update status based on site response.
record.Status = response.Status;
record.ErrorMessage = response.ErrorMessage;
@@ -322,7 +509,7 @@ public class DeploymentService
// logged loudly for operator reconciliation but must not flip
// the already-committed Success record back to Failed.
await ApplyPostSuccessSideEffectsAsync(
instance, deploymentId, revisionHash, configJson,
instance, deploymentId, prepared.RevisionHash, prepared.ConfigJson,
forceEnabledState: true, cancellationToken);
}
@@ -345,61 +532,366 @@ public class DeploymentService
}
catch (Exception ex)
{
// Any exception out of the try (timeout,
// cancellation, transport, serialization, DB) must leave the
// deployment record as Failed -- the design requires an interrupted
// deployment to be treated as failed, never stuck in InProgress.
//
// The failure-status write must NOT use the
// operation's cancellation token. If the operation was cancelled or
// timed out, that token is already cancelled and the cleanup writes
// would themselves throw before the Failed status is persisted.
// Use CancellationToken.None so the failure is durably recorded.
var isTimeout = ex is TimeoutException or OperationCanceledException or Akka.Actor.AskTimeoutException;
record.Status = DeploymentStatus.Failed;
record.ErrorMessage = isTimeout
? $"{TimeoutFailurePrefix} {ex.Message}"
: $"Deployment error: {ex.Message}";
record.CompletedAt = DateTimeOffset.UtcNow;
try
{
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
// Note: if the staging SaveChangesAsync above was interrupted, an
// Added PendingDeployment may still be tracked and will be
// committed by this cleanup save. That row is orphaned (no
// RefreshDeploymentCommand was sent, so no site holds its token)
// and is removed by TTL purge / superseded by the next deploy --
// harmless.
await _repository.SaveChangesAsync(CancellationToken.None);
NotifyStatusChange(record);
await _auditService.LogAsync(user, "DeployFailed", "Instance", instanceId.ToString(),
instance.UniqueName, new { DeploymentId = deploymentId, Error = ex.Message },
CancellationToken.None);
}
catch (Exception cleanupEx)
{
// The deployment already failed; a failed cleanup write must not
// mask the original error. Log loudly so an operator can reconcile.
_logger.LogError(cleanupEx,
"Failed to persist Failed status for deployment {DeploymentId} of instance {Instance} " +
"after deployment error: {Error}",
deploymentId, instance.UniqueName, ex.Message);
}
// A fault in the post-send persistence path is handled identically to
// a send fault: the record must never be left InProgress.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, ex);
_logger.LogError(ex,
"Deployment {DeploymentId} for instance {Instance} failed",
deploymentId, instance.UniqueName);
return Result<DeploymentRecord>.Failure(
isTimeout
? $"Deployment timed out: {ex.Message}"
: $"Deployment failed: {ex.Message}");
return FailureResultFor(ex);
}
}
/// <summary>
/// Writes the terminal <see cref="DeploymentStatus.Failed"/> status (plus the
/// failure audit row) for a deployment that faulted, on any phase.
///
/// <para>
/// The failure-status write deliberately uses <see cref="CancellationToken.None"/>:
/// if the operation was cancelled or timed out, the operation's own token is
/// already cancelled and these cleanup writes would themselves throw before
/// the Failed status was persisted, leaving the record stuck InProgress.
/// </para>
///
/// <para>
/// A fault DURING cleanup is logged loudly and swallowed — it must not mask
/// the original error the caller is about to report.
/// </para>
/// </summary>
private async Task MarkDeploymentFailedAsync(
DeploymentRecord record,
Instance instance,
string deploymentId,
string user,
Exception cause)
{
record.Status = DeploymentStatus.Failed;
record.ErrorMessage = IsTimeoutFault(cause)
? $"{TimeoutFailurePrefix} {cause.Message}"
: $"Deployment error: {cause.Message}";
record.CompletedAt = DateTimeOffset.UtcNow;
try
{
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
// Note: if the staging SaveChangesAsync was interrupted, an Added
// PendingDeployment may still be tracked and will be committed by this
// cleanup save. That row is orphaned (no RefreshDeploymentCommand was
// sent, so no site holds its token) and is removed by TTL purge /
// superseded by the next deploy -- harmless.
await _repository.SaveChangesAsync(CancellationToken.None);
NotifyStatusChange(record);
await _auditService.LogAsync(user, "DeployFailed", "Instance", instance.Id.ToString(),
instance.UniqueName, new { DeploymentId = deploymentId, Error = cause.Message },
CancellationToken.None);
}
catch (Exception cleanupEx)
{
_logger.LogError(cleanupEx,
"Failed to persist Failed status for deployment {DeploymentId} of instance {Instance} " +
"after deployment error: {Error}",
deploymentId, instance.UniqueName, cause.Message);
}
}
/// <summary>
/// True when a deployment fault is a timeout/cancellation rather than a hard
/// error. Drives both the <see cref="TimeoutFailurePrefix"/> marker (which the
/// query-before-redeploy trigger reads) and the caller-facing wording.
/// </summary>
private static bool IsTimeoutFault(Exception ex) =>
ex is TimeoutException or OperationCanceledException or Akka.Actor.AskTimeoutException;
/// <summary>Maps a deployment fault to the caller-facing failure result.</summary>
private static Result<DeploymentRecord> FailureResultFor(Exception ex) =>
Result<DeploymentRecord>.Failure(
IsTimeoutFault(ex)
? $"Deployment timed out: {ex.Message}"
: $"Deployment failed: {ex.Message}");
/// <summary>
/// A deployment that has cleared phase 1 and is ready for its site round-trip,
/// OR one that resolved during phase 1 (carrying <see cref="EarlyResult"/>).
///
/// <para>
/// When <see cref="EarlyResult"/> is <see langword="null"/> this record OWNS the
/// per-instance operation lock in <see cref="LockHandle"/>, and the caller must
/// dispose it after phase 3. When <see cref="EarlyResult"/> is set the lock has
/// already been released and every other member is a placeholder.
/// </para>
/// </summary>
private sealed record PreparedDeployment(
Instance Instance,
string DeploymentId,
DeploymentRecord Record,
string RevisionHash,
string ConfigJson,
string SiteIdentifier,
RefreshDeploymentCommand Command,
IDisposable? LockHandle,
Result<DeploymentRecord>? EarlyResult)
{
/// <summary>Builds a phase-1-resolved deployment carrying the given result and holding no lock.</summary>
/// <param name="result">The result to hand back to the caller.</param>
/// <returns>A prepared deployment whose <see cref="EarlyResult"/> is set.</returns>
public static PreparedDeployment Resolved(Result<DeploymentRecord> result) =>
new(null!, string.Empty, null!, string.Empty, string.Empty, string.Empty, null!, null, result);
}
/// <summary>
/// Result of phase 2: either the site's response or the exception that
/// prevented one. Exactly one is non-null.
/// </summary>
private readonly record struct SendOutcome(DeploymentStatusResponse? Response, Exception? Error);
/// <summary>
/// Deploy every deployable instance at a site in one operation.
///
/// <para>
/// Structured as the three deployment phases with the middle one fanned out:
/// </para>
/// <list type="number">
/// <item>
/// <b>Prepare (serial).</b> Every instance is flattened, validated, staged
/// and given an <c>InProgress</c> record on the caller's single scoped
/// <c>DbContext</c>. All instances share ONE <see cref="FlattenSession"/>,
/// so a template chain common to N instances is walked once, and the
/// session-global queries (shared scripts, schema library, the site's data
/// connections) run once for the whole batch instead of once per instance.
/// </item>
/// <item>
/// <b>Send (bounded parallel).</b> Site round-trips run concurrently up to
/// <see cref="DeploymentManagerOptions.SiteDeploymentMaxParallelism"/>,
/// each under its own
/// <see cref="DeploymentManagerOptions.SiteDeploymentTimeoutPerInstance"/>
/// deadline, so one wedged instance cannot stall the batch. This phase
/// touches no repository — that is precisely why it is the only phase that
/// may run in parallel against a non-thread-safe <c>DbContext</c>.
/// </item>
/// <item>
/// <b>Finalize (serial).</b> Terminal statuses, post-success side effects
/// and audit rows are committed one instance at a time, then each
/// instance's operation lock is released.
/// </item>
/// </list>
///
/// <para>
/// Semantics per instance are byte-for-byte those of
/// <see cref="DeployInstanceAsync"/> — its own deployment id and revision hash,
/// its own operation lock held across the whole operation, its own
/// optimistically-concurrent status record, and the same query-before-redeploy
/// idempotency check. Instances that cannot be deployed (wrong state, failed
/// validation, lock already held) are reported as failed rows; the batch is
/// NOT all-or-nothing, mirroring the artifact deployment path.
/// </para>
/// </summary>
/// <param name="siteId">The database ID of the site whose instances are deployed.</param>
/// <param name="user">The username initiating the deployment, recorded on every record and audit row.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A per-instance result matrix, or a failure result when the site itself cannot be resolved.</returns>
public async Task<Result<SiteDeploymentSummary>> DeploySiteAsync(
int siteId,
string user,
CancellationToken cancellationToken = default)
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
if (site == null)
return Result<SiteDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
if (instances.Count == 0)
return Result<SiteDeploymentSummary>.Success(new SiteDeploymentSummary(site.SiteIdentifier, [], 0, 0));
// ---- Phase 1: prepare, serially, on ONE shared flatten session. ----
var session = _flatteningPipeline.CreateSession();
var prepared = new List<PreparedDeployment>(instances.Count);
var results = new List<InstanceDeploymentResult>(instances.Count);
foreach (var instance in instances)
{
cancellationToken.ThrowIfCancellationRequested();
PreparedDeployment step;
try
{
step = await PrepareDeploymentAsync(instance.Id, user, session, cancellationToken);
}
catch (Exception ex)
{
// A prepare fault (most commonly a TimeoutException from the
// operation lock because another operation holds this instance)
// fails only this instance. Recording it and moving on is what
// makes a bulk deploy usable while individual instances are busy.
_logger.LogWarning(ex,
"Preparing instance {Instance} for bulk deployment of site {SiteId} failed",
instance.UniqueName, site.SiteIdentifier);
results.Add(new InstanceDeploymentResult(
instance.Id, instance.UniqueName, null, false, ex.Message));
continue;
}
if (step.EarlyResult is { } early)
{
results.Add(ToInstanceResult(instance.Id, instance.UniqueName, early));
continue;
}
prepared.Add(step);
}
// ---- Phase 2: bounded-parallel site round-trips. ----
var outcomes = await SendPreparedAsync(prepared, cancellationToken);
// ---- Phase 3: finalize, serially, releasing each lock as we go. ----
for (var i = 0; i < prepared.Count; i++)
{
var step = prepared[i];
try
{
var result = await FinalizeDeploymentAsync(step, outcomes[i], user, cancellationToken);
results.Add(ToInstanceResult(step.Instance.Id, step.Instance.UniqueName, result, step.DeploymentId));
}
finally
{
step.LockHandle?.Dispose();
}
}
var summary = new SiteDeploymentSummary(
site.SiteIdentifier,
results,
results.Count(r => r.Success),
results.Count(r => !r.Success));
_logger.LogInformation(
"Bulk deployment of site {SiteId} complete: {SuccessCount} succeeded, {FailureCount} failed " +
"({ChainLoads} template-chain load(s) across {InstanceCount} instance(s))",
site.SiteIdentifier, summary.SuccessCount, summary.FailureCount,
session.ChainLoads, instances.Count);
return Result<SiteDeploymentSummary>.Success(summary);
}
/// <summary>
/// Runs phase 2 for a whole batch: every prepared deployment's site round-trip,
/// concurrent up to <see cref="DeploymentManagerOptions.SiteDeploymentMaxParallelism"/>
/// and each bounded by
/// <see cref="DeploymentManagerOptions.SiteDeploymentTimeoutPerInstance"/>.
/// Outcomes are returned positionally so phase 3 can pair them back with their
/// prepared deployment.
/// </summary>
private async Task<SendOutcome[]> SendPreparedAsync(
IReadOnlyList<PreparedDeployment> prepared,
CancellationToken cancellationToken)
{
var outcomes = new SendOutcome[prepared.Count];
if (prepared.Count == 0)
return outcomes;
using var gate = new SemaphoreSlim(_options.SiteDeploymentMaxParallelism);
var sends = prepared.Select(async (step, index) =>
{
await gate.WaitAsync(cancellationToken);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.SiteDeploymentTimeoutPerInstance);
outcomes[index] = await SendDeploymentAsync(step, cts.Token);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(sends);
return outcomes;
}
/// <summary>Maps a per-instance deployment result into the bulk summary row shape.</summary>
private static InstanceDeploymentResult ToInstanceResult(
int instanceId,
string uniqueName,
Result<DeploymentRecord> result,
string? deploymentId = null) =>
result.IsSuccess
? new InstanceDeploymentResult(
instanceId, uniqueName, result.Value.DeploymentId, true, null)
: new InstanceDeploymentResult(
instanceId, uniqueName, deploymentId, false, result.Error);
/// <summary>
/// Opportunistic retention sweep for TERMINAL deployment records, rate-limited
/// to once every <see cref="TerminalPurgeMinInterval"/> per process and bounded
/// to <see cref="TerminalPurgeBatchesPerSweep"/> batches per call.
///
/// <para>
/// Swept on read (the deployment-list path) rather than from a timer for the
/// same reason expired secured writes are: the component owns no scheduling
/// infrastructure of its own, and the read path is exactly the operation that
/// suffers when the table is allowed to grow without bound. Both central nodes
/// may sweep; the delete is idempotent, so a duplicate pass is a no-op.
/// </para>
///
/// <para>
/// Best-effort: a failed sweep is logged and swallowed. Retention maintenance
/// must never fail the operator's actual query.
/// </para>
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of records purged by this call (0 when the sweep was skipped).</returns>
public async Task<int> TryPurgeTerminalDeploymentRecordsAsync(CancellationToken cancellationToken = default)
{
var retention = _options.TerminalDeploymentRecordRetention;
if (retention <= TimeSpan.Zero)
return 0;
var now = DateTimeOffset.UtcNow;
var last = Interlocked.Read(ref _lastTerminalPurgeTicks);
if (last != 0 && now.UtcTicks - last < TerminalPurgeMinInterval.Ticks)
return 0;
// Claim the sweep. A loser of this CAS returns immediately rather than
// running a redundant second pass.
if (Interlocked.CompareExchange(ref _lastTerminalPurgeTicks, now.UtcTicks, last) != last)
return 0;
var cutoff = now - retention;
var purged = 0;
try
{
for (var batch = 0; batch < TerminalPurgeBatchesPerSweep; batch++)
{
var deleted = await _repository.PurgeTerminalDeploymentRecordsAsync(
cutoff, TerminalPurgeBatchSize, cancellationToken);
purged += deleted;
if (deleted < TerminalPurgeBatchSize)
break;
}
if (purged > 0)
{
_logger.LogInformation(
"Purged {Count} terminal deployment record(s) completed before {Cutoff} " +
"(retention {Retention}).",
purged, cutoff, retention);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Terminal deployment-record retention sweep failed; retrying after {Interval}.",
TerminalPurgeMinInterval);
}
return purged;
}
/// <summary>
/// Disable an instance. Stops Instance Actor, retains config, S&amp;F drains.
/// </summary>
@@ -1178,3 +1670,37 @@ public record DeploymentComparisonResult(
bool IsStale,
DateTimeOffset DeployedAt,
ConfigurationDiff? Diff = null);
/// <summary>
/// One instance's outcome inside a bulk site deployment.
/// </summary>
/// <param name="InstanceId">Database id of the instance.</param>
/// <param name="UniqueName">The instance's unique name (also its operation-lock key).</param>
/// <param name="DeploymentId">
/// The deployment id minted for this instance, or <see langword="null"/> when the
/// instance never got that far (state-transition rejection, lock contention).
/// </param>
/// <param name="Success">Whether the site confirmed the apply.</param>
/// <param name="ErrorMessage">Failure detail; <see langword="null"/> on success.</param>
public record InstanceDeploymentResult(
int InstanceId,
string UniqueName,
string? DeploymentId,
bool Success,
string? ErrorMessage);
/// <summary>
/// Result matrix of a bulk site deployment. Mirrors the shape of
/// <c>ArtifactDeploymentSummary</c>: successes are not rolled back when other
/// instances fail, and each failed instance is individually retryable through the
/// ordinary single-instance deploy.
/// </summary>
/// <param name="SiteIdentifier">The site's string identifier (e.g. <c>site-a</c>).</param>
/// <param name="InstanceResults">Per-instance outcomes.</param>
/// <param name="SuccessCount">Number of instances the site confirmed.</param>
/// <param name="FailureCount">Number of instances that failed for any reason.</param>
public record SiteDeploymentSummary(
string SiteIdentifier,
IReadOnlyList<InstanceDeploymentResult> InstanceResults,
int SuccessCount,
int FailureCount);
@@ -0,0 +1,257 @@
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.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Memoises the repository reads that <see cref="FlatteningPipeline"/> performs,
/// for the lifetime of ONE flatten/validate session.
///
/// <para>
/// Without a session every instance flatten re-walks its whole template
/// inheritance chain (one query per link), re-loads the compositions of every
/// template in that chain and of every composed chain it reaches, and re-issues
/// the three session-global queries — shared scripts, the shared-schema library,
/// and the target site's data connections. For a bulk deploy of N instances off
/// the same template that is N× the same work; even a single instance re-loads a
/// composed chain once per composing template.
/// </para>
///
/// <para>
/// <b>Cache key.</b> Templates are keyed on <c>(id, ITemplateGraphWatermark
/// version)</c> and chain MEMBERSHIP additionally on the watermark's
/// <see cref="ITemplateGraphWatermark.StructureVersion"/>, so a template edited
/// mid-session is re-read rather than served from the memo. The session is also
/// short-lived by construction — it is created per deploy/validate operation and
/// discarded with it — so a cached graph can never outlive the operation that
/// captured it. That is the design's "template state is captured at the time of
/// flatten" guarantee, unchanged: the session narrows the capture window, it does
/// not widen it.
/// </para>
///
/// <para>
/// <b>Threading.</b> A session is NOT thread-safe and must not be shared across
/// threads. It caches entities materialised by one scoped <c>DbContext</c>, which
/// is itself single-threaded, and it hands out tracked entities that the
/// in-flight bundle importer relies on observing. <c>DeploySiteAsync</c> honours
/// this by running the whole prepare (flatten) phase serially on one scope and
/// parallelising only the site round-trips, which touch no repository at all.
/// </para>
/// </summary>
public sealed class FlattenSession
{
private readonly ITemplateGraphWatermark _watermark;
private readonly Dictionary<int, (long Version, Template Template)> _templates = new();
private readonly Dictionary<int, CachedChain> _chains = new();
private readonly Dictionary<int, (long Version, IReadOnlyList<TemplateComposition> Compositions)> _compositions = new();
private readonly Dictionary<int, IReadOnlyDictionary<int, DataConnection>> _dataConnections = new();
private IReadOnlyList<ResolvedScript>? _sharedScripts;
private IReadOnlyDictionary<string, string>? _schemaLibrary;
/// <summary>Initializes a session bound to the process-wide graph watermark.</summary>
/// <param name="watermark">Watermark supplying the per-template versions used as cache keys.</param>
public FlattenSession(ITemplateGraphWatermark watermark)
{
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// Number of template-chain walks that actually hit the repository. Reset
/// never; used by tests to assert that N instances sharing a template load
/// that chain exactly once.
/// </summary>
public int ChainLoads { get; private set; }
/// <summary>Number of single-template repository reads that missed the memo.</summary>
public int TemplateLoads { get; private set; }
/// <summary>Number of per-template composition queries that missed the memo.</summary>
public int CompositionLoads { get; private set; }
/// <summary>Number of times the session-global queries (shared scripts, schema library) ran.</summary>
public int GlobalLoads { get; private set; }
/// <summary>Number of per-site data-connection queries that missed the memo.</summary>
public int DataConnectionLoads { get; private set; }
/// <summary>
/// Every template id this session resolved — the union of all inheritance and
/// composed chains it walked. The staleness fast path records the watermark
/// version of exactly this set, so a change to ANY template that fed a
/// flatten invalidates the memoised revision hash.
/// </summary>
public IReadOnlyCollection<int> VisitedTemplateIds => _templates.Keys;
/// <summary>
/// Returns the inheritance chain for <paramref name="templateId"/> (the
/// template itself, then each ancestor), loading it through
/// <paramref name="loadTemplate"/> on a miss.
/// </summary>
/// <param name="templateId">Root of the chain to build.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The chain, ordered leaf-first.</returns>
public async Task<IReadOnlyList<Template>> GetChainAsync(
int templateId,
Func<int, Task<Template?>> loadTemplate)
{
var structure = _watermark.StructureVersion;
if (_chains.TryGetValue(templateId, out var cached) && IsChainCurrent(cached, structure))
return cached.Chain;
var chain = new List<Template>();
var memberVersions = new Dictionary<int, long>();
var currentId = (int?)templateId;
// Guard against a cyclic parent edge. Acyclicity is enforced on save, but
// this walk must not hang if a bad row ever reaches the database.
var seen = new HashSet<int>();
while (currentId.HasValue && seen.Add(currentId.Value))
{
memberVersions[currentId.Value] = _watermark.GetTemplateVersion(currentId.Value);
var template = await GetTemplateAsync(currentId.Value, loadTemplate);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
ChainLoads++;
_chains[templateId] = new CachedChain(structure, memberVersions, chain);
return chain;
}
/// <summary>
/// A memoised chain plus the exact watermark readings it was built under.
/// </summary>
/// <param name="Structure">Structure version, covering chain MEMBERSHIP.</param>
/// <param name="MemberVersions">Per-template version of every link, covering chain CONTENTS.</param>
/// <param name="Chain">The chain itself, ordered leaf-first.</param>
private sealed record CachedChain(
long Structure,
IReadOnlyDictionary<int, long> MemberVersions,
IReadOnlyList<Template> Chain);
/// <summary>
/// A cached chain is current only when BOTH the graph shape and every member's
/// own version are unchanged.
///
/// <para>
/// Both halves are load-bearing. Structure alone would miss an ordinary member
/// edit (an attribute added to a template already in the chain), which changes
/// what the flattener produces without changing which templates it walks.
/// Member versions alone would miss a re-parent, which changes membership
/// without touching any surviving member. Checking only one would let a session
/// serve a chain that no longer reflects the database.
/// </para>
/// </summary>
private bool IsChainCurrent(CachedChain cached, long structure)
{
if (cached.Structure != structure)
return false;
foreach (var (templateId, version) in cached.MemberVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
return true;
}
/// <summary>
/// Returns one template with its children, loading it through
/// <paramref name="loadTemplate"/> on a miss or when its watermark version moved.
/// </summary>
/// <param name="templateId">Template to read.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The template, or <see langword="null"/> when it does not exist.</returns>
public async Task<Template?> GetTemplateAsync(int templateId, Func<int, Task<Template?>> loadTemplate)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_templates.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Template;
var template = await loadTemplate(templateId);
TemplateLoads++;
if (template != null)
_templates[templateId] = (version, template);
return template;
}
/// <summary>
/// Returns the composition rows declared directly on <paramref name="templateId"/>.
/// </summary>
/// <param name="templateId">Template whose compositions are read.</param>
/// <param name="loadCompositions">Repository read for one template's compositions.</param>
/// <returns>The composition rows.</returns>
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsAsync(
int templateId,
Func<int, Task<IReadOnlyList<TemplateComposition>>> loadCompositions)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_compositions.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Compositions;
var compositions = await loadCompositions(templateId);
CompositionLoads++;
_compositions[templateId] = (version, compositions);
return compositions;
}
/// <summary>
/// Returns the site's data connections keyed by id. Cached per site for the
/// session — a bulk site deploy resolves them once for the whole batch.
/// </summary>
/// <param name="siteId">Site whose connections are read.</param>
/// <param name="loadConnections">Repository read for a site's data connections.</param>
/// <returns>The site's connections keyed by database id.</returns>
public async Task<IReadOnlyDictionary<int, DataConnection>> GetDataConnectionsAsync(
int siteId,
Func<int, Task<IReadOnlyDictionary<int, DataConnection>>> loadConnections)
{
if (_dataConnections.TryGetValue(siteId, out var cached))
return cached;
var connections = await loadConnections(siteId);
DataConnectionLoads++;
_dataConnections[siteId] = connections;
return connections;
}
/// <summary>
/// Returns the shared-script set used for semantic validation. Hoisted to the
/// session so a bulk deploy issues this query once instead of once per instance.
/// </summary>
/// <param name="loadSharedScripts">Repository read for all shared scripts.</param>
/// <returns>The resolved shared scripts.</returns>
public async Task<IReadOnlyList<ResolvedScript>> GetSharedScriptsAsync(
Func<Task<IReadOnlyList<ResolvedScript>>> loadSharedScripts)
{
if (_sharedScripts != null)
return _sharedScripts;
_sharedScripts = await loadSharedScripts();
GlobalLoads++;
return _sharedScripts;
}
/// <summary>
/// Returns the shared JSON-Schema library as a name → schema-JSON map, hoisted
/// to the session for the same reason as the shared scripts.
/// </summary>
/// <param name="loadSchemaLibrary">Repository read building the schema library map.</param>
/// <returns>The schema library.</returns>
public async Task<IReadOnlyDictionary<string, string>> GetSchemaLibraryAsync(
Func<Task<IReadOnlyDictionary<string, string>>> loadSchemaLibrary)
{
if (_schemaLibrary != null)
return _schemaLibrary;
_schemaLibrary = await loadSchemaLibrary();
GlobalLoads++;
return _schemaLibrary;
}
}
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
@@ -23,6 +24,7 @@ public class FlatteningPipeline : IFlatteningPipeline
private readonly ValidationService _validationService;
private readonly RevisionHashService _revisionHashService;
private readonly ISharedSchemaRepository _sharedSchemaRepo;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
@@ -35,13 +37,19 @@ public class FlatteningPipeline : IFlatteningPipeline
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
/// script schema becomes a deploy-blocking error.
/// </param>
/// <param name="watermark">
/// Process-wide template version watermark. Supplies the per-template version
/// used as the <see cref="FlattenSession"/> cache-key discriminator, so a
/// memoised chain is dropped the moment any of its templates is edited.
/// </param>
public FlatteningPipeline(
ITemplateEngineRepository templateRepo,
ISiteRepository siteRepo,
FlatteningService flatteningService,
ValidationService validationService,
RevisionHashService revisionHashService,
ISharedSchemaRepository sharedSchemaRepo)
ISharedSchemaRepository sharedSchemaRepo,
ITemplateGraphWatermark watermark)
{
_templateRepo = templateRepo;
_siteRepo = siteRepo;
@@ -49,21 +57,35 @@ public class FlatteningPipeline : IFlatteningPipeline
_validationService = validationService;
_revisionHashService = revisionHashService;
_sharedSchemaRepo = sharedSchemaRepo;
_watermark = watermark;
}
/// <inheritdoc />
public FlattenSession CreateSession() => new(_watermark);
/// <inheritdoc />
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default,
bool validateScripts = true)
bool validateScripts = true,
FlattenSession? session = null)
{
// A caller that supplies no session gets a private one. That still pays
// off inside a single flatten: the composed-chain walk below reaches the
// same template through several composing parents, and the memo collapses
// those to one load each.
session ??= CreateSession();
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
Task<Commons.Entities.Templates.Template?> LoadTemplate(int id) =>
_templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken);
// Build template chain
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);
if (templateChain.Count == 0)
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
@@ -84,7 +106,9 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (!processedTemplateIds.Add(template.Id)) continue;
var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken);
var compositions = await session.GetCompositionsAsync(
template.Id,
id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken));
if (compositions.Count == 0) continue;
compositionMap[template.Id] = compositions;
@@ -92,7 +116,7 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken);
var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate);
composedChains[comp.ComposedTemplateId] = composedChain;
pendingChains.Enqueue(composedChain);
}
@@ -100,7 +124,9 @@ public class FlatteningPipeline : IFlatteningPipeline
}
// Load data connections for the site
var dataConnections = await LoadDataConnectionsAsync(instance.SiteId, cancellationToken);
var dataConnections = await session.GetDataConnectionsAsync(
instance.SiteId,
siteId => LoadDataConnectionsAsync(siteId, cancellationToken));
// Flatten
var flattenResult = _flatteningService.Flatten(
@@ -111,15 +137,20 @@ public class FlatteningPipeline : IFlatteningPipeline
var config = flattenResult.Value;
// Load shared scripts for semantic validation
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
// Load shared scripts for semantic validation. Session-global: identical
// for every instance in a batch, so hoisted to one query per session
// (the ArtifactDeploymentService global-hoist shape).
var resolvedSharedScripts = await session.GetSharedScriptsAsync(async () =>
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
return (IReadOnlyList<ResolvedScript>)sharedScriptEntities.Select(s => new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
});
// Compute the alarm-capable connection-name set so the semantic validator
// can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
@@ -149,8 +180,13 @@ public class FlatteningPipeline : IFlatteningPipeline
// pre-loaded once into a name→JSON map here (avoiding sync-over-async) and the
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
// validated script schema then becomes a deploy-blocking SchemaReference error.
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
// Session-global, same as the shared scripts above.
var schemaLibrary = await session.GetSchemaLibraryAsync(async () =>
{
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
return (IReadOnlyDictionary<string, string>)sharedSchemas
.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
});
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
// Validate. This is the deploy-gating path, so connection-binding completeness
@@ -180,24 +216,6 @@ public class FlatteningPipeline : IFlatteningPipeline
new FlatteningPipelineResult(config, hash, validation));
}
private async Task<IReadOnlyList<Commons.Entities.Templates.Template>> BuildTemplateChainAsync(
int templateId,
CancellationToken cancellationToken)
{
var chain = new List<Commons.Entities.Templates.Template>();
var currentId = (int?)templateId;
while (currentId.HasValue)
{
var template = await _templateRepo.GetTemplateWithChildrenAsync(currentId.Value, cancellationToken);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
return chain;
}
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
int siteId,
CancellationToken cancellationToken)
@@ -23,11 +23,29 @@ public interface IFlatteningPipeline
/// which need only the flattened config + revision hash, not a compile. Structural and
/// semantic validation still run in both cases.
/// </param>
/// <param name="session">
/// Optional flatten session memoising template-chain, composition and
/// session-global repository reads. Pass one shared session when flattening
/// several instances in a batch (see <c>DeploymentService.DeploySiteAsync</c>)
/// so a template chain shared by N instances is walked once. When
/// <see langword="null"/> the pipeline creates a private single-use session,
/// which still de-duplicates the repeated composed-chain loads inside one
/// instance's flatten. Additive parameter — existing callers are unaffected.
/// </param>
/// <returns>A task that resolves to the flattened configuration, revision hash, and validation result; or a failure result if flattening could not complete.</returns>
Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default,
bool validateScripts = true);
bool validateScripts = true,
FlattenSession? session = null);
/// <summary>
/// Creates a fresh <see cref="FlattenSession"/> bound to this pipeline's
/// watermark, for a caller that wants to share one across a batch of
/// <see cref="FlattenAndValidateAsync"/> calls.
/// </summary>
/// <returns>A new, empty session.</returns>
FlattenSession CreateSession();
}
/// <summary>
@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -39,6 +41,12 @@ public static class ServiceCollectionExtensions
ServiceDescriptor.Singleton<IValidateOptions<DeploymentManagerOptions>, DeploymentManagerOptionsValidator>());
services.AddSingleton<OperationLockManager>();
// Template/instance version watermark backing the flatten-session cache key
// and the staleness fast path. TryAdd because AddConfigurationDatabase
// registers the same singleton — whichever composition root runs first wins
// and both observe one instance.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
// Push-based deployment-status notification. Registered
// as a singleton so the scoped DeploymentService and the Central UI's
// scoped Blazor page component share one instance — both run in the
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -10,21 +12,70 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
/// Transport component (which references only Commons + TemplateEngine) can probe
/// staleness without taking a DeploymentManager project reference.
///
/// <para>
/// <b>Watermark fast path.</b> A bundle import probes EVERY instance derived from
/// an overwritten template, and the Deployments page sweeps the fleet — both pay a
/// full flatten per instance for an answer that is usually "unchanged". A probe
/// result is therefore memoised against the
/// <see cref="ITemplateGraphWatermark"/> reading that produced it: the instance's
/// own version, the graph's structure version (which covers chain MEMBERSHIP), and
/// the version of every template the flatten actually walked. When all of those are
/// unchanged the recorded hash is still current by construction and the flatten is
/// skipped.
/// </para>
///
/// <para>
/// The fast path can only ever cause EXTRA work, never stale work: the watermark is
/// in-memory and process-local, so a restart, a failover to the other central node,
/// or any mutation at all simply misses and falls back to the authoritative
/// flatten.
/// </para>
/// </summary>
public sealed class StaleInstanceProbe : IStaleInstanceProbe
{
/// <summary>
/// Ceiling on memoised probe results. Bounded because the key space is the
/// instance table and this cache is process-wide and static; on overflow it is
/// dropped wholesale, which is safe here (unlike the script-compile verdict
/// cache) because a miss costs one flatten and leaks nothing.
/// </summary>
private const int MaxCachedProbes = 4096;
private static readonly ConcurrentDictionary<int, ProbeMemo> Memos = new();
private readonly IFlatteningPipeline _flatteningPipeline;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
/// <param name="watermark">Graph version watermark backing the fast path.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline, ITemplateGraphWatermark watermark)
{
_flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// A previously computed revision hash together with the exact watermark
/// readings it was computed under.
/// </summary>
/// <param name="Structure">Graph structure version at flatten time.</param>
/// <param name="InstanceVersion">The instance's own version at flatten time.</param>
/// <param name="TemplateVersions">Version of every template the flatten walked, by template id.</param>
/// <param name="RevisionHash">The hash that reading produced.</param>
private sealed record ProbeMemo(
long Structure,
long InstanceVersion,
IReadOnlyDictionary<int, long> TemplateVersions,
string RevisionHash);
/// <inheritdoc />
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
{
if (TryGetMemoisedHash(instanceId, out var memoised))
return memoised;
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
// template chain mid-import) yields null so the caller treats the
// instance as "staleness indeterminate" and skips it. Flattening reuses
@@ -33,9 +84,63 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
// Staleness only needs the revision hash — skip the expensive Roslyn
// script-compilation stage (this probe is called per-instance across a whole
// bundle import in BundleImporter.ComputeStaleInstanceIdsAsync).
//
// Watermark readings are taken BEFORE the flatten. A mutation that lands
// during the flatten therefore bumps a version past the recorded one and
// invalidates the memo on the next read, rather than being captured as if
// it had already been included.
var structure = _watermark.StructureVersion;
var instanceVersion = _watermark.GetInstanceVersion(instanceId);
var session = _flatteningPipeline.CreateSession();
var result = await _flatteningPipeline
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false, session)
.ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
if (result.IsFailure)
return null;
if (Memos.Count >= MaxCachedProbes)
Memos.Clear();
var visited = session.VisitedTemplateIds;
var versions = new Dictionary<int, long>(visited.Count);
foreach (var templateId in visited)
versions[templateId] = _watermark.GetTemplateVersion(templateId);
Memos[instanceId] = new ProbeMemo(structure, instanceVersion, versions, result.Value.RevisionHash);
return result.Value.RevisionHash;
}
/// <summary>
/// Returns the memoised revision hash when every watermark reading it was
/// computed under is still current.
/// </summary>
private bool TryGetMemoisedHash(int instanceId, out string? hash)
{
hash = null;
if (!Memos.TryGetValue(instanceId, out var memo))
return false;
if (memo.Structure != _watermark.StructureVersion)
return false;
if (memo.InstanceVersion != _watermark.GetInstanceVersion(instanceId))
return false;
foreach (var (templateId, version) in memo.TemplateVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
hash = memo.RevisionHash;
return true;
}
/// <summary>
/// Drops every memoised probe result. Test hook — production invalidation is
/// driven entirely by the watermark.
/// </summary>
public static void ClearMemos() => Memos.Clear();
}