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
@@ -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
}
/// <summary>
/// Compiles the deployment's scripts off the actor thread purely to populate the
/// process-wide <see cref="SiteScriptCompileCache"/>, then pipes
/// Compiles the deployment's scripts off the actor thread purely to populate the compile
/// cache owned by the shared <c>ScriptCompilationService</c> singleton, then pipes
/// <see cref="DeployCompileWarmed"/> 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 <see cref="RunDeployGateAndProceed"/> 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)
{
@@ -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)
/// <see cref="ScriptCompilationService"/>, it is pure and thread-safe; the Deployment
/// Manager nevertheless invokes it <em>synchronously on the singleton's actor thread</em>
/// as a pure prefix step, preserving mailbox-FIFO deploy ordering (see
/// Because it holds no actor state and only calls <see cref="ScriptCompilationService"/>
/// (whose only state is a thread-safe compile cache), it is pure and thread-safe; the
/// Deployment Manager nevertheless invokes it <em>synchronously on the singleton's actor
/// thread</em> as a pure prefix step, preserving mailbox-FIFO deploy ordering (see
/// <see cref="Actors.DeploymentManagerActor.HandleDeploy"/>). 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.
/// </summary>
public sealed class DeployCompileValidator
{
@@ -14,22 +14,48 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// execution-path compile of the script against <see cref="ScriptGlobals"/> /
/// <see cref="TriggerExpressionGlobals"/>.
///
/// Compile results — success AND failure — are memoised process-wide in
/// Compile results — success AND failure — are memoised in this service's
/// <see cref="SiteScriptCompileCache"/> (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 <c>Script&lt;object?&gt;</c>. Error text is name-free by construction, so a
/// cached failure renders correctly for every caller.
///
/// <para><b>The cache is owned per service instance, not static (register row 100).</b>
/// "Once per process" is a property of this service being registered as a DI
/// <b>singleton</b> (<see cref="ServiceCollectionExtensions.AddSiteRuntime"/>, pinned by
/// <c>SiteRuntimeServiceRegistrationTests</c>) — the whole site reaches the cache through
/// that one instance, which the Deployment Manager threads into its
/// <see cref="Deployment.DeployCompileValidator"/> 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.</para>
/// </summary>
public class ScriptCompilationService
{
private readonly ILogger<ScriptCompilationService> _logger;
private readonly SiteScriptCompileCache _compileCache;
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
/// <param name="logger">Logger instance.</param>
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
: this(logger, new SiteScriptCompileCache())
{
}
/// <summary>
/// Test seam: constructs the service over a caller-supplied compile cache, so a test can
/// observe <see cref="SiteScriptCompileCache.Hits"/> / <see cref="SiteScriptCompileCache.Count"/>
/// for exactly the compiles it caused. Production always uses the public constructor.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="compileCache">The compile cache this service memoises into.</param>
internal ScriptCompilationService(
ILogger<ScriptCompilationService> logger,
SiteScriptCompileCache compileCache)
{
_logger = logger;
_compileCache = compileCache;
}
/// <summary>
@@ -136,16 +162,16 @@ public class ScriptCompilationService
/// <summary>
/// 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 <see cref="SiteScriptCompileCache"/>,
/// AND failure — are memoised in this service's <see cref="SiteScriptCompileCache"/>,
/// keyed on code hash + globals type: an unchanged script body compiles once per
/// process lifetime and the compiled <c>Script&lt;object?&gt;</c> 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
/// <c>Script&lt;object?&gt;</c> 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.
/// </summary>
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(
@@ -5,13 +5,25 @@ using System.Text;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Process-wide cache of site-side script <see cref="ScriptCompilationResult"/>s, keyed on
/// Cache of site-side script <see cref="ScriptCompilationResult"/>s, keyed on
/// the globals type plus a SHA-256 hash of the script code. Unlike the TemplateEngine's
/// <c>ScriptCompileVerdictCache</c> (which caches central-validation <em>verdicts</em>), this
/// caches the full compiled result — the immutable Roslyn <c>Script&lt;object?&gt;</c> itself —
/// so the synchronous deploy gate and the <c>InstanceActor.PreStart</c> recompile share one
/// Roslyn compile per script body per process lifetime (N4).
///
/// <para><b>Owned by <see cref="ScriptCompilationService"/>, not static (register row 100).</b>
/// This used to be a <c>static</c> class, which made its <c>Hits</c>/<c>Count</c> 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
/// <c>ScriptCompilationService</c> being a DI <b>singleton</b> (<c>ServiceCollectionExtensions</c>,
/// pinned by <c>SiteRuntimeServiceRegistrationTests</c>), 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
/// <c>ScriptCompilationService</c> now gets its own cache instead of writing into everyone else's.
/// The old <c>Clear()</c> 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.</para>
///
/// <para>Soundness rests on three invariants:</para>
/// <list type="number">
/// <item>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;
/// </para>
///
/// <para><b>WP3.1 — approximate LRU replaced the overflow cliff.</b> The cache used to
/// <c>Clear()</c> 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.</para>
/// </summary>
internal static class SiteScriptCompileCache
internal sealed class SiteScriptCompileCache
{
/// <summary>Upper bound on cached entries; crossing it evicts the oldest batch.</summary>
internal const int MaxEntries = 1024;
@@ -54,18 +66,18 @@ internal static class SiteScriptCompileCache
/// </summary>
private const int EvictionBatchDivisor = 8;
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new();
private static readonly object EvictionLock = new();
private static long _hits;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly object _evictionLock = new();
private long _hits;
/// <summary>Monotonic access sequence; the recency stamp written onto entries.</summary>
private static long _accessSequence;
private long _accessSequence;
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
public static long Hits => Interlocked.Read(ref _hits);
/// <summary>Number of cache hits observed by this cache instance.</summary>
public long Hits => Interlocked.Read(ref _hits);
/// <summary>Current number of cached compile results.</summary>
public static int Count => Cache.Count;
public int Count => _cache.Count;
/// <summary>
/// Returns the cached compile result for <paramref name="code"/> against
@@ -77,11 +89,11 @@ internal static class SiteScriptCompileCache
/// <param name="globalsType">The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct.</param>
/// <param name="factory">Computes the compile result on a cache miss.</param>
/// <returns>The cached or newly computed compile result.</returns>
public static ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func<ScriptCompilationResult> factory)
public ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func<ScriptCompilationResult> 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;
}
/// <summary>Clears all cached compile results and resets the hit counter.</summary>
public static void Clear()
{
Cache.Clear();
Interlocked.Exchange(ref _hits, 0);
}
/// <summary>Refreshes an entry's recency stamp. Lock-free — a lost race only costs accuracy, never correctness.</summary>
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);
/// <summary>
/// Evicts the oldest <see cref="MaxEntries"/> / <see cref="EvictionBatchDivisor"/> entries
/// by recency stamp. Double-checked under <see cref="EvictionLock"/> so several concurrent
/// by recency stamp. Double-checked under <see cref="_evictionLock"/> so several concurrent
/// inserts crossing the bound together perform ONE sweep rather than one each.
/// </summary>
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 _);
}
}
@@ -32,7 +32,14 @@ public static class ServiceCollectionExtensions
services.AddHostedService<SiteStorageInitializer>();
// 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<ScriptCompilationService>();
// Shared script library