3c11510c52
HandleOverridesLoaded now sends children a site-local AttributeValueSeed (stream still gets the AttributeValueChanged). ScriptActor updates its snapshot, Conditional truth value and Expression last result (seed-only eval, never fires); AlarmActor evaluates it as a normal change.
257 lines
12 KiB
C#
257 lines
12 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);
|
|
}
|
|
|
|
private static ResolvedScript FiringScript(string name, string triggerType, object triggerConfig, string flag) => new()
|
|
{
|
|
CanonicalName = name,
|
|
TriggerType = triggerType,
|
|
TriggerConfiguration = JsonSerializer.Serialize(triggerConfig),
|
|
Code = $"await Instance.SetAttribute(\"{flag}\", \"1\"); return null;"
|
|
};
|
|
|
|
private string AttributeOf(IActorRef instance, string instanceName, string attribute)
|
|
{
|
|
instance.Tell(new GetAttributeRequest("g", instanceName, attribute, DateTimeOffset.UtcNow));
|
|
return ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(15)).Value?.ToString() ?? "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// User decision M1: an override seeded at actor start updates trigger state but does NOT run
|
|
/// ValueChange / Conditional / Expression scripts; an Expression ALARM still raises; and a
|
|
/// later real change fires all three scripts.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SeededOverride_RaisesAlarm_ButFiresNoScript_UntilARealChange()
|
|
{
|
|
const string name = "PumpSeed";
|
|
await _storage.SetStaticOverrideAsync(name, "Level", "50");
|
|
const string over20 = "int.Parse(Attributes[\"Level\"].ToString()) > 20";
|
|
|
|
var config = new FlattenedConfiguration
|
|
{
|
|
InstanceUniqueName = name,
|
|
Attributes =
|
|
[
|
|
new ResolvedAttribute { CanonicalName = "Level", Value = "10", DataType = "Int32" },
|
|
new ResolvedAttribute { CanonicalName = "FiredVC", Value = "0", DataType = "String" },
|
|
new ResolvedAttribute { CanonicalName = "FiredCond", Value = "0", DataType = "String" },
|
|
new ResolvedAttribute { CanonicalName = "FiredExpr", Value = "0", DataType = "String" }
|
|
],
|
|
Scripts =
|
|
[
|
|
FiringScript("VC", "ValueChange", new { attributeName = "Level" }, "FiredVC"),
|
|
FiringScript("Cond", "Conditional",
|
|
new { attributeName = "Level", @operator = ">", threshold = 20 }, "FiredCond"),
|
|
FiringScript("Expr", "Expression", new { expression = over20 }, "FiredExpr")
|
|
],
|
|
Alarms =
|
|
[
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "LevelHigh", TriggerType = "Expression", PriorityLevel = 500,
|
|
TriggerConfiguration = JsonSerializer.Serialize(new { expression = over20 })
|
|
}
|
|
]
|
|
};
|
|
|
|
var instance = StartInstance(name, config);
|
|
|
|
// The alarm raises from the seeded value alone — no other write.
|
|
AwaitAssert(() => Assert.Equal(AlarmState.Active, AlarmStateOf(instance, name, "LevelHigh")),
|
|
TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100));
|
|
Assert.Equal("50", AttributeOf(instance, name, "Level"));
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(1.5));
|
|
Assert.Equal("0", AttributeOf(instance, name, "FiredVC"));
|
|
Assert.Equal("0", AttributeOf(instance, name, "FiredCond"));
|
|
Assert.Equal("0", AttributeOf(instance, name, "FiredExpr"));
|
|
|
|
// Real changes fire them: down (Expression edge resets) then up again.
|
|
instance.Tell(new SetStaticAttributeCommand("w1", name, "Level", "5", DateTimeOffset.UtcNow));
|
|
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(15));
|
|
await Task.Delay(500);
|
|
instance.Tell(new SetStaticAttributeCommand("w2", name, "Level", "60", DateTimeOffset.UtcNow));
|
|
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(15));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
Assert.Equal("1", AttributeOf(instance, name, "FiredVC"));
|
|
Assert.Equal("1", AttributeOf(instance, name, "FiredCond"));
|
|
Assert.Equal("1", AttributeOf(instance, name, "FiredExpr"));
|
|
}, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(200));
|
|
}
|
|
}
|