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 . /// /// /// /// Deferred-work register row 100 — the cache is no longer static. Every test here builds /// its OWN , so and /// count this test's compiles and nothing else. That /// retires three workarounds this file used to carry: /// /// /// the [Collection("SiteScriptCompileCache")] serialisation with /// ScriptCompilationServiceTests and DeploymentWarmThenGateTests — those three /// classes shared a mutable global and had to be kept off each other; they no longer touch any /// shared state and run fully in parallel again; /// the per-test GUID key token (register row 91) — no other class can reach this cache at /// all, so keys need not be globally unique; /// the one-sided counter bounds and the ForeignEntrySlack = 512 allowance on /// Count — foreign inserts are impossible now, so every assertion below is exact. /// /// /// The old escape hatch, Clear(), is gone from the type: it had no production caller and /// existed only to reset the global between tests. Constructing a cache is the reset. /// /// public class SiteScriptCompileCacheTests { /// The cache under test. Private to this test instance — xunit builds one per test. private readonly SiteScriptCompileCache _cache = new(); private static ScriptCompilationResult Ok() => ScriptCompilationResult.Succeeded(CSharpScript.Create("1", ScriptOptions.Default, typeof(ScriptGlobals))); [Fact] public void GetOrAdd_SameCodeAndGlobals_ComputesOnce() { var factoryCalls = 0; ScriptCompilationResult Factory() { factoryCalls++; return Ok(); } var r1 = _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory); var r2 = _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory); Assert.Equal(1, factoryCalls); Assert.Same(r1, r2); Assert.Equal(1, _cache.Hits); Assert.Equal(1, _cache.Count); } [Fact] public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries() { var scriptCompiles = 0; var triggerCompiles = 0; var script = _cache.GetOrAdd("1 > 0", typeof(ScriptGlobals), () => { scriptCompiles++; return Ok(); }); var trigger = _cache.GetOrAdd("1 > 0", typeof(TriggerExpressionGlobals), () => { triggerCompiles++; return Ok(); }); Assert.Equal(1, scriptCompiles); Assert.Equal(1, triggerCompiles); Assert.NotSame(script, trigger); // Two distinct entries and no cross-globals hit — exact, because this cache is ours. Assert.Equal(2, _cache.Count); Assert.Equal(0, _cache.Hits); // Each entry stays independently addressable afterwards. Assert.Same(script, _cache.GetOrAdd("1 > 0", typeof(ScriptGlobals), () => throw new InvalidOperationException("script entry was not cached"))); Assert.Same(trigger, _cache.GetOrAdd("1 > 0", typeof(TriggerExpressionGlobals), () => throw new InvalidOperationException("trigger entry was not cached"))); } /// /// Register row 100: two caches are genuinely independent stores. This is the property the /// whole change exists for — one test class's cache activity can no longer evict, serve, or /// be counted by another's. It is the direct replacement for the old /// Clear()-under-a-serialized-collection arrangement. /// [Fact] public void SeparateCaches_DoNotShareEntriesOrCounters() { var other = new SiteScriptCompileCache(); var mine = _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Ok); _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Ok); // a hit on OUR cache // The other cache has neither the entry nor the hit. Assert.Equal(0, other.Count); Assert.Equal(0, other.Hits); // Same key, other store: it must compute its own value, and get a distinct instance. var theirCompiles = 0; var theirs = other.GetOrAdd("return 1;", typeof(ScriptGlobals), () => { theirCompiles++; return Ok(); }); Assert.Equal(1, theirCompiles); Assert.NotSame(mine, theirs); // …and filling the other cache past its bound — the closest thing left to the old // wholesale Clear() — leaves ours completely untouched. for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++) other.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); Assert.Equal(1, _cache.Count); Assert.Equal(1, _cache.Hits); Assert.Same(mine, _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), () => throw new InvalidOperationException("our entry was evicted by another cache"))); } [Fact] public void Overflow_StaysWithinTheBound() { for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++) _cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); // MaxEntries + 1 distinct inserts, single-threaded: the sweep ran and the ceiling holds // exactly (no foreign inserts can land here any more — register row 100). Assert.True(_cache.Count <= SiteScriptCompileCache.MaxEntries, $"cache grew past its bound: {_cache.Count} vs max {SiteScriptCompileCache.MaxEntries}"); var recomputed = 0; for (var i = 0; i < 16; i++) _cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), () => { recomputed++; return Ok(); }); Assert.True(recomputed > 0, "no oldest-batch entry was evicted — the overflow sweep did not run"); } // ── 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() { // Fill to the bound. Entries 1..N are inserted oldest-first. for (var i = 0; i < SiteScriptCompileCache.MaxEntries; i++) _cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok); // Touch entry 0 so it carries the NEWEST access stamp despite being inserted first. var hot = _cache.GetOrAdd("return 0;", typeof(ScriptGlobals), Ok); var hitsAfterTouch = _cache.Hits; Assert.Equal(1, hitsAfterTouch); // exactly one hit so far: the touch // Push well past the bound so at least one eviction sweep definitely runs. for (var i = 0; i < 200; i++) _cache.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 (a wholesale clear used to reset it to 0). Assert.True(_cache.Count > SiteScriptCompileCache.MaxEntries / 2, $"cache collapsed to {_cache.Count} entries — this looks like a wholesale clear"); Assert.Equal(hitsAfterTouch, _cache.Hits); // 200 distinct inserts, none of them hits // The touched entry survived the sweep — a hit, not a recompile. This is the LRU // property: recency, not insertion order, decides what stays. var again = _cache.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(hitsAfterTouch + 1, _cache.Hits); // …and untouched old entries genuinely were evicted, so the sweep really ran. var recomputed = 0; for (var i = 1; i <= 16; i++) _cache.GetOrAdd($"return {i};", 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() { const int perTask = 400; const int tasks = 8; await Task.WhenAll(Enumerable.Range(0, tasks).Select(t => Task.Run(() => { for (var i = 0; i < perTask; i++) _cache.GetOrAdd($"return {t}_{i};", typeof(ScriptGlobals), Ok); }))); // 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 slack is bounded by the number of racing threads // (register row 100 — it used to be 512, to absorb inserts from other test classes // sharing the process-global cache; nothing foreign can reach this instance). // 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. Assert.True(_cache.Count <= SiteScriptCompileCache.MaxEntries + tasks, $"cache grew past its bound: {_cache.Count} vs max {SiteScriptCompileCache.MaxEntries} " + $"(+{tasks} racing-insert slack) after {tasks * perTask} distinct inserts"); } }