diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs new file mode 100644 index 00000000..0b025af8 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +/// +/// Process-wide cache of site-side script s, keyed on +/// the globals type plus a SHA-256 hash of the script code. Unlike the TemplateEngine's +/// ScriptCompileVerdictCache (which caches central-validation verdicts), this +/// caches the full compiled result — the immutable Roslyn Script<object?> itself — +/// so the synchronous deploy gate and the InstanceActor.PreStart recompile share one +/// Roslyn compile per script body per process lifetime (N4). +/// +/// Soundness rests on three invariants: +/// +/// A compile result is a pure function of (code, globals type, compile-time-static +/// trust policy) — none of which vary at runtime for a given key. +/// The stored error text is name-free (Roslyn Diagnostic.GetMessage() / +/// "Compilation exception: …"); callers prefix their own script-name context, so a +/// shared cached failure renders correctly for every caller. Failures are cached too — +/// a bad script body fails identically for every caller. +/// Roslyn Script<T> is immutable and thread-safe (RunAsync creates +/// per-run state), so one compiled instance may be shared across actors and threads. +/// +/// +/// +/// Bounded at entries — deliberately smaller than the verdict cache's +/// 4096 because entries pin compiled assemblies, not just verdict strings. On overflow the cache +/// is cleared wholesale (results are recomputable, so a coarse reset avoids eviction bookkeeping). +/// // are exposed for tests and diagnostics. +/// +/// +internal static class SiteScriptCompileCache +{ + /// Upper bound on cached entries; the cache is cleared wholesale on overflow. + internal const int MaxEntries = 1024; + + private static readonly ConcurrentDictionary Cache = new(); + private static long _hits; + + /// Number of cache hits observed since the last . + public static long Hits => Interlocked.Read(ref _hits); + + /// Current number of cached compile results. + public static int Count => Cache.Count; + + /// + /// Returns the cached compile result for against + /// , or computes it via and caches + /// it on a miss. A hit increments . Both success and failure results are + /// cached — the error text is name-free by construction. + /// + /// The script source code to look up (hashed to form the cache key). + /// The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct. + /// Computes the compile result on a cache miss. + /// The cached or newly computed compile result. + public static ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func factory) + { + var key = globalsType.FullName + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code))); + + if (Cache.TryGetValue(key, out var result)) + { + Interlocked.Increment(ref _hits); + return result; + } + + result = factory(); + + // Coarse bound: on overflow drop everything rather than track evictions. + if (Cache.Count >= MaxEntries) + Cache.Clear(); + + Cache[key] = result; + return result; + } + + /// Clears all cached compile results and resets the hit counter. + public static void Clear() + { + Cache.Clear(); + Interlocked.Exchange(ref _hits, 0); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs new file mode 100644 index 00000000..435863a9 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// +/// Tests for the process-wide . The cache is +/// static/process-wide state, so this class shares the "SiteScriptCompileCache" xunit +/// collection with ScriptCompilationServiceTests (no cross-class parallelism) and +/// calls at the top of each test. +/// +[Collection("SiteScriptCompileCache")] +public class SiteScriptCompileCacheTests +{ + private static ScriptCompilationResult Ok() => + ScriptCompilationResult.Succeeded(CSharpScript.Create("1", ScriptOptions.Default, typeof(ScriptGlobals))); + + [Fact] + public void GetOrAdd_SameCodeAndGlobals_ComputesOnce() + { + SiteScriptCompileCache.Clear(); + var factoryCalls = 0; + ScriptCompilationResult Factory() { factoryCalls++; return Ok(); } + + var r1 = SiteScriptCompileCache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory); + var r2 = SiteScriptCompileCache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory); + + Assert.Equal(1, factoryCalls); + Assert.Same(r1, r2); + Assert.Equal(1, SiteScriptCompileCache.Hits); + } + + [Fact] + public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries() + { + SiteScriptCompileCache.Clear(); + SiteScriptCompileCache.GetOrAdd("1 > 0", typeof(ScriptGlobals), Ok); + SiteScriptCompileCache.GetOrAdd("1 > 0", typeof(TriggerExpressionGlobals), Ok); + + Assert.Equal(2, SiteScriptCompileCache.Count); + Assert.Equal(0, SiteScriptCompileCache.Hits); // no cross-globals hit + } + + [Fact] + public void Overflow_ClearsWholesale() + { + SiteScriptCompileCache.Clear(); + for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++) + SiteScriptCompileCache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); + + Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries); + } +}