feat(runtime): #112 ScriptedAlarmActor state persistence via IAlarmActorStateStore
Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
ScriptedAlarmActor now survives actor restart: PreStart loads from the configured store + restores in-memory state; every Transition() fires a fire-and-forget save. ActiveState still re-derives from the evaluator on first tick (Phase 7 decision #14), but Acked state + lastAckUser persist verbatim so operators don't re-ack across an outage. Three pieces: - IAlarmActorStateStore seam in Commons.Engines, with the AlarmActorStateSnapshot record (alarmId / state / lastTransitionUtc / lastAckUser) and NullAlarmActorStateStore default. - EfAlarmActorStateStore in Runtime.ScriptedAlarms — production adapter over the existing ScriptedAlarmState table in ConfigDb. Maps the actor's 3-state enum to the table's AckedState column (Active⇒Unacknowledged, Acknowledged⇒Acknowledged, Inactive⇒ Acknowledged). Concurrency conflicts are logged + dropped — the next transition writes again. - ScriptedAlarmActor PreStart load (async, piped back as StateRestored) + Transition save. New Props overload takes the store; default is NullAlarmActorStateStore so tests stay quiet. Tests: Runtime 52 -> 57 (+5): - Transition writes Active then Acknowledged snapshots with lastAckUser populated - PreStart with persisted Active state restores so a subsequent AcknowledgeAlarm fires (not ignored as it would be from Inactive) - Empty store boots Inactive (AcknowledgeAlarm correctly ignored) - EfAlarmActorStateStore Save + Load round-trips via in-memory EF - Load for unknown alarmId returns null All 6 v2 test suites green: 157 tests passing. Closes #112. F9 (#80) remaining residual is predicate binding to Core.ScriptedAlarms.ScriptedAlarmEngine — split as F9b in tasks JSON.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Transition_writes_to_state_store_with_lastAckUser()
|
||||
{
|
||||
var store = new RecordingStateStore();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
store.Snapshots.Last().State.ShouldBe("Active");
|
||||
store.Snapshots.Last().LastAckUser.ShouldBeNull();
|
||||
}, duration: TimeSpan.FromSeconds(2));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var ackedSnap = store.Snapshots.Last(s => s.State == "Acknowledged");
|
||||
ackedSnap.LastAckUser.ShouldBe("operator-jane");
|
||||
}, duration: TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreStart_restores_persisted_state_so_restart_does_not_drop_pending_ack()
|
||||
{
|
||||
var store = new RecordingStateStore();
|
||||
await store.SaveAsync(new AlarmActorStateSnapshot(
|
||||
AlarmId: "a-1",
|
||||
State: "Active",
|
||||
LastTransitionUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
LastAckUser: null), CancellationToken.None);
|
||||
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store));
|
||||
|
||||
// After PreStart's async load, the actor should be in Active — duplicate ConditionMet
|
||||
// is then ignored because the existing Active-state check.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-bob"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>(TimeSpan.FromMilliseconds(500))
|
||||
.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
}, duration: TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreStart_with_no_persisted_state_boots_inactive()
|
||||
{
|
||||
var store = new RecordingStateStore();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("never-seen", "X", "/eq", 500, Predicate: null);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store));
|
||||
|
||||
// Empty store ⇒ actor sits Inactive; AcknowledgeAlarm is ignored from Inactive so no
|
||||
// StateChanged should arrive.
|
||||
await Task.Delay(200);
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("anyone"));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.Instance);
|
||||
|
||||
await ef.SaveAsync(new AlarmActorStateSnapshot(
|
||||
AlarmId: "alarm-7",
|
||||
State: "Active",
|
||||
LastTransitionUtc: DateTime.UtcNow,
|
||||
LastAckUser: null), CancellationToken.None);
|
||||
|
||||
using (var ctx = db.CreateDbContext())
|
||||
{
|
||||
var row = ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7");
|
||||
row.AckedState.ShouldBe("Unacknowledged");
|
||||
}
|
||||
|
||||
// Acknowledge — same alarmId, transitions to Acknowledged.
|
||||
await ef.SaveAsync(new AlarmActorStateSnapshot(
|
||||
AlarmId: "alarm-7",
|
||||
State: "Acknowledged",
|
||||
LastTransitionUtc: DateTime.UtcNow,
|
||||
LastAckUser: "jane"), CancellationToken.None);
|
||||
|
||||
var loaded = await ef.LoadAsync("alarm-7", CancellationToken.None);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.State.ShouldBe("Acknowledged");
|
||||
loaded.LastAckUser.ShouldBe("jane");
|
||||
|
||||
using (var ctx = db.CreateDbContext())
|
||||
{
|
||||
ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-7").ShouldBe(1);
|
||||
ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7").LastAckUser.ShouldBe("jane");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.Instance);
|
||||
|
||||
var loaded = await ef.LoadAsync("never-saved", CancellationToken.None);
|
||||
loaded.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class RecordingStateStore : IAlarmActorStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AlarmActorStateSnapshot> _byId = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentQueue<AlarmActorStateSnapshot> _saves = new();
|
||||
|
||||
public List<AlarmActorStateSnapshot> Snapshots => _saves.ToList();
|
||||
|
||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
=> Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null);
|
||||
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
_byId[snapshot.AlarmId] = snapshot;
|
||||
_saves.Enqueue(snapshot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user