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;
///
/// Production-side backed by the
/// 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).
///
public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
{
private readonly IDbContextFactory _dbFactory;
private readonly ILogger _logger;
/// Initializes a new instance of the EfAlarmActorStateStore.
/// The factory for creating database contexts.
/// The logger instance.
public EfAlarmActorStateStore(
IDbContextFactory dbFactory,
ILogger logger)
{
_dbFactory = dbFactory;
_logger = logger;
}
/// Loads the alarm state snapshot from the database.
/// The identifier of the alarm.
/// The cancellation token.
/// The alarm state snapshot, or null if not found.
public async Task 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);
}
/// Saves the alarm state snapshot to the database.
/// The alarm state snapshot to save.
/// The cancellation token.
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";
}
}