diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md
index c266f06a..ad22f311 100644
--- a/docs/requirements/Component-SiteRuntime.md
+++ b/docs/requirements/Component-SiteRuntime.md
@@ -128,7 +128,9 @@ flowchart TD
> **The Roslyn `ScriptOptions` are process-static, and must stay that way**: the reference set backing every site-side compile is built once per process, never per compile. `ScriptOptions.WithReferences(Assembly[])` resolves each assembly through `MetadataReference.CreateFromFile`, which does **not** cache — each call mints a fresh `MetadataReference` owning an `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`, an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile leaks native memory permanently: no GC reclaims it, and it is invisible to gcdump and to the managed allocation counters, so the node's working set grows without the GC heap growing. This was a live defect (a Site node at 2,885 MB working set with 150 MB of live GC heap, ~2,469 MB of it on the default process heap across ~6,700 undisposed `AssemblyMetadata` instances). Note this is orthogonal to the compile cache above — the cache dedupes *identical* script bodies, so it bounds nothing when the bodies differ. (It no longer clears wholesale on overflow: WP3.1 replaced that latency cliff with approximate-LRU batch eviction, see **Compile cache eviction** below.) Pinned by a reference-equality regression test rather than by watching memory.
-> **Compile cache eviction is approximate LRU, not a wholesale clear (WP3.1)**: `SiteScriptCompileCache` is bounded at 1024 entries — deliberately smaller than the TemplateEngine verdict cache's 4096, because entries pin compiled assemblies rather than verdict strings. It used to `Clear()` **everything** on overflow. Instance scripts *and* trigger expressions share this cache, so on a site large enough to cross the bound every overflow discarded up to 1023 live compiled scripts and the next deploy or Instance-Actor start paid a full recompile storm **on actor threads**. Overflow now evicts only the oldest ⅛ (128 entries) by last-access stamp: one 1024-element scan instead of 1023 future recompiles, and hot entries survive. Recency is an `Interlocked` access sequence rather than a clock — deterministic for tests and immune to clock steps; hits update it lock-free, and only the (rare) eviction sweep takes a lock, double-checked so concurrent inserts do not stampede it. The sweep snapshots through `ConcurrentDictionary.ToArray()` and **not** LINQ over the dictionary: LINQ's `ToArray` picks the `ICollection.CopyTo` fast path, which throws when a concurrent insert lands between its Count read and its copy, and inserts are deliberately lock-free.
+> **The compile cache is owned by the `ScriptCompilationService` singleton, not static (register row 100)**: `SiteScriptCompileCache` is an ordinary instance held by `ScriptCompilationService`, and "one Roslyn compile per script body per process" (N4) is exactly the statement that `AddSiteRuntime` registers that service as a **DI singleton** — the deploy gate (via `DeployCompileValidator`), every Instance Actor's `PreStart`, and `SharedScriptLibrary` all reach the one cache through the one service the Deployment Manager threads to them. The singleton lifetime is therefore load-bearing rather than incidental, and is pinned by a registration test (`SiteRuntimeServiceRegistrationTests`); downgrading it to Scoped/Transient would hand every consumer an empty cache and silently reintroduce WP3.1's recompile storms with no behavioural test failing. The class was previously `static`, which made its entry table and its `Hits`/`Count` counters one process-global blob every test class in the assembly wrote into; the `Clear()` method that existed only so tests could reset that blob is **removed** — it never had a production caller, and constructing a cache is the honest reset.
+
+> **Compile cache eviction is approximate LRU, not a wholesale clear (WP3.1)**: `SiteScriptCompileCache` is bounded at 1024 entries — deliberately smaller than the TemplateEngine verdict cache's 4096, because entries pin compiled assemblies rather than verdict strings. It used to clear **everything** on overflow. Instance scripts *and* trigger expressions share this cache, so on a site large enough to cross the bound every overflow discarded up to 1023 live compiled scripts and the next deploy or Instance-Actor start paid a full recompile storm **on actor threads**. Overflow now evicts only the oldest ⅛ (128 entries) by last-access stamp: one 1024-element scan instead of 1023 future recompiles, and hot entries survive. Recency is an `Interlocked` access sequence rather than a clock — deterministic for tests and immune to clock steps; hits update it lock-free, and only the (rare) eviction sweep takes a lock, double-checked so concurrent inserts do not stampede it. The sweep snapshots through `ConcurrentDictionary.ToArray()` and **not** LINQ over the dictionary: LINQ's `ToArray` picks the `ICollection.CopyTo` fast path, which throws when a concurrent insert lands between its Count read and its copy, and inserts are deliberately lock-free.
> **Static options were only half the fix — the compile must also use the shared caching metadata resolver**: the static `ScriptOptions` carry only the *direct* API-surface references (5 assemblies here), and each `script.Compile()` still binds their **transitive closure** (~105 assemblies on a site node). Every transitively-referenced assembly is resolved through the options' `MetadataReferenceResolver`, and Roslyn's default one has no cross-compilation cache — it calls `MetadataReference.CreateFromFile` afresh on **every compile**, minting the same undisposed `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock` triple per assembly per compiled script, pinned for the process lifetime by the compile cache. That per-compile *re-resolution* — not the per-compile options construction — was the dominant term (measured live: 21 site scripts holding 6,640 metadata objects, counts bit-identical before and after the static-options fix). `ScriptCompilationService` therefore also attaches the process-wide `CachingScriptMetadataResolver.Instance` (owned by the Script Analysis component, see `Component-ScriptAnalysis.md`) via `ScriptOptions.WithMetadataResolver`, so each distinct assembly is materialized once per process. The decorator returns exactly what the undecorated resolver returned — only object identity is de-duplicated — so compile results and the trust gate are unchanged.
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
index be83b8ca..8282e4cf 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -677,7 +677,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
// Verdict discarded: startup does not gate on compile failures (a failing
// script is logged by the Instance Actor and leaves the rest running).
- // This call exists only to populate SiteScriptCompileCache.
+ // This call exists only to populate the compile cache owned by the
+ // shared ScriptCompilationService (SiteScriptCompileCache).
validator.Validate(configJson);
}
catch
@@ -798,8 +799,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
///
- /// Compiles the deployment's scripts off the actor thread purely to populate the
- /// process-wide , then pipes
+ /// Compiles the deployment's scripts off the actor thread purely to populate the compile
+ /// cache owned by the shared ScriptCompilationService singleton, then pipes
/// back so the authoritative gate can run on the actor
/// thread against a warm cache. The warm is best-effort: its verdict is discarded and any
/// exception swallowed, because re-derives the
@@ -910,9 +911,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// already been warmed off-thread (StartDeployCompileWarm), so this call is
// pure cache hits and no longer holds the singleton — while the per-instance
// warm guard keeps this instance's own command ordering intact. Redeploys and
- // multi-instance deploys of unchanged scripts hit the process-wide compile
- // cache (SiteScriptCompileCache) too, and the Instance Actor's PreStart reuses
- // the gate's compile (N4).
+ // multi-instance deploys of unchanged scripts hit the compile cache
+ // (SiteScriptCompileCache, owned by the ScriptCompilationService singleton this
+ // actor shares with every Instance Actor) too, and the Instance Actor's PreStart
+ // reuses the gate's compile (N4).
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
if (errors.Count > 0)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs
index 196a1eec..3e432233 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs
@@ -13,12 +13,13 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
/// do not compile on the site (design spec: site-side compile failure rejects the
/// deployment; no partial state is applied).
///
-/// Because it holds no actor state and only calls the (stateless)
-/// , it is pure and thread-safe; the Deployment
-/// Manager nevertheless invokes it synchronously on the singleton's actor thread
-/// as a pure prefix step, preserving mailbox-FIFO deploy ordering (see
+/// Because it holds no actor state and only calls
+/// (whose only state is a thread-safe compile cache), it is pure and thread-safe; the
+/// Deployment Manager nevertheless invokes it synchronously on the singleton's actor
+/// thread as a pure prefix step, preserving mailbox-FIFO deploy ordering (see
/// ). Compiles of unchanged
-/// script bodies are deduped by the process-wide compile cache.
+/// script bodies are deduped by that cache, which is shared across the whole site because
+/// the compilation service is a DI singleton.
///
public sealed class DeployCompileValidator
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs
index 1d66655c..d9a7e73d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs
@@ -14,22 +14,48 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// execution-path compile of the script against /
/// .
///
-/// Compile results — success AND failure — are memoised process-wide in
+/// Compile results — success AND failure — are memoised in this service's
/// (keyed on code hash + globals type), so an
/// unchanged script body compiles once per process lifetime and the deploy gate,
/// the Instance Actor's PreStart recompile, and SharedScriptLibrary all reuse one
/// compiled Script<object?>. Error text is name-free by construction, so a
/// cached failure renders correctly for every caller.
+///
+/// The cache is owned per service instance, not static (register row 100).
+/// "Once per process" is a property of this service being registered as a DI
+/// singleton (, pinned by
+/// SiteRuntimeServiceRegistrationTests) — the whole site reaches the cache through
+/// that one instance, which the Deployment Manager threads into its
+/// and into every Instance Actor. Making the
+/// cache an instance field rather than static state means a test (or a second host in one
+/// process) that constructs its own service gets its own cache instead of mutating everyone
+/// else's; that shared-static cross-talk is what row 100 closed.
///
public class ScriptCompilationService
{
private readonly ILogger _logger;
+ private readonly SiteScriptCompileCache _compileCache;
/// Initializes a new instance of the ScriptCompilationService class.
/// Logger instance.
public ScriptCompilationService(ILogger logger)
+ : this(logger, new SiteScriptCompileCache())
+ {
+ }
+
+ ///
+ /// Test seam: constructs the service over a caller-supplied compile cache, so a test can
+ /// observe /
+ /// for exactly the compiles it caused. Production always uses the public constructor.
+ ///
+ /// Logger instance.
+ /// The compile cache this service memoises into.
+ internal ScriptCompilationService(
+ ILogger logger,
+ SiteScriptCompileCache compileCache)
{
_logger = logger;
+ _compileCache = compileCache;
}
///
@@ -136,16 +162,16 @@ public class ScriptCompilationService
///
/// Shared compilation funnel for every site-side compile (the deploy gate, the
/// Instance Actor's PreStart recompile, and SharedScriptLibrary). Results — success
- /// AND failure — are memoised process-wide in ,
+ /// AND failure — are memoised in this service's ,
/// keyed on code hash + globals type: an unchanged script body compiles once per
- /// process lifetime and the compiled Script<object?> is shared across all
- /// call sites (N4). Cached error text is name-free by construction (Roslyn diagnostics /
- /// the compile-exception message never embed the caller's script name), so a cached
- /// failure renders correctly for every caller.
+ /// process lifetime (this service is a DI singleton) and the compiled
+ /// Script<object?> is shared across all call sites (N4). Cached error text is
+ /// name-free by construction (Roslyn diagnostics / the compile-exception message never
+ /// embed the caller's script name), so a cached failure renders correctly for every caller.
///
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
{
- var result = SiteScriptCompileCache.GetOrAdd(code, globalsType,
+ var result = _compileCache.GetOrAdd(code, globalsType,
() => CompileUncached(name, code, globalsType));
if (!result.IsSuccess)
_logger.LogDebug(
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs
index 768a6e9d..728879b9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/SiteScriptCompileCache.cs
@@ -5,13 +5,25 @@ using System.Text;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
///
-/// Process-wide cache of site-side script s, keyed on
+/// Cache of site-side script s, keyed on
/// the globals type plus a SHA-256 hash of the script code. Unlike the TemplateEngine's
/// ScriptCompileVerdictCache (which caches central-validation verdicts), this
/// caches the full compiled result — the immutable Roslyn Script<object?> itself —
/// so the synchronous deploy gate and the InstanceActor.PreStart recompile share one
/// Roslyn compile per script body per process lifetime (N4).
///
+/// Owned by , not static (register row 100).
+/// This used to be a static class, which made its Hits/Count counters and
+/// its entry table one process-global blob that every test class in the assembly shared. The
+/// process-wide N4 sharing property is unchanged in production — it now comes from
+/// ScriptCompilationService being a DI singleton (ServiceCollectionExtensions,
+/// pinned by SiteRuntimeServiceRegistrationTests), so exactly one cache exists per host and
+/// the deploy gate, the Instance Actor's PreStart recompile and SharedScriptLibrary all reach it
+/// through that one service. What changed is that a test constructing its own
+/// ScriptCompilationService now gets its own cache instead of writing into everyone else's.
+/// The old Clear() escape hatch is deliberately GONE: it had no production caller — it
+/// existed only so tests could reset the global — and a fresh instance is the honest reset.
+///
/// Soundness rests on three invariants:
///
/// A compile result is a pure function of (code, globals type, compile-time-static
@@ -30,7 +42,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
///
///
/// WP3.1 — approximate LRU replaced the overflow cliff. The cache used to
-/// Clear() wholesale on overflow. Instance scripts AND trigger expressions share this
+/// clear itself wholesale on overflow. Instance scripts AND trigger expressions share this
/// cache, so on a site large enough to cross 1024 entries every overflow discarded up to 1023
/// live compiled scripts and the next deploy or Instance-Actor start paid a full recompile
/// storm — on actor threads. Now an insert at the bound evicts only the oldest
@@ -42,7 +54,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// only lock is the (rare) eviction sweep, double-checked so concurrent inserts do not
/// stampede it.
///
-internal static class SiteScriptCompileCache
+internal sealed class SiteScriptCompileCache
{
/// Upper bound on cached entries; crossing it evicts the oldest batch.
internal const int MaxEntries = 1024;
@@ -54,18 +66,18 @@ internal static class SiteScriptCompileCache
///
private const int EvictionBatchDivisor = 8;
- private static readonly ConcurrentDictionary Cache = new();
- private static readonly object EvictionLock = new();
- private static long _hits;
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly object _evictionLock = new();
+ private long _hits;
/// Monotonic access sequence; the recency stamp written onto entries.
- private static long _accessSequence;
+ private long _accessSequence;
- /// Number of cache hits observed since the last .
- public static long Hits => Interlocked.Read(ref _hits);
+ /// Number of cache hits observed by this cache instance.
+ public long Hits => Interlocked.Read(ref _hits);
/// Current number of cached compile results.
- public static int Count => Cache.Count;
+ public int Count => _cache.Count;
///
/// Returns the cached compile result for against
@@ -77,11 +89,11 @@ internal static class SiteScriptCompileCache
/// The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct.
/// Computes the compile result on a cache miss.
/// The cached or newly computed compile result.
- public static ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func factory)
+ public ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func factory)
{
var key = globalsType.FullName + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
- if (Cache.TryGetValue(key, out var entry))
+ if (_cache.TryGetValue(key, out var entry))
{
Interlocked.Increment(ref _hits);
Touch(entry);
@@ -90,35 +102,28 @@ internal static class SiteScriptCompileCache
var result = factory();
- if (Cache.Count >= MaxEntries)
+ if (_cache.Count >= MaxEntries)
EvictOldestBatch();
- Cache[key] = new CacheEntry(result) { LastAccess = NextStamp() };
+ _cache[key] = new CacheEntry(result) { LastAccess = NextStamp() };
return result;
}
- /// Clears all cached compile results and resets the hit counter.
- public static void Clear()
- {
- Cache.Clear();
- Interlocked.Exchange(ref _hits, 0);
- }
-
/// Refreshes an entry's recency stamp. Lock-free — a lost race only costs accuracy, never correctness.
- private static void Touch(CacheEntry entry) => Volatile.Write(ref entry.LastAccess, NextStamp());
+ private void Touch(CacheEntry entry) => Volatile.Write(ref entry.LastAccess, NextStamp());
- private static long NextStamp() => Interlocked.Increment(ref _accessSequence);
+ private long NextStamp() => Interlocked.Increment(ref _accessSequence);
///
/// Evicts the oldest / entries
- /// by recency stamp. Double-checked under so several concurrent
+ /// by recency stamp. Double-checked under so several concurrent
/// inserts crossing the bound together perform ONE sweep rather than one each.
///
- private static void EvictOldestBatch()
+ private void EvictOldestBatch()
{
- lock (EvictionLock)
+ lock (_evictionLock)
{
- if (Cache.Count < MaxEntries) return; // another thread already swept
+ if (_cache.Count < MaxEntries) return; // another thread already swept
var batch = Math.Max(1, MaxEntries / EvictionBatchDivisor);
@@ -128,14 +133,14 @@ internal static class SiteScriptCompileCache
// copies, and throws ArgumentException when a concurrent insert lands between
// the two. The eviction lock only excludes other EVICTORS — inserts run
// lock-free by design — so the snapshot must be the thread-safe one.
- var victims = Cache.ToArray()
+ var victims = _cache.ToArray()
.OrderBy(kvp => Volatile.Read(ref kvp.Value.LastAccess))
.Take(batch)
.Select(kvp => kvp.Key)
.ToArray();
foreach (var victim in victims)
- Cache.TryRemove(victim, out _);
+ _cache.TryRemove(victim, out _);
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
index e94af366..033e228c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
@@ -32,7 +32,14 @@ public static class ServiceCollectionExtensions
services.AddHostedService();
- // Script compilation service
+ // Script compilation service.
+ //
+ // SINGLETON IS LOAD-BEARING (register row 100): this service owns the
+ // SiteScriptCompileCache, so "one Roslyn compile per script body per process"
+ // (N4) is exactly the statement that there is one of these per host. Downgrading
+ // it to Scoped/Transient would silently give every consumer its own cache and
+ // reintroduce the recompile storms WP3.1 removed — no test would fail on
+ // behaviour, only the registration pin in SiteRuntimeServiceRegistrationTests.
services.AddSingleton();
// Shared script library
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs
index 7028e886..2a3ed54c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs
@@ -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.
///
-/// Shares the SiteScriptCompileCache collection because the batch pre-warm test
-/// asserts on that process-wide cache's hit counter.
+/// The batch pre-warm test asserts on the compile cache's hit counter. As of register
+/// row 100 that cache is owned by the ScriptCompilationService 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
+/// [Collection("SiteScriptCompileCache")] serialisation against the two
+/// Scripts/ test classes is therefore gone: nothing here is shared any more.
///
-[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.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
+ _compileCache = new SiteScriptCompileCache();
_compilationService = new ScriptCompilationService(
- NullLogger.Instance);
+ NullLogger.Instance, _compileCache);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger.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);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs
index 5b8dff9d..fd2a70d7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs
@@ -15,15 +15,20 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// System.Diagnostics.Process is blocked under System.Diagnostics —
/// Stopwatch stays allowed. The real execution-path compile against
/// ScriptGlobals / TriggerExpressionGlobals is unchanged.
+///
+/// Register row 100: 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
+/// [Collection("SiteScriptCompileCache")] serialisation and the Clear() calls that
+/// used to open the cache-sensitive tests are gone; Hits is now exact.
///
-[Collection("SiteScriptCompileCache")]
public class ScriptCompilationServiceTests
{
+ private readonly SiteScriptCompileCache _cache = new();
private readonly ScriptCompilationService _service;
public ScriptCompilationServiceTests()
{
- _service = new ScriptCompilationService(NullLogger.Instance);
+ _service = new ScriptCompilationService(NullLogger.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 (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 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);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs
index 125b415b..7b5af38f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SiteScriptCompileCacheTests.cs
@@ -5,48 +5,34 @@ 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 and DeploymentWarmThenGateTests
-/// (no cross-class parallelism between those three, so no concurrent
-/// ).
+/// Tests for .
///
///
///
-/// Deferred-work register row 91. 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 too, in parallel, and
-/// bumps the same process-global /
-/// . Absolute assertions such as
-/// Assert.Equal(1, Hits) after a Clear() 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.
+/// 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 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 (),
-/// so no other class can collide with or serve them; and the global counters are only ever
-/// asserted as one-sided bounds (>= baseline + n), 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, 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.
///
///
-[Collection("SiteScriptCompileCache")]
public class SiteScriptCompileCacheTests
{
- ///
- /// Slack allowed on 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.
- ///
- private const int ForeignEntrySlack = 512;
-
- /// Per-test-instance token making every cache key in this test uniquely ours.
- private readonly string _uniq = Guid.NewGuid().ToString("N");
-
- /// Builds a cache key body that no other test class in the assembly can produce.
- private string Code(string body) => $"/*{_uniq}*/ {body}";
+ /// 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