perf(siteruntime): deploy gate + InstanceActor PreStart share one Roslyn compile via the compile cache (plan R2-03 T6)

This commit is contained in:
Joseph Doherty
2026-07-13 09:54:49 -04:00
parent 1cc05f132f
commit 128d2bab73
3 changed files with 60 additions and 3 deletions
@@ -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)
{
@@ -13,6 +13,13 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <see cref="ScriptTrustValidator"/>; this service keeps the real
/// execution-path compile of the script against <see cref="ScriptGlobals"/> /
/// <see cref="TriggerExpressionGlobals"/>.
///
/// Compile results — success AND failure — are memoised process-wide in
/// <see cref="SiteScriptCompileCache"/> (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 <c>Script&lt;object?&gt;</c>. Error text is name-free by construction, so a
/// cached failure renders correctly for every caller.
/// </summary>
public class ScriptCompilationService
{
@@ -92,10 +99,33 @@ public class ScriptCompilationService
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
/// <summary>
/// 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 <see cref="SiteScriptCompileCache"/>,
/// keyed on code hash + globals type: an unchanged script body compiles once per
/// process lifetime and the compiled <c>Script&lt;object?&gt;</c> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private ScriptCompilationResult CompileUncached(string name, string code, Type globalsType)
{
// Validate trust model
var violations = ValidateTrustModel(code);
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <c>Stopwatch</c> stays allowed. The real execution-path compile against
/// <c>ScriptGlobals</c> / <c>TriggerExpressionGlobals</c> is unchanged.
/// </summary>
[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<T> (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()
{