refactor(siteruntime): scope SiteScriptCompileCache to the ScriptCompilationService singleton — close register row 100
The compile cache was a `static` class, so its entry table and its Hits/Count
counters were one process-global blob every test class in the SiteRuntime
assembly wrote into. Row 91 last wave neutered the resulting flakes (per-test
GUID keys + one-sided counter bounds), but the shared mutable state itself
remained: any test calling Clear() evicted entries other parallel classes had
just inserted, and a Clear() from a class outside the serialized collection
would degrade the others to recompiles and re-open the row-91 flake class.
PREMISE CORRECTION: the register speculated production clears "per-instance or
per-deployment script entries" and offered ClearKeys(prefix) as the natural fit.
There are NO production callers of Clear() at all — grep of src/ finds only
GetOrAdd (ScriptCompilationService.CompileCore). Clear() existed solely so
tests could reset the global. A key-scoped API would therefore have been new
production surface built for nobody, so the instance-scoped seam is the fix and
Clear() is deleted outright rather than migrated.
- SiteScriptCompileCache: `internal static class` -> `internal sealed class`;
Cache/EvictionLock/_hits/_accessSequence become instance fields. LRU batch
eviction, the double-checked sweep and the ConcurrentDictionary.ToArray()
snapshot are byte-for-byte the same policy. Clear() removed.
- ScriptCompilationService owns one cache. Public ctor (the DI path) builds its
own; a new internal ctor takes one for tests. Production N4 sharing is
unchanged and now rests on the DI SINGLETON registration — the one service
instance is threaded to DeployCompileValidator and to every Instance Actor —
so that lifetime is load-bearing and is pinned by a new registration test.
- Tests: all three Clear() callers migrated off it. Each test now owns its cache,
which lets the assertions get STRONGER — the row-91 workarounds are retired:
Hits/Count are exact again (no >= baseline bounds), the per-test GUID key
token is gone, and ForeignEntrySlack (512 entries of allowance on the bound)
drops to the racing-thread count in the concurrency test. New test
SeparateCaches_DoNotShareEntriesOrCounters pins the scoping directly: filling
one cache past MaxEntries leaves another's entry, hit count and identity
intact. DeploymentWarmThenGateTests additionally asserts Count == 1 now that
its counter is private.
- The [Collection("SiteScriptCompileCache")] serialization from row 91 is
RELAXED (removed from all three classes) — they no longer touch any shared
state, so there is nothing left to serialize.
- Docs: Component-SiteRuntime.md gains the ownership/singleton note and drops
the Clear() reference; DeploymentManagerActor / DeployCompileValidator /
ServiceCollectionExtensions comments corrected to say where the cache lives.
Build: 0 warnings. SiteRuntime.Tests 643/643, three consecutive full-parallel
runs. AuditLog.Tests correlation classes (other ScriptCompilationService
consumers) green.
This commit is contained in:
+20
-13
@@ -29,13 +29,17 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
/// answer its deployer instead of leaving it to Ask-timeout, and commands for DIFFERENT
|
||||
/// instances must not block each other.</para>
|
||||
///
|
||||
/// <para>Shares the <c>SiteScriptCompileCache</c> collection because the batch pre-warm test
|
||||
/// asserts on that process-wide cache's hit counter.</para>
|
||||
/// <para>The batch pre-warm test asserts on the compile cache's hit counter. As of register
|
||||
/// row 100 that cache is owned by the <c>ScriptCompilationService</c> instance this fixture
|
||||
/// builds — the one it threads into the Deployment Manager and, through it, into every Instance
|
||||
/// Actor — so the counter is private to the test. The old
|
||||
/// <c>[Collection("SiteScriptCompileCache")]</c> serialisation against the two
|
||||
/// <c>Scripts/</c> test classes is therefore gone: nothing here is shared any more.</para>
|
||||
/// </summary>
|
||||
[Collection("SiteScriptCompileCache")]
|
||||
public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly SiteScriptCompileCache _compileCache;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
@@ -45,8 +49,9 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
_localDb = TestLocalDb.CreateTemp("dm-warm-gate-test");
|
||||
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compileCache = new SiteScriptCompileCache();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
NullLogger<ScriptCompilationService>.Instance, _compileCache);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
@@ -247,21 +252,23 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
await _storage.StoreDeployedConfigAsync(
|
||||
"BatchTwo", ConfigJson("BatchTwo", sharedCode), "d2", "h2", true);
|
||||
|
||||
SiteScriptCompileCache.Clear();
|
||||
// Baseline rather than Assert.Equal(0, Hits): Hits is a process-global counter and test
|
||||
// classes outside this collection compile scripts in parallel, so a foreign hit can land
|
||||
// between the Clear() and the read (deferred-work register row 91). Everything below is
|
||||
// asserted as a delta over this baseline.
|
||||
var hitsBefore = SiteScriptCompileCache.Hits;
|
||||
// No Clear() and no baseline subtraction: this cache is the fixture's own and nothing
|
||||
// has compiled into it yet (register rows 91/100 — the counter used to be a
|
||||
// process-global that other test classes bumped in parallel, forcing a delta-over-
|
||||
// baseline reading). Only the Deployment Manager below writes to it.
|
||||
Assert.Equal(0, _compileCache.Hits);
|
||||
|
||||
CreateDeploymentManager();
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
// One compile, then repeated hits: the second pre-warm plus both PreStarts.
|
||||
Assert.True(SiteScriptCompileCache.Hits - hitsBefore >= 3,
|
||||
$"expected the pre-warmed body to be served from cache, saw " +
|
||||
$"{SiteScriptCompileCache.Hits - hitsBefore} hits over baseline {hitsBefore}");
|
||||
Assert.True(_compileCache.Hits >= 3,
|
||||
$"expected the pre-warmed body to be served from cache, saw {_compileCache.Hits} hits");
|
||||
}, TimeSpan.FromSeconds(20));
|
||||
|
||||
// …and exactly one entry for the one shared body: the hits above were the SAME compiled
|
||||
// script being reused, not several bodies each compiled once.
|
||||
Assert.Equal(1, _compileCache.Count);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -15,15 +15,20 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// <c>System.Diagnostics.Process</c> is blocked under System.Diagnostics —
|
||||
/// <c>Stopwatch</c> stays allowed. The real execution-path compile against
|
||||
/// <c>ScriptGlobals</c> / <c>TriggerExpressionGlobals</c> is unchanged.
|
||||
///
|
||||
/// <para><b>Register row 100:</b> the compile cache is owned by the service instance, not
|
||||
/// static, so this class's service — and therefore its cache — is private to each test. The
|
||||
/// <c>[Collection("SiteScriptCompileCache")]</c> serialisation and the <c>Clear()</c> calls that
|
||||
/// used to open the cache-sensitive tests are gone; <c>Hits</c> is now exact.</para>
|
||||
/// </summary>
|
||||
[Collection("SiteScriptCompileCache")]
|
||||
public class ScriptCompilationServiceTests
|
||||
{
|
||||
private readonly SiteScriptCompileCache _cache = new();
|
||||
private readonly ScriptCompilationService _service;
|
||||
|
||||
public ScriptCompilationServiceTests()
|
||||
{
|
||||
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance, _cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,28 +43,27 @@ public class ScriptCompilationServiceTests
|
||||
[Fact]
|
||||
public void Compile_SameCodeTwice_SharesOneRoslynCompile()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var r1 = _service.Compile("deploy-gate-copy", "return 1 + 1;");
|
||||
var r2 = _service.Compile("prestart-copy", "return 1 + 1;");
|
||||
|
||||
Assert.True(r1.IsSuccess);
|
||||
Assert.Same(r1.CompiledScript, r2.CompiledScript); // one compile, shared Script<T> (N4) — the definitive proof
|
||||
// Hits is a process-global counter; other test classes in this assembly compile scripts
|
||||
// concurrently (they are not in this serialized collection), so the exact post-Clear count
|
||||
// is not deterministic under a full-assembly parallel run. The shared-Script<T> assertion
|
||||
// above is the real proof of cache reuse; here we only require the second lookup registered
|
||||
// a hit (>= 1) rather than pinning an exact global count.
|
||||
Assert.True(SiteScriptCompileCache.Hits >= 1);
|
||||
// The cache belongs to this service instance (register row 100), so the hit count is
|
||||
// exactly the one the second Compile caused — it used to be a >= 1 lower bound on a
|
||||
// process-global counter other test classes were bumping in parallel.
|
||||
Assert.Equal(1, _cache.Hits);
|
||||
Assert.Equal(1, _cache.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_ScriptAndTriggerExpression_DoNotCrossContaminate()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var script = _service.Compile("s", "1 > 0");
|
||||
var trigger = _service.CompileTriggerExpression("t", "1 > 0");
|
||||
|
||||
Assert.NotSame(script.CompiledScript, trigger.CompiledScript); // different globals surfaces
|
||||
Assert.Equal(2, _cache.Count); // two entries, no cross-globals hit
|
||||
Assert.Equal(0, _cache.Hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -231,7 +235,6 @@ public class ScriptCompilationServiceTests
|
||||
[Fact]
|
||||
public void Compile_DistinctScripts_ShareOneMetadataReferenceSet_SoNativeMemoryDoesNotGrow()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var first = _service.Compile("first", "return 1 + 1;");
|
||||
var second = _service.Compile("second", "return 2 + 2;");
|
||||
|
||||
@@ -255,7 +258,6 @@ public class ScriptCompilationServiceTests
|
||||
[Fact]
|
||||
public void Compile_UsesProcessWideCachingMetadataResolver()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var result = _service.Compile("resolver-pin", "return 41 + 1;");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
+101
-107
@@ -5,48 +5,34 @@ 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> and <c>DeploymentWarmThenGateTests</c>
|
||||
/// (no cross-class parallelism between those three, so no concurrent
|
||||
/// <see cref="SiteScriptCompileCache.Clear"/>).
|
||||
/// Tests for <see cref="SiteScriptCompileCache"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Deferred-work register row 91.</b> That collection does NOT stop the rest of the
|
||||
/// assembly: any test class that compiles a script — Instance/Script actor tests, deployment
|
||||
/// tests — goes through <see cref="SiteScriptCompileCache.GetOrAdd"/> too, in parallel, and
|
||||
/// bumps the same process-global <see cref="SiteScriptCompileCache.Hits"/> /
|
||||
/// <see cref="SiteScriptCompileCache.Count"/>. Absolute assertions such as
|
||||
/// <c>Assert.Equal(1, Hits)</c> after a <c>Clear()</c> therefore only held while nothing else
|
||||
/// happened to be compiling; they failed roughly 1 run in 4 of a full parallel solution run and
|
||||
/// were always green in isolation.
|
||||
/// <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 fix follows the register's row-74 pattern: assert the DELTA attributable to this test's
|
||||
/// OWN keys across its own scoped action, never an absolute global value. Two habits carry it:
|
||||
/// every test keys its cache entries on a per-test-instance unique token (<see cref="_uniq"/>),
|
||||
/// so no other class can collide with or serve them; and the global counters are only ever
|
||||
/// asserted as one-sided bounds (<c>>= baseline + n</c>), since foreign traffic can only add.
|
||||
/// A collection fixture spanning every script-compiling class in the assembly would fix it too,
|
||||
/// but would serialise most of the suite for the benefit of five facts.
|
||||
/// 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>
|
||||
[Collection("SiteScriptCompileCache")]
|
||||
public class SiteScriptCompileCacheTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Slack allowed on <see cref="SiteScriptCompileCache.Count"/> bounds for entries inserted
|
||||
/// by test classes outside this collection while a bound test runs (register row 91). The
|
||||
/// exactness of the eviction claims comes from the per-key probes, not from this bound.
|
||||
/// </summary>
|
||||
private const int ForeignEntrySlack = 512;
|
||||
|
||||
/// <summary>Per-test-instance token making every cache key in this test uniquely ours.</summary>
|
||||
private readonly string _uniq = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>Builds a cache key body that no other test class in the assembly can produce.</summary>
|
||||
private string Code(string body) => $"/*{_uniq}*/ {body}";
|
||||
/// <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)));
|
||||
@@ -54,72 +40,94 @@ public class SiteScriptCompileCacheTests
|
||||
[Fact]
|
||||
public void GetOrAdd_SameCodeAndGlobals_ComputesOnce()
|
||||
{
|
||||
var code = Code("return 1;");
|
||||
var hitsBefore = SiteScriptCompileCache.Hits;
|
||||
var factoryCalls = 0;
|
||||
ScriptCompilationResult Factory() { factoryCalls++; return Ok(); }
|
||||
|
||||
var r1 = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), Factory);
|
||||
var r2 = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), Factory);
|
||||
var r1 = _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory);
|
||||
var r2 = _cache.GetOrAdd("return 1;", typeof(ScriptGlobals), Factory);
|
||||
|
||||
// Scoped to our own key, so these two are exact and are the real claim: one compile,
|
||||
// and the second lookup returned that very instance rather than a re-computation.
|
||||
Assert.Equal(1, factoryCalls);
|
||||
Assert.Same(r1, r2);
|
||||
|
||||
// Hits is process-global; a parallel class's compile can only push the delta up, so the
|
||||
// claim "the second lookup was counted as a hit" is a lower bound, not an equality.
|
||||
Assert.True(SiteScriptCompileCache.Hits >= hitsBefore + 1,
|
||||
$"expected the second GetOrAdd to register a hit; hits went {hitsBefore} -> {SiteScriptCompileCache.Hits}");
|
||||
Assert.Equal(1, _cache.Hits);
|
||||
Assert.Equal(1, _cache.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries()
|
||||
{
|
||||
var code = Code("1 > 0");
|
||||
var scriptCompiles = 0;
|
||||
var triggerCompiles = 0;
|
||||
|
||||
var script = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
|
||||
var script = _cache.GetOrAdd("1 > 0", typeof(ScriptGlobals),
|
||||
() => { scriptCompiles++; return Ok(); });
|
||||
var trigger = SiteScriptCompileCache.GetOrAdd(code, typeof(TriggerExpressionGlobals),
|
||||
var trigger = _cache.GetOrAdd("1 > 0", typeof(TriggerExpressionGlobals),
|
||||
() => { triggerCompiles++; return Ok(); });
|
||||
|
||||
// "No cross-globals hit" asserted where it is actually observable and ours: the second
|
||||
// lookup had to compute, and the two globals surfaces hold distinct entries. (The old
|
||||
// form — Count == 2 and Hits == 0 on the process-global counters — was only true while
|
||||
// no other class was compiling.)
|
||||
Assert.Equal(1, scriptCompiles);
|
||||
Assert.Equal(1, triggerCompiles);
|
||||
Assert.NotSame(script, trigger);
|
||||
|
||||
// Each entry is independently addressable afterwards: re-getting either must serve the
|
||||
// matching cached result, never the other globals surface's.
|
||||
Assert.Same(script, SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
|
||||
// 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, SiteScriptCompileCache.GetOrAdd(code, typeof(TriggerExpressionGlobals),
|
||||
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")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overflow_StaysWithinTheBound()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++)
|
||||
SiteScriptCompileCache.GetOrAdd(Code($"return {i};"), typeof(ScriptGlobals), Ok);
|
||||
_cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok);
|
||||
|
||||
// MaxEntries + 1 distinct inserts must not all be resident — the sweep ran. The bound
|
||||
// carries slack for foreign inserts (register row 91); the sharp claim is the eviction
|
||||
// probe below, which is scoped to keys only this test can produce.
|
||||
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + ForeignEntrySlack,
|
||||
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries}");
|
||||
// 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++)
|
||||
{
|
||||
var code = Code($"return {i};");
|
||||
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), () => { recomputed++; return Ok(); });
|
||||
}
|
||||
_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");
|
||||
}
|
||||
@@ -134,50 +142,37 @@ public class SiteScriptCompileCacheTests
|
||||
[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(Code($"return {i};"), typeof(ScriptGlobals), Ok);
|
||||
_cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok);
|
||||
|
||||
// Touch entry 0 so it carries the NEWEST access stamp despite being inserted first.
|
||||
var hot = SiteScriptCompileCache.GetOrAdd(Code("return 0;"), typeof(ScriptGlobals), Ok);
|
||||
var hitsAfterTouch = SiteScriptCompileCache.Hits;
|
||||
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++)
|
||||
SiteScriptCompileCache.GetOrAdd(Code($"return overflow{i};"), typeof(ScriptGlobals), Ok);
|
||||
_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 (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);
|
||||
// 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 hitsBefore = SiteScriptCompileCache.Hits;
|
||||
var again = SiteScriptCompileCache.GetOrAdd(Code("return 0;"), typeof(ScriptGlobals),
|
||||
var again = _cache.GetOrAdd("return 0;", typeof(ScriptGlobals),
|
||||
() => throw new InvalidOperationException("hot entry was evicted — LRU is not keeping recently-used entries"));
|
||||
// Assert.Same is the sharp claim (our own key, our own instance). The counter check is a
|
||||
// lower bound: Hits is process-global and a parallel class's hit lands in the same window
|
||||
// (register row 91 — this was `Assert.Equal(hitsBefore + 1, …)`).
|
||||
Assert.Same(hot, again);
|
||||
Assert.True(SiteScriptCompileCache.Hits >= hitsBefore + 1,
|
||||
$"expected the surviving hot entry to register a hit; hits went {hitsBefore} -> {SiteScriptCompileCache.Hits}");
|
||||
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++)
|
||||
{
|
||||
var code = Code($"return {i};");
|
||||
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
|
||||
() => { recomputed++; return Ok(); });
|
||||
}
|
||||
_cache.GetOrAdd($"return {i};", typeof(ScriptGlobals), () => { recomputed++; return Ok(); });
|
||||
|
||||
Assert.True(recomputed > 0, "no old entry was evicted — the overflow sweep did not run");
|
||||
}
|
||||
|
||||
@@ -189,26 +184,25 @@ public class SiteScriptCompileCacheTests
|
||||
[Fact]
|
||||
public async Task ConcurrentGetOrAddStorm_KeepsTheBound()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
|
||||
const int perTask = 400;
|
||||
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
|
||||
const int tasks = 8;
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, tasks).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < perTask; i++)
|
||||
SiteScriptCompileCache.GetOrAdd(Code($"return {t}_{i};"), typeof(ScriptGlobals), Ok);
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
_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 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 + ForeignEntrySlack,
|
||||
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries} " +
|
||||
$"after {8 * perTask} distinct inserts");
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the DI lifetimes that carry a behavioural contract rather than a convention.
|
||||
/// </summary>
|
||||
public class SiteRuntimeServiceRegistrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Register row 100. <c>ScriptCompilationService</c> owns the
|
||||
/// <c>SiteScriptCompileCache</c>, so "one Roslyn compile per script body per process"
|
||||
/// (N4 / WP3.1's warm-then-gate deploy path) is precisely the statement that DI hands
|
||||
/// out ONE of these per host. The cache stopped being <c>static</c> when row 100 removed
|
||||
/// the process-global state tests were clobbering; this registration is what replaced
|
||||
/// the static as the guarantee, so it is pinned here.
|
||||
///
|
||||
/// <para>Downgrading this to Scoped or Transient would give every consumer — the deploy
|
||||
/// gate, each Instance Actor's PreStart, SharedScriptLibrary — its own empty cache and
|
||||
/// silently reintroduce the recompile storms WP3.1 removed, with no functional test
|
||||
/// failing. This assertion is the only thing that would notice.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddSiteRuntime_RegistersScriptCompilationServiceAsSingleton_SoOneCompileCacheExistsPerProcess()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSiteRuntime();
|
||||
|
||||
var descriptor = Assert.Single(
|
||||
services, d => d.ServiceType == typeof(ScriptCompilationService));
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user