Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentWarmThenGateTests.cs
T
Joseph Doherty 84b405ff5f 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.
2026-08-17 06:03:58 -04:00

275 lines
14 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// WP3.1 test group 9 — warm-then-gate deploys and startup batch pre-warm.
///
/// <para>The site-side compile gate (S3) must stay synchronous on the Deployment Manager's
/// thread, because redeploy-supersede and delete-during-redeploy both depend on strict mailbox
/// FIFO. But the Roslyn compile it performs used to hold the singleton for the whole
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. WP3.1
/// warms the compile off-thread first and then re-runs the gate as pure cache hits, with a
/// per-instance in-flight guard preserving same-instance ordering.</para>
///
/// <para>These tests pin the ordering contract, not the timing: a command for the SAME instance
/// arriving during a warm must be queued and applied after the deploy, a superseded deploy must
/// answer its deployer instead of leaving it to Ask-timeout, and commands for DIFFERENT
/// instances must not block each other.</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>
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;
public DeploymentWarmThenGateTests()
{
_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, _compileCache);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateDeploymentManager(ISiteHealthCollector? healthCollector = null) =>
ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage, _compilationService, _sharedScriptLibrary, null,
new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance, null,
healthCollector)));
/// <summary>
/// Register row 40: starts a Deployment Manager and blocks until it has PROVABLY finished
/// its startup config load, by waiting for its first instance-count report. Replaces a bare
/// <c>await Task.Delay(500)</c>, which merely hoped 500 ms was enough and — for the empty-site
/// assertion — could not tell "startup ran and found nothing" from "startup had not run yet".
/// </summary>
private (IActorRef Dm, DeployedCountCollector Health) CreateStartedDeploymentManager()
{
var health = new DeployedCountCollector();
var dm = CreateDeploymentManager(health);
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20),
TimeSpan.FromMilliseconds(25));
return (dm, health);
}
/// <summary>
/// Captures the deployed-instance count the Deployment Manager reports. The count is
/// mutated only on the actor thread — <c>HandleDeploy</c> adds the instance name,
/// <c>HandleDelete</c> removes it — so it is an exact, storage-race-free record of the
/// order in which the two commands were APPLIED.
/// </summary>
private sealed class DeployedCountCollector : ISiteHealthCollector
{
private int _reports;
public int LastDeployedCount { get; private set; }
/// <summary>
/// Register row 40: how many times the Deployment Manager has reported instance
/// counts. The manager calls <see cref="SetInstanceCounts"/> unconditionally the
/// moment its startup config load completes (<c>HandleStartupConfigsLoaded</c>) —
/// including for an empty site — so a non-zero value here is the explicit
/// observable edge for "startup has finished", replacing a wall-clock sleep.
/// Without it, <c>LastDeployedCount == 0</c> is satisfied by the field's initial
/// value and proves nothing about the manager having started at all.
/// </summary>
public int ReportCount => Volatile.Read(ref _reports);
public void IncrementScriptError() { }
public void IncrementAlarmError() { }
public void IncrementDeadLetter() { }
public void IncrementSiteAuditWriteFailures() { }
public void IncrementAuditRedactionFailure() { }
public void UpdateSiteAuditBacklog(Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
public void RemoveConnection(string connectionName) { }
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
public void SetInstanceCounts(int deployed, int enabled, int disabled)
{
LastDeployedCount = deployed;
Interlocked.Increment(ref _reports);
}
public void SetParkedMessageCount(int count) { }
public void SetNodeHostname(string hostname) { }
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) { }
public void SetActiveNode(bool isActive) { }
public bool IsActiveNode => true;
public Commons.Messages.Health.SiteHealthReport CollectReport(string siteId)
=> throw new NotSupportedException();
}
private static string ConfigJson(string instanceName, string? scriptCode = null) =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
],
Scripts = scriptCode is null
? []
: [new ResolvedScript { CanonicalName = "Worker", Code = scriptCode, TriggerType = "Call" }]
});
[Fact]
public async Task DeleteArrivingDuringTheCompileWarm_IsQueuedAndAppliedAfterTheDeploy()
{
// The startup load has demonstrably completed (it reported counts at least once), so
// the zero below is the manager's own observation of an empty site rather than an
// untouched field.
var (dm, health) = CreateStartedDeploymentManager();
Assert.Equal(0, health.LastDeployedCount);
var deployProbe = CreateTestProbe();
var deleteProbe = CreateTestProbe();
// Back-to-back on the mailbox: the delete lands while the deploy's compile warm is
// still in flight, so it must be queued rather than racing ahead of the deploy.
dm.Tell(new DeployInstanceCommand(
"dep-1", "WarmPump", "h1", ConfigJson("WarmPump", "return 1;"), "admin", DateTimeOffset.UtcNow),
deployProbe.Ref);
dm.Tell(new DeleteInstanceCommand("del-1", "WarmPump", DateTimeOffset.UtcNow), deleteProbe.Ref);
var deploy = deployProbe.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal(DeploymentStatus.Success, deploy.Status);
var delete = deleteProbe.ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(15));
Assert.True(delete.Success);
// Terminal in-memory state: the deploy applied FIRST (adding the instance) and the
// delete applied SECOND (removing it), leaving the count at 0. Had the delete raced
// ahead of the warm it would have removed nothing and the deploy would have left the
// count at 1. This is the ordering signal rather than the SQLite row, because the
// deploy's store and the delete's remove are independent background tasks whose
// completion order the actor has never guaranteed (true before WP3.1 as well).
AwaitAssert(() => Assert.Equal(0, health.LastDeployedCount), TimeSpan.FromSeconds(10));
}
[Fact]
public async Task SecondDeployDuringTheWarm_SupersedesTheFirst_AndAnswersItsDeployer()
{
var (dm, _) = CreateStartedDeploymentManager();
var first = CreateTestProbe();
var second = CreateTestProbe();
dm.Tell(new DeployInstanceCommand(
"dep-a", "SupersedePump", "h1", ConfigJson("SupersedePump", "return 1;"), "admin", DateTimeOffset.UtcNow),
first.Ref);
dm.Tell(new DeployInstanceCommand(
"dep-b", "SupersedePump", "h2", ConfigJson("SupersedePump", "return 2;"), "admin", DateTimeOffset.UtcNow),
second.Ref);
// The displaced deployer is answered rather than left to time out its Ask.
var superseded = first.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal("dep-a", superseded.DeploymentId);
Assert.Equal(DeploymentStatus.Failed, superseded.Status);
Assert.Contains("superseded", superseded.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
var winner = second.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
Assert.Equal("dep-b", winner.DeploymentId);
Assert.Equal(DeploymentStatus.Success, winner.Status);
// Exactly one row, carrying the winning revision hash.
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "SupersedePump");
Assert.Equal("h2", row.RevisionHash);
}
[Fact]
public async Task DeploysForDifferentInstances_DoNotBlockEachOther()
{
var (dm, _) = CreateStartedDeploymentManager();
var probeX = CreateTestProbe();
var probeY = CreateTestProbe();
dm.Tell(new DeployInstanceCommand(
"dep-x", "PumpX", "hx", ConfigJson("PumpX", "return 1;"), "admin", DateTimeOffset.UtcNow),
probeX.Ref);
dm.Tell(new DeployInstanceCommand(
"dep-y", "PumpY", "hy", ConfigJson("PumpY", "return 2;"), "admin", DateTimeOffset.UtcNow),
probeY.Ref);
// Both apply; the per-instance warm guard scopes to the instance, so a warm for X
// never queues a command for Y.
Assert.Equal(DeploymentStatus.Success,
probeX.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
Assert.Equal(DeploymentStatus.Success,
probeY.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpX");
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpY");
}
[Fact]
public async Task StaggeredStartup_PreWarmsEachBatchSoInstanceActorPreStartCompilesAreCacheHits()
{
// Two instances sharing one script body. The batch pre-warm compiles it once; the
// second config's warm and BOTH Instance Actors' PreStart compiles are then hits.
// Before WP3.1 every Instance Actor Roslyn-compiled its own scripts inside PreStart,
// serialising a site's whole failover recovery behind compilation.
const string sharedCode = "return 41 + 1;";
await _storage.StoreDeployedConfigAsync(
"BatchOne", ConfigJson("BatchOne", sharedCode), "d1", "h1", true);
await _storage.StoreDeployedConfigAsync(
"BatchTwo", ConfigJson("BatchTwo", sharedCode), "d2", "h2", true);
// 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(_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);
}
}