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"; } }