using System.Collections.Immutable; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; /// /// Production-side backed by the /// table in the central config DB. This store maps the /// full Part 9 — Enabled / Acked / Confirmed / Shelving /// + the ack/confirm audit trail + operator comments. /// /// /// /// ActiveState is NOT persisted — the entity has no Active column. On /// it is restored as ; /// the engine re-derives it from the live predicate on startup (Phase 7 decision #14). /// /// /// LastTransitionUtc ↔ UpdatedAtUtc: the table has no dedicated transition /// column, so LastTransitionUtc is written into the row-write /// on save and read back from it on load. /// /// /// LastActiveUtc / LastClearedUtc are transient — they have no columns and /// default to null on load (they re-derive from the predicate alongside Active). /// /// /// serializes to/from /// via System.Text.Json. An empty list /// round-trips as "[]" (matching the entity default). /// /// public sealed class EfAlarmConditionStateStore : IAlarmStateStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; /// Initializes a new instance of the . /// The factory for creating config database contexts. /// The logger instance. public EfAlarmConditionStateStore( IDbContextFactory dbFactory, ILogger logger) { _dbFactory = dbFactory; _logger = logger; } /// 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); return row is null ? null : MapToState(row); } /// public async Task> LoadAllAsync(CancellationToken ct) { using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); var rows = await db.ScriptedAlarmStates.AsNoTracking() .ToListAsync(ct) .ConfigureAwait(false); return rows.Select(MapToState).ToArray(); } /// /// /// Concurrency assumption: saves for a given alarmId are serialized by the /// owning host actor (one actor owns the engine per equipment). The check-then-insert /// pattern is therefore safe under that guarantee — two concurrent inserts for the same /// alarm cannot occur in the live /// runtime. The catch handles the edge case of a /// racing concurrent restart during crash recovery. /// public async Task SaveAsync(AlarmConditionState state, CancellationToken ct) { using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); var row = await db.ScriptedAlarmStates .FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct) .ConfigureAwait(false); if (row is null) { // Required members are set here to satisfy the compiler; ApplyState is the single // source of truth for all columns and immediately overwrites these values below. row = new ScriptedAlarmState { ScriptedAlarmId = state.AlarmId, EnabledState = MapEnabledToColumn(state.Enabled), AckedState = MapAckedToColumn(state.Acked), ConfirmedState = MapConfirmedToColumn(state.Confirmed), ShelvingState = MapShelvingToColumn(state.Shelving.Kind), }; ApplyState(row, state); db.ScriptedAlarmStates.Add(row); } else { ApplyState(row, state); } try { await db.SaveChangesAsync(ct).ConfigureAwait(false); } catch (DbUpdateConcurrencyException ex) { // Two writers racing to save the same alarm is benign — last writer wins on // UpdatedAtUtc and the next transition writes again. Log + drop so a race never // crashes the engine. _logger.LogDebug(ex, "EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save", state.AlarmId); } } /// public async Task RemoveAsync(string alarmId, CancellationToken ct) { using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); var row = await db.ScriptedAlarmStates .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct) .ConfigureAwait(false); if (row is null) return; db.ScriptedAlarmStates.Remove(row); await db.SaveChangesAsync(ct).ConfigureAwait(false); } private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state) { row.EnabledState = MapEnabledToColumn(state.Enabled); row.AckedState = MapAckedToColumn(state.Acked); row.ConfirmedState = MapConfirmedToColumn(state.Confirmed); row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind); row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc; row.LastAckUser = state.LastAckUser; row.LastAckComment = state.LastAckComment; row.LastAckUtc = state.LastAckUtc; row.LastConfirmUser = state.LastConfirmUser; row.LastConfirmComment = state.LastConfirmComment; row.LastConfirmUtc = state.LastConfirmUtc; row.CommentsJson = SerializeComments(state.Comments); // No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc. row.UpdatedAtUtc = state.LastTransitionUtc; } private static AlarmConditionState MapToState(ScriptedAlarmState row) => new( AlarmId: row.ScriptedAlarmId, Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal) ? AlarmEnabledState.Disabled : AlarmEnabledState.Enabled, // unknown string → Enabled (safe default) // Active is not persisted — the engine re-derives it from the predicate at startup. Active: AlarmActiveState.Inactive, Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal) ? AlarmAckedState.Acknowledged : AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default) Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal) ? AlarmConfirmedState.Confirmed : AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default) Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc), // No transition column — UpdatedAtUtc carries the last transition timestamp. LastTransitionUtc: row.UpdatedAtUtc, // LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load. LastActiveUtc: null, LastClearedUtc: null, LastAckUtc: row.LastAckUtc, LastAckUser: row.LastAckUser, LastAckComment: row.LastAckComment, LastConfirmUtc: row.LastConfirmUtc, LastConfirmUser: row.LastConfirmUser, LastConfirmComment: row.LastConfirmComment, Comments: DeserializeComments(row.CommentsJson)); private static string MapEnabledToColumn(AlarmEnabledState enabled) => enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled"; private static string MapAckedToColumn(AlarmAckedState acked) => acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged"; private static string MapConfirmedToColumn(AlarmConfirmedState confirmed) => confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed"; private static string MapShelvingToColumn(ShelvingKind kind) => kind switch { ShelvingKind.OneShot => "OneShotShelved", ShelvingKind.Timed => "TimedShelved", _ => "Unshelved", }; private static ShelvingKind MapShelvingFromColumn(string column) => column switch { "OneShotShelved" => ShelvingKind.OneShot, "TimedShelved" => ShelvingKind.Timed, _ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default) }; private static string SerializeComments(ImmutableList comments) { if (comments.IsEmpty) return "[]"; var dtos = comments.Select(c => new CommentDto { // AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip; // the engine always creates AlarmComment instances with Utc kind. TimestampUtc = c.TimestampUtc, User = c.User, Kind = c.Kind, Text = c.Text, }); return JsonSerializer.Serialize(dtos, JsonOptions); } private static ImmutableList DeserializeComments(string? json) { if (string.IsNullOrWhiteSpace(json)) return ImmutableList.Empty; var dtos = JsonSerializer.Deserialize>(json, JsonOptions); if (dtos is null || dtos.Count == 0) return ImmutableList.Empty; return dtos .Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty)) .ToImmutableList(); } /// Stable on-disk shape for a persisted in CommentsJson. private sealed class CommentDto { /// When the comment was recorded (UTC). public DateTime TimestampUtc { get; set; } /// Identity of the actor that wrote the comment. public string? User { get; set; } /// Human-readable classification of the comment (Acknowledge, Confirm, …). public string? Kind { get; set; } /// Operator-supplied or engine-generated comment text. public string? Text { get; set; } } }