Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorOverrideRepublishTests.cs
T
Joseph Doherty e765700eac fix(site-runtime): republish loaded static overrides to children/stream and answer pending waits on stop (review 2026-10-21 01/SR-U1, 01/SR-M3)
SR-U1: children are created before the async override load lands, so their
trigger snapshots kept the deploy default forever after a restart/failover/
enable/sweep start. HandleOverridesLoaded now republishes every changed value
through PublishAndNotifyChildren(evaluateWaiters:false), and skips attributes
a static write touched before the load (newer than the stored row).
SR-M3: PostStop cancels each WaitForAttribute timeout and replies
'instance stopped' instead of leaving scripts to time out and timeouts to
dead-letter.
2026-10-22 09:10:26 -04:00

176 lines
8.0 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
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;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Review 2026-10-21 01/SR-U1: persisted static overrides were applied to the Instance Actor's
/// own <c>_attributes</c> but never republished. The Script/Alarm children are created in
/// <c>PreStart</c> BEFORE the async override load lands, so their snapshots were seeded with the
/// deploy default — and static attributes never re-emit a change — so after a restart, failover,
/// enable or sweep start every trigger evaluated against the default, indefinitely.
/// </summary>
public class InstanceActorOverrideRepublishTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
public InstanceActorOverrideRepublishTests()
{
_localDb = TestLocalDb.CreateTemp("instance-override-republish");
_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);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
/// <summary>
/// <c>Mode</c> defaults to <c>Manual</c>; the runtime override is <c>Auto</c>. An Expression
/// alarm on <c>Mode == "Auto" &amp;&amp; Temp &gt; 100</c>.
/// </summary>
private static FlattenedConfiguration ModeAlarmConfig(string instanceName) => new()
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "Mode", Value = "Manual", DataType = "String" },
new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Int32" }
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "AutoOverTemp",
TriggerType = "Expression",
TriggerConfiguration = JsonSerializer.Serialize(new
{
expression = "\"Auto\".Equals(Attributes[\"Mode\"]) && int.Parse(Attributes[\"Temp\"].ToString()) > 100"
}),
PriorityLevel = 500
}
]
};
private IActorRef StartInstance(string instanceName, FlattenedConfiguration config) =>
ActorOf(Props.Create(() => new InstanceActor(
instanceName, JsonSerializer.Serialize(config), _storage, _compilationService,
_sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance)));
private AlarmState AlarmStateOf(IActorRef instance, string instanceName, string alarm)
{
instance.Tell(new GetAlarmSnapshotRequest("snap", instanceName, DateTimeOffset.UtcNow));
var snapshot = ExpectMsg<GetAlarmSnapshotResponse>(TimeSpan.FromSeconds(5));
return snapshot.Alarms.Single(a => a.AlarmName == alarm).State;
}
/// <summary>
/// The finding's regression: deploy → override → restart → the Expression alarm sees the
/// override once another attribute changes. Before the fix the child snapshot still held
/// <c>Manual</c>, so the alarm never raised.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PersistedOverride_ReachesExpressionAlarm_AfterActorRestart()
{
const string name = "PumpU1";
await _storage.SetStaticOverrideAsync(name, "Mode", "Auto");
var instance = StartInstance(name, ModeAlarmConfig(name));
// The override load is async; GetAttribute is the authoritative view. Generous timeouts:
// a cold Roslyn compile of the trigger expression runs inside PreStart.
AwaitAssert(() =>
{
instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow));
Assert.Equal("Auto", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(15)).Value?.ToString());
}, TimeSpan.FromSeconds(30));
instance.Tell(new SetStaticAttributeCommand("w", name, "Temp", "150", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
AwaitAssert(() => Assert.Equal(AlarmState.Active, AlarmStateOf(instance, name, "AutoOverTemp")),
TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// A static write that lands BEFORE the async override load is newer than the stored
/// override, so the load must not overwrite (or republish over) it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteBeforeOverrideLoad_IsNotOverwrittenByTheOlderStoredValue()
{
const string name = "PumpU1Race";
await _storage.SetStaticOverrideAsync(name, "Mode", "Auto");
var gate = new TaskCompletionSource();
// A long flush window keeps the write dirty in memory, so the store still holds the
// OLDER "Auto" when the (gated) load finally reads it.
var flushWindow = TimeSpan.FromMinutes(1);
var configJson = JsonSerializer.Serialize(ModeAlarmConfig(name));
var instance = ActorOf(Props.Create(() => new InstanceActor(
name, configJson, _storage, _compilationService,
_sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance,
null, null, null, flushWindow, gate.Task)));
instance.Tell(new SetStaticAttributeCommand("w", name, "Mode", "Maintenance", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(15)).Success);
gate.SetResult();
await Task.Delay(500);
instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow));
Assert.Equal("Maintenance", ExpectMsg<GetAttributeResponse>().Value?.ToString());
}
/// <summary>
/// Review 2026-10-21 01/SR-M3: stopping the instance answers every pending
/// <c>WaitAsync</c> at once ("instance stopped") instead of leaving the script to burn its
/// whole wait timeout plus the Ask timeout, and leaves no timeout self-message to dead-letter.
/// </summary>
[Fact]
public void Stop_AnswersPendingAttributeWaiters_Immediately()
{
const string name = "PumpM3";
var instance = StartInstance(name, ModeAlarmConfig(name));
var waiter = CreateTestProbe();
instance.Tell(new WaitForAttributeRequest(
"wait-1", name, "Mode", "\"NeverReached\"", null, TimeSpan.FromMinutes(5), DateTimeOffset.UtcNow),
waiter.Ref);
// A round trip proves the waiter is registered before the stop.
instance.Tell(new GetAttributeRequest("g", name, "Mode", DateTimeOffset.UtcNow));
ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(15));
Sys.Stop(instance);
var response = waiter.ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("wait-1", response.CorrelationId);
Assert.False(response.Matched);
Assert.False(response.TimedOut);
Assert.Contains("instance stopped", response.ErrorMessage);
}
}