From 1c96fe0be0d71e6d0e01a688172aa25cc5beff80 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 14:21:19 -0400 Subject: [PATCH] feat(scripted-alarms): EfAlarmConditionStateStore (T8) --- .../EfAlarmConditionStateStore.cs | 238 +++++++++++++++++ .../EfAlarmConditionStateStoreTests.cs | 244 ++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs new file mode 100644 index 00000000..baf697fb --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs @@ -0,0 +1,238 @@ +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. Unlike the narrower +/// (which only persists AckedState for the actor's +/// 3-state enum), 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(); + } + + /// + 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) + { + 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, + // 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, + Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal) + ? AlarmConfirmedState.Confirmed + : AlarmConfirmedState.Unconfirmed, + 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, + }; + + private static string SerializeComments(ImmutableList comments) + { + if (comments.IsEmpty) return "[]"; + var dtos = comments.Select(c => new CommentDto + { + 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; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs new file mode 100644 index 00000000..00e89ef0 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs @@ -0,0 +1,244 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; + +/// +/// Round-trip + upsert + lifecycle coverage for , +/// the EF-backed over the ScriptedAlarmState table. +/// +public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase +{ + private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory db) + { + db = NewInMemoryDbFactory(); + return new EfAlarmConditionStateStore(db, NullLogger.Instance); + } + + private static AlarmConditionState Sample( + string alarmId, + AlarmActiveState active = AlarmActiveState.Inactive, + ShelvingState? shelving = null, + ImmutableList? comments = null) + { + var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc); + return new AlarmConditionState( + AlarmId: alarmId, + Enabled: AlarmEnabledState.Disabled, + Active: active, + Acked: AlarmAckedState.Unacknowledged, + Confirmed: AlarmConfirmedState.Unconfirmed, + Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)), + LastTransitionUtc: t, + LastActiveUtc: t.AddMinutes(-5), + LastClearedUtc: t.AddMinutes(-1), + LastAckUtc: t.AddMinutes(-2), + LastAckUser: "jane", + LastAckComment: "ack-comment", + LastConfirmUtc: t.AddMinutes(-3), + LastConfirmUser: "bob", + LastConfirmComment: "confirm-comment", + Comments: comments ?? ImmutableList.Empty); + } + + /// Save then load round-trips every persisted operator-state + audit field. + [Fact] + public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields() + { + var store = NewStore(out _); + var state = Sample("alarm-1"); + + await store.SaveAsync(state, CancellationToken.None); + var loaded = await store.LoadAsync("alarm-1", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.AlarmId.ShouldBe("alarm-1"); + loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); + loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); + loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); + loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed); + loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc); + loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc); + loaded.LastAckUtc.ShouldBe(state.LastAckUtc); + loaded.LastAckUser.ShouldBe("jane"); + loaded.LastAckComment.ShouldBe("ack-comment"); + loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc); + loaded.LastConfirmUser.ShouldBe("bob"); + loaded.LastConfirmComment.ShouldBe("confirm-comment"); + } + + /// Loading an id that was never saved returns null. + [Fact] + public async Task LoadAsync_for_unknown_id_returns_null() + { + var store = NewStore(out _); + + var loaded = await store.LoadAsync("never-saved", CancellationToken.None); + + loaded.ShouldBeNull(); + } + + /// Active is not persisted — a saved Active state loads back as Inactive. + [Fact] + public async Task Active_is_ignored_on_load_and_defaults_to_Inactive() + { + var store = NewStore(out _); + await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None); + + var loaded = await store.LoadAsync("alarm-active", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.Active.ShouldBe(AlarmActiveState.Inactive); + } + + /// LastActiveUtc / LastClearedUtc have no columns and default to null on load. + [Fact] + public async Task Derived_timestamps_default_to_null_on_load() + { + var store = NewStore(out _); + await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None); + + var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.LastActiveUtc.ShouldBeNull(); + loaded.LastClearedUtc.ShouldBeNull(); + } + + /// Unshelved round-trips with a null UnshelveAtUtc. + [Fact] + public async Task Unshelved_round_trips() + { + var store = NewStore(out _); + await store.SaveAsync( + Sample("alarm-unshelved", shelving: ShelvingState.Unshelved), + CancellationToken.None); + + var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); + loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); + } + + /// OneShot shelving round-trips. + [Fact] + public async Task OneShot_shelving_round_trips() + { + var store = NewStore(out _); + await store.SaveAsync( + Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)), + CancellationToken.None); + + var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot); + loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); + } + + /// The append-only comment trail round-trips all four fields per comment. + [Fact] + public async Task Comments_round_trip_all_fields() + { + var store = NewStore(out _); + var c1 = new AlarmComment( + new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"); + var c2 = new AlarmComment( + new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear"); + var comments = ImmutableList.Create(c1, c2); + + await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None); + var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.Comments.Count.ShouldBe(2); + loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc); + loaded.Comments[0].User.ShouldBe("jane"); + loaded.Comments[0].Kind.ShouldBe("Acknowledge"); + loaded.Comments[0].Text.ShouldBe("checking pump"); + loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc); + loaded.Comments[1].User.ShouldBe("bob"); + loaded.Comments[1].Kind.ShouldBe("Confirm"); + loaded.Comments[1].Text.ShouldBe("all clear"); + } + + /// An empty comment list round-trips as an empty list. + [Fact] + public async Task Empty_comments_round_trip() + { + var store = NewStore(out _); + await store.SaveAsync( + Sample("alarm-empty-comments", comments: ImmutableList.Empty), + CancellationToken.None); + + var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None); + + loaded.ShouldNotBeNull(); + loaded.Comments.ShouldBeEmpty(); + } + + /// Saving the same id twice is an upsert — one row, latest values win. + [Fact] + public async Task SaveAsync_twice_upserts_latest() + { + var store = NewStore(out var db); + var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled }; + var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled }; + + await store.SaveAsync(first, CancellationToken.None); + await store.SaveAsync(second, CancellationToken.None); + + using (var ctx = db.CreateDbContext()) + { + ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1); + } + + var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None); + loaded.ShouldNotBeNull(); + loaded.LastAckUser.ShouldBe("second-user"); + loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); + } + + /// LoadAllAsync returns every saved state. + [Fact] + public async Task LoadAllAsync_returns_all_saved_states() + { + var store = NewStore(out _); + await store.SaveAsync(Sample("a-1"), CancellationToken.None); + await store.SaveAsync(Sample("a-2"), CancellationToken.None); + await store.SaveAsync(Sample("a-3"), CancellationToken.None); + + var all = await store.LoadAllAsync(CancellationToken.None); + + all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" }); + } + + /// RemoveAsync deletes a row so a subsequent load returns null; others survive. + [Fact] + public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null() + { + var store = NewStore(out _); + await store.SaveAsync(Sample("keep"), CancellationToken.None); + await store.SaveAsync(Sample("drop"), CancellationToken.None); + + await store.RemoveAsync("drop", CancellationToken.None); + + (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull(); + (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull(); + } + + /// Removing an unknown id is a no-op (does not throw). + [Fact] + public async Task RemoveAsync_for_unknown_id_is_noop() + { + var store = NewStore(out _); + + await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None)); + } +}