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:
Joseph Doherty
2026-08-17 06:03:58 -04:00
parent dcfcf822af
commit 84b405ff5f
10 changed files with 263 additions and 181 deletions
@@ -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);