feat(siteruntime): bounded process-wide compiled-script cache, keyed on code hash + globals type (plan R2-03 T5)
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cache of site-side script <see cref="ScriptCompilationResult"/>s, keyed on
|
||||
/// the globals type plus a SHA-256 hash of the script code. Unlike the TemplateEngine's
|
||||
/// <c>ScriptCompileVerdictCache</c> (which caches central-validation <em>verdicts</em>), this
|
||||
/// caches the full compiled result — the immutable Roslyn <c>Script<object?></c> itself —
|
||||
/// so the synchronous deploy gate and the <c>InstanceActor.PreStart</c> recompile share one
|
||||
/// Roslyn compile per script body per process lifetime (N4).
|
||||
///
|
||||
/// <para>Soundness rests on three invariants:</para>
|
||||
/// <list type="number">
|
||||
/// <item>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.</item>
|
||||
/// <item>The stored error text is name-free (Roslyn <c>Diagnostic.GetMessage()</c> /
|
||||
/// <c>"Compilation exception: …"</c>); callers prefix their own script-name context, so a
|
||||
/// shared cached failure renders correctly for every caller. <em>Failures are cached too</em> —
|
||||
/// a bad script body fails identically for every caller.</item>
|
||||
/// <item>Roslyn <c>Script<T></c> is immutable and thread-safe (<c>RunAsync</c> creates
|
||||
/// per-run state), so one compiled instance may be shared across actors and threads.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Bounded at <see cref="MaxEntries"/> 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).
|
||||
/// <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/> are exposed for tests and diagnostics.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SiteScriptCompileCache
|
||||
{
|
||||
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
|
||||
internal const int MaxEntries = 1024;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, ScriptCompilationResult> Cache = new();
|
||||
private static long _hits;
|
||||
|
||||
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
|
||||
public static long Hits => Interlocked.Read(ref _hits);
|
||||
|
||||
/// <summary>Current number of cached compile results.</summary>
|
||||
public static int Count => Cache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached compile result for <paramref name="code"/> against
|
||||
/// <paramref name="globalsType"/>, or computes it via <paramref name="factory"/> and caches
|
||||
/// it on a miss. A hit increments <see cref="Hits"/>. Both success and failure results are
|
||||
/// cached — the error text is name-free by construction.
|
||||
/// </summary>
|
||||
/// <param name="code">The script source code to look up (hashed to form the cache key).</param>
|
||||
/// <param name="globalsType">The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct.</param>
|
||||
/// <param name="factory">Computes the compile result on a cache miss.</param>
|
||||
/// <returns>The cached or newly computed compile result.</returns>
|
||||
public static ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func<ScriptCompilationResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>Clears all cached compile results and resets the hit counter.</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
Cache.Clear();
|
||||
Interlocked.Exchange(ref _hits, 0);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the process-wide <see cref="SiteScriptCompileCache"/>. The cache is
|
||||
/// static/process-wide state, so this class shares the "SiteScriptCompileCache" xunit
|
||||
/// collection with <c>ScriptCompilationServiceTests</c> (no cross-class parallelism) and
|
||||
/// calls <see cref="SiteScriptCompileCache.Clear"/> at the top of each test.
|
||||
/// </summary>
|
||||
[Collection("SiteScriptCompileCache")]
|
||||
public class SiteScriptCompileCacheTests
|
||||
{
|
||||
private static ScriptCompilationResult Ok() =>
|
||||
ScriptCompilationResult.Succeeded(CSharpScript.Create<object?>("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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user