diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index d5aa014c..e360dffc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -518,7 +518,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // delete/disable commands, breaking that ordering. A deploy is an // infrequent admin command, so briefly holding the singleton for a pure // Roslyn compile is acceptable; the central deployer already Asks and - // waits for the DeploymentStatusResponse. + // waits for the DeploymentStatusResponse. Redeploys and multi-instance + // deploys of unchanged scripts hit the process-wide compile cache + // (SiteScriptCompileCache), so the synchronous gate recompiles only + // genuinely new code and the Instance Actor's PreStart reuses the gate's + // compile (N4). var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson); if (errors.Count > 0) { diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs index d08692d9..485323f3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -13,6 +13,13 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// ; this service keeps the real /// execution-path compile of the script against / /// . +/// +/// Compile results — success AND failure — are memoised process-wide in +/// (keyed on code hash + globals type), so an +/// unchanged script body compiles once per process lifetime and the deploy gate, +/// the Instance Actor's PreStart recompile, and SharedScriptLibrary all reuse one +/// compiled Script<object?>. Error text is name-free by construction, so a +/// cached failure renders correctly for every caller. /// public class ScriptCompilationService { @@ -92,10 +99,33 @@ public class ScriptCompilationService => CompileCore(name, expression, typeof(TriggerExpressionGlobals)); /// - /// Shared compilation path: validates the trust model, builds the script - /// against the given globals type, and returns the compiled result. + /// Shared compilation funnel for every site-side compile (the deploy gate, the + /// Instance Actor's PreStart recompile, and SharedScriptLibrary). Results — success + /// AND failure — are memoised process-wide in , + /// keyed on code hash + globals type: an unchanged script body compiles once per + /// process lifetime and the compiled Script<object?> is shared across all + /// call sites (N4). Cached error text is name-free by construction (Roslyn diagnostics / + /// the compile-exception message never embed the caller's script name), so a cached + /// failure renders correctly for every caller. /// private ScriptCompilationResult CompileCore(string name, string code, Type globalsType) + { + var result = SiteScriptCompileCache.GetOrAdd(code, globalsType, + () => CompileUncached(name, code, globalsType)); + if (!result.IsSuccess) + _logger.LogDebug( + "Script {Script}: returning cached compile failure ({ErrorCount} error(s))", + name, result.Errors.Count); // miss-path logs name the FIRST caller; keep this caller visible + return result; + } + + /// + /// The uncached compile: validates the trust model, builds the script against the + /// given globals type, and returns the compiled result. The verdict is deterministic + /// on the code (plus the compile-time-static trust policy and globals surface), so it + /// is safe to cache alongside the compiled script. + /// + private ScriptCompilationResult CompileUncached(string name, string code, Type globalsType) { // Validate trust model var violations = ValidateTrustModel(code); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs index f02aca5d..d654ff23 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs @@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// Stopwatch stays allowed. The real execution-path compile against /// ScriptGlobals / TriggerExpressionGlobals is unchanged. /// +[Collection("SiteScriptCompileCache")] public class ScriptCompilationServiceTests { private readonly ScriptCompilationService _service; @@ -34,6 +35,28 @@ public class ScriptCompilationServiceTests Assert.Empty(result.Errors); } + [Fact] + public void Compile_SameCodeTwice_SharesOneRoslynCompile() + { + SiteScriptCompileCache.Clear(); + var r1 = _service.Compile("deploy-gate-copy", "return 1 + 1;"); + var r2 = _service.Compile("prestart-copy", "return 1 + 1;"); + + Assert.True(r1.IsSuccess); + Assert.Same(r1.CompiledScript, r2.CompiledScript); // one compile, shared Script (N4) + Assert.Equal(1, SiteScriptCompileCache.Hits); + } + + [Fact] + public void Compile_ScriptAndTriggerExpression_DoNotCrossContaminate() + { + SiteScriptCompileCache.Clear(); + var script = _service.Compile("s", "1 > 0"); + var trigger = _service.CompileTriggerExpression("t", "1 > 0"); + + Assert.NotSame(script.CompiledScript, trigger.CompiledScript); // different globals surfaces + } + [Fact] public void Compile_InvalidSyntax_ReturnsErrors() {