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_StaysWithinTheBound() { SiteScriptCompileCache.Clear(); for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++) SiteScriptCompileCache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries); } // ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ────────── /// /// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries. /// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor /// start paid a full recompile storm on actor threads. /// [Fact] public void Overflow_EvictsOldestBatch_AndKeepsRecentlyTouchedEntries() { // Note: the cache is process-wide static and other test classes compile into it // concurrently, so the assertions below are phrased as properties of the eviction // policy (what survives, what does not, nothing is wiped) rather than as exact // counts, which no test can own here. SiteScriptCompileCache.Clear(); // Fill to the bound. Entries 1..N are inserted oldest-first. for (var i = 0; i < SiteScriptCompileCache.MaxEntries; i++) SiteScriptCompileCache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); // Touch entry 0 so it carries the NEWEST access stamp despite being inserted first. var hot = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals), Ok); var hitsAfterTouch = SiteScriptCompileCache.Hits; // Push well past the bound so at least one eviction sweep definitely runs. for (var i = 0; i < 200; i++) SiteScriptCompileCache.GetOrAdd($"return overflow{i};", typeof(ScriptGlobals), Ok); // Nothing was cleared wholesale: the cache is still most of the way full and the hit // counter survived (Clear() would have reset it to 0). Assert.True(SiteScriptCompileCache.Count > SiteScriptCompileCache.MaxEntries / 2, $"cache collapsed to {SiteScriptCompileCache.Count} entries — this looks like a wholesale clear"); Assert.True(SiteScriptCompileCache.Hits >= hitsAfterTouch); // The touched entry survived the sweep — a hit, not a recompile. This is the LRU // property: recency, not insertion order, decides what stays. var hitsBefore = SiteScriptCompileCache.Hits; var again = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals), () => throw new InvalidOperationException("hot entry was evicted — LRU is not keeping recently-used entries")); Assert.Same(hot, again); Assert.Equal(hitsBefore + 1, SiteScriptCompileCache.Hits); // …and untouched old entries genuinely were evicted, so the sweep really ran. var recomputed = 0; for (var i = 1; i <= 16; i++) { var code = $"return {i};"; SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), () => { recomputed++; return Ok(); }); } Assert.True(recomputed > 0, "no old entry was evicted — the overflow sweep did not run"); } /// /// WP3.1: concurrent inserts crossing the bound together must not blow past it — the /// eviction sweep is double-checked under its own lock precisely so a stampede performs /// one sweep rather than one per racing thread. /// [Fact] public async Task ConcurrentGetOrAddStorm_KeepsTheBound() { SiteScriptCompileCache.Clear(); const int perTask = 400; var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() => { for (var i = 0; i < perTask; i++) SiteScriptCompileCache.GetOrAdd($"return {t}_{i};", typeof(ScriptGlobals), Ok); })); await Task.WhenAll(tasks); // A small transient overshoot is by design: threads that lose the double-checked // eviction race still insert their own entry afterwards, so the ceiling is "bounded", // not "never exceeded by one". The property under test is that 3200 distinct inserts // do not accumulate — before WP3.1 this was a wholesale Clear(), and a per-entry // eviction bug would show up here as unbounded growth, not as a handful of extra rows. // (Other test classes compile into this process-wide cache concurrently, which is the // other reason the bound is asserted with slack rather than exactly.) Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + 512, $"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries} " + $"after {8 * perTask} distinct inserts"); } }