e765700eac
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.
176 lines
8.0 KiB
C#
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" && Temp > 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);
|
|
}
|
|
}
|