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()
{