perf(deployment): staleness/comparison paths no longer Roslyn-compile every script — deploy gate remains the authoritative compile

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 15:48:45 -04:00
parent 730bf19162
commit 854a6a8c0b
9 changed files with 226 additions and 15 deletions
@@ -648,8 +648,12 @@ public class DeploymentService
if (snapshot == null)
return Result<DeploymentComparisonResult>.Failure("No deployed snapshot found for this instance.");
// Compute current template-derived config
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
// Compute current template-derived config. This read-only comparison only needs
// the revision hash (staleness) + the flattened config (structured diff) — NOT a
// script compile — so skip the expensive Roslyn ValidateScriptCompilation stage.
// The deploy gate remains the authoritative compile.
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: false);
if (currentResult.IsFailure)
return Result<DeploymentComparisonResult>.Failure($"Cannot compute current config: {currentResult.Error}");
@@ -54,7 +54,8 @@ public class FlatteningPipeline : IFlatteningPipeline
/// <inheritdoc />
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
bool validateScripts = true)
{
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
@@ -158,13 +159,19 @@ public class FlatteningPipeline : IFlatteningPipeline
// on the site — blocks the deployment. (The template DESIGN-TIME validate path in
// ManagementActor leaves this non-blocking by NOT enforcing, since bindings are
// set later at instance/deploy time.)
// validateScripts == false is the read-only staleness/comparison path: skip
// only the expensive Roslyn ValidateScriptCompilation (which loads a
// non-collectible InteractiveAssemblyLoader assembly per script). Structural
// and semantic validation still run so a genuinely broken flatten is surfaced;
// the deploy path (validateScripts: true) remains the authoritative compile gate.
var validation = _validationService.Validate(
config,
resolvedSharedScripts,
alarmCapableConnectionNames,
enforceConnectionBindings: true,
siteConnectionNames: siteConnectionNames,
resolveSchemaRef: resolveSchemaRef);
resolveSchemaRef: resolveSchemaRef,
validateScriptCompilation: validateScripts);
// Compute revision hash
var hash = _revisionHashService.ComputeHash(config);
@@ -15,10 +15,19 @@ public interface IFlatteningPipeline
/// </summary>
/// <param name="instanceId">Id of the instance to flatten and validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="validateScripts">
/// When <c>true</c> (the deploy default), the authoritative Roslyn script-compilation
/// gate runs. When <c>false</c>, that compile step is skipped — used by the read-only
/// staleness/comparison paths (Deployments comparison page,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport.IStaleInstanceProbe"/>)
/// which need only the flattened config + revision hash, not a compile. Structural and
/// semantic validation still run in both cases.
/// </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);
CancellationToken cancellationToken = default,
bool validateScripts = true);
}
/// <summary>
@@ -30,8 +30,11 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
// instance as "staleness indeterminate" and skips it. Flattening reuses
// the scoped ITemplateEngineRepository, so it observes template rows the
// in-flight import has staged on the shared change tracker.
// 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).
var result = await _flatteningPipeline
.FlattenAndValidateAsync(instanceId, cancellationToken)
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
.ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
}
@@ -95,6 +95,14 @@ public class ValidationService
/// <c>$ref</c> are unaffected (behavior unchanged), and a <c>$ref</c> with no
/// resolver is treated as dangling (the safe option).
/// </param>
/// <param name="validateScriptCompilation">
/// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
/// stage — the single most expensive check (a non-collectible assembly load per script).
/// <c>false</c> skips ONLY that stage; every other structural/semantic check still runs.
/// Read-only staleness/comparison callers (DeploymentManager's FlatteningPipeline on its
/// comparison/probe paths) pass <c>false</c> because they need only the flattened config and
/// revision hash, not a compile. The deploy gate keeps the default (<c>true</c>).
/// </param>
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
public ValidationResult Validate(
FlattenedConfiguration configuration,
@@ -102,7 +110,8 @@ public class ValidationService
IReadOnlySet<string>? alarmCapableConnectionNames = null,
bool enforceConnectionBindings = false,
IReadOnlySet<string>? siteConnectionNames = null,
Func<string, string?>? resolveSchemaRef = null)
Func<string, string?>? resolveSchemaRef = null,
bool validateScriptCompilation = true)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -110,7 +119,13 @@ public class ValidationService
{
ValidateFlatteningSuccess(configuration),
ValidateNamingCollisions(configuration),
ValidateScriptCompilation(configuration),
// The Roslyn compile is the single most expensive validation stage (a
// non-collectible InteractiveAssemblyLoader assembly load per script).
// Read-only staleness/comparison callers pass validateScriptCompilation:
// false to skip ONLY this stage; every other structural/semantic check
// still runs. The deploy gate keeps the default (true) — it is the
// authoritative compile.
validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),