feat(runtime): #112 ScriptedAlarmActor state persistence via IAlarmActorStateStore
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
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,111 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Production-side <see cref="IAlarmActorStateStore"/> backed by the
|
||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. The actor's
|
||||
/// 3-state enum projects into the table's two persisted dimensions: Acked + an
|
||||
/// internal "_lastActiveState" recorded via a synthetic mapping (Inactive ⇒ Acked,
|
||||
/// Active ⇒ Unacked, Acknowledged ⇒ Acked). ActiveState itself is deliberately NOT
|
||||
/// persisted — re-derives from the evaluator on startup (Phase 7 decision #14).
|
||||
/// </summary>
|
||||
public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILogger<EfAlarmActorStateStore> _logger;
|
||||
|
||||
public EfAlarmActorStateStore(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
ILogger<EfAlarmActorStateStore> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (row is null) return null;
|
||||
|
||||
var state = MapAckedToActorState(row.AckedState);
|
||||
return new AlarmActorStateSnapshot(
|
||||
AlarmId: alarmId,
|
||||
State: state,
|
||||
LastTransitionUtc: row.UpdatedAtUtc,
|
||||
LastAckUser: row.LastAckUser);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == snapshot.AlarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var ackedState = MapActorStateToAcked(snapshot.State);
|
||||
if (row is null)
|
||||
{
|
||||
db.ScriptedAlarmStates.Add(new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = snapshot.AlarmId,
|
||||
EnabledState = "Enabled",
|
||||
AckedState = ackedState,
|
||||
ConfirmedState = "Confirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
LastAckUser = snapshot.LastAckUser,
|
||||
LastAckUtc = string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal)
|
||||
? snapshot.LastTransitionUtc
|
||||
: null,
|
||||
UpdatedAtUtc = snapshot.LastTransitionUtc,
|
||||
CommentsJson = "[]",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.AckedState = ackedState;
|
||||
row.LastAckUser = snapshot.LastAckUser ?? row.LastAckUser;
|
||||
if (string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal))
|
||||
row.LastAckUtc = snapshot.LastTransitionUtc;
|
||||
row.UpdatedAtUtc = snapshot.LastTransitionUtc;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
// Two actors racing to save the same alarm is benign — the last writer wins on
|
||||
// UpdatedAtUtc, and the next transition on either side will write again. Log
|
||||
// + drop so a race doesn't crash the dispatcher.
|
||||
_logger.LogDebug(ex, "EfAlarmActorStateStore: concurrency conflict for {AlarmId}; dropping save",
|
||||
snapshot.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapActorStateToAcked(string actorState) => actorState switch
|
||||
{
|
||||
"Active" => "Unacknowledged",
|
||||
"Acknowledged" => "Acknowledged",
|
||||
// Inactive maps to Acknowledged — when an alarm clears, nothing is left to ack.
|
||||
_ => "Acknowledged",
|
||||
};
|
||||
|
||||
private static string MapAckedToActorState(string ackedState)
|
||||
{
|
||||
// Only Active distinguishes from Acked — Inactive comes from a re-eval, not from
|
||||
// the table. Persisted "Unacknowledged" implies the actor was last Active +
|
||||
// un-acked; we restore it to Active so a restart doesn't drop pending operator work.
|
||||
return string.Equals(ackedState, "Unacknowledged", StringComparison.Ordinal)
|
||||
? "Active"
|
||||
: "Acknowledged";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user