7a7d345b89
Six sleeps across four test files stood in for an observable edge. Each preceded an
ABSENCE assertion (or "settled" before one), so they failed toward false-pass rather than
flake: the assertion could not distinguish "the mechanism ran and correctly stayed quiet"
from "the mechanism had not run yet".
ScriptDeadlineAtEnqueueTests (two sleeps, the worst offender)
- :105 Thread.Sleep(2s) — now two explicit edges: the run must be observably QUEUED
(scheduler.QueueDepth >= 1, never checked before), then the wait polls until the 1 s
budget has elapsed, bailing early if the run leaves the queue, and asserts it is STILL
queued afterwards. The test now proves the design claim it was named for — queue wait
burns the run's budget — instead of assuming it.
- :128 Thread.Sleep(2s) + Assert.Equal(0, DetachedThreadCount) — replaced by a positive
control: a second script that genuinely wedges a worker, on the same scheduler, same
1 s timeout + 1 s grace, armed strictly later. Its stuck report is the edge; once it
lands, every watchdog armed at or before it has provably finished its grace delay. The
absence is then asserted per script name, and the detach counter is pinned at 1 (the
wedged run) rather than 0 — which a counter that never moves also satisfied.
DeploymentWarmThenGateTests (:117 plus two sibling Task.Delay(500) startup waits)
- The manager calls SetInstanceCounts unconditionally when its startup config load
completes, so a report counter on the test collector is the startup edge. The
empty-site zero is now the manager's own observation instead of an untouched field.
SiteAlarmLiveCacheServiceTests (:195, :216)
- Both sampled IsLive once after a fixed sleep. Now they poll continuously across three
linger windows on a new teardown observable (the mock client counts subscription
cancellations), so a stop at any instant inside the window is caught, and each closes
with a positive control proving that counter moves.
DataConnectionActorTests (:435)
- A Task.Delay(200) "settle" after an AwaitCondition on a connect counter. The counter
only proves ConnectAsync was CALLED; the actor is still Connecting until it processes
the piped result. Now waits for the Connected health publication that BecomeConnected
emits, so the subscribe under test provably runs against Connected rather than being
silently stashed. (The stash path keeps its own coverage in
WP6_ConnectingState_StashesSubscribeRequests.)
Negative controls (product broken in the working tree, old vs new test compared):
- Stuck-script watchdog disabled entirely: OLD test passed 2/2, NEW test failed. The
whole watchdog could be deleted and the old assertions were satisfied.
- Startup reporting a phantom instance count and taking longer than 500 ms: OLD passed
2/2, NEW failed (expected 0, actual 1).
- Linger cancellation disabled + the remaining-viewer guard dropped, so the stream is
torn down under a live viewer while IsLive keeps reporting true:
Resubscribe_Within_Linger OLD passed 2/2, NEW failed. (Last_Viewer_Leaving caught this
particular break in the old form too, via a later assertion — its strengthening is the
teardown observable and the positive control, not a false-pass fix.)
- DataConnectionActorTests :435 has no false-pass demo: the sleep guarded no assertion
there, and the change is determinism plus proving the claimed state.
Also fixes a latent race these timing changes exposed in
InstanceActorChildAttributeRaceTests: it resolved child actors single-shot immediately
after construction, but ResolveOne throws until the child's cell is published on the
parent AND the cell's Actor property stays null until the child's constructor has run on
its own mailbox thread. Neither is ordered against the test thread, so it failed both ways
under full-suite load (~1 run in 6 once the sleeps above stopped padding the schedule;
14/14 green on the unmodified tree, so it was latent, not new). Resolution now retries to
a bounded deadline; the assertion is unchanged.
Verification: SiteRuntime.Tests 8/8 green (605 tests), Communication.Tests 3/3 (717),
DataConnectionLayer.Tests 3/3 (319). Test-only change — no src/ modifications.
263 lines
13 KiB
C#
263 lines
13 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>Shares the <c>SiteScriptCompileCache</c> collection because the batch pre-warm test
|
|
/// asserts on that process-wide cache's hit counter.</para>
|
|
/// </summary>
|
|
[Collection("SiteScriptCompileCache")]
|
|
public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
|
{
|
|
private readonly SiteStorageService _storage;
|
|
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();
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_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);
|
|
|
|
SiteScriptCompileCache.Clear();
|
|
Assert.Equal(0, SiteScriptCompileCache.Hits);
|
|
|
|
CreateDeploymentManager();
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
// One compile, then repeated hits: the second pre-warm plus both PreStarts.
|
|
Assert.True(SiteScriptCompileCache.Hits >= 3,
|
|
$"expected the pre-warmed body to be served from cache, saw {SiteScriptCompileCache.Hits} hits");
|
|
}, TimeSpan.FromSeconds(20));
|
|
}
|
|
}
|