513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
213 lines
10 KiB
C#
213 lines
10 KiB
C#
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 <see cref="SiteScriptCompileCache"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Deferred-work register row 100 — the cache is no longer static.</b> Every test here builds
|
|
/// its OWN <see cref="SiteScriptCompileCache"/>, so <see cref="SiteScriptCompileCache.Hits"/> and
|
|
/// <see cref="SiteScriptCompileCache.Count"/> count this test's compiles and nothing else. That
|
|
/// retires three workarounds this file used to carry:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item>the <c>[Collection("SiteScriptCompileCache")]</c> serialisation with
|
|
/// <c>ScriptCompilationServiceTests</c> and <c>DeploymentWarmThenGateTests</c> — 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;</item>
|
|
/// <item>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;</item>
|
|
/// <item>the one-sided counter bounds and the <c>ForeignEntrySlack = 512</c> allowance on
|
|
/// <c>Count</c> — foreign inserts are impossible now, so every assertion below is exact.</item>
|
|
/// </list>
|
|
/// <para>
|
|
/// The old escape hatch, <c>Clear()</c>, 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class SiteScriptCompileCacheTests
|
|
{
|
|
/// <summary>The cache under test. Private to this test instance — xunit builds one per test.</summary>
|
|
private readonly SiteScriptCompileCache _cache = new();
|
|
|
|
private static ScriptCompilationResult Ok() =>
|
|
ScriptCompilationResult.Succeeded(CSharpScript.Create<object?>("1", ScriptOptions.Default, typeof(ScriptGlobals)));
|
|
|
|
/// <summary>Verifies that GetOrAdd with the same code and globals computes the value only once.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that GetOrAdd with the same code but different globals creates separate entries.</summary>
|
|
[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")));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>Clear()</c>-under-a-serialized-collection arrangement.
|
|
/// </summary>
|
|
[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")));
|
|
}
|
|
|
|
/// <summary>Verifies that overflowing the cache keeps it within its bound.</summary>
|
|
[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");
|
|
}
|
|
|
|
// ── Approximate LRU replaced the wholesale-Clear overflow cliff ──────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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
|
|
// the change to approximate LRU 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");
|
|
}
|
|
}
|