feat(scripted-alarms): EfAlarmConditionStateStore (T8)

This commit is contained in:
Joseph Doherty
2026-06-10 14:21:19 -04:00
parent 945ccd0b85
commit 1c96fe0be0
2 changed files with 482 additions and 0 deletions
@@ -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;
/// <summary>
/// Production-side <see cref="IAlarmStateStore"/> backed by the
/// <see cref="ScriptedAlarmState"/> table in the central config DB. Unlike the narrower
/// <see cref="EfAlarmActorStateStore"/> (which only persists AckedState for the actor's
/// 3-state enum), this store maps the full Part 9 <see cref="AlarmConditionState"/> —
/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator
/// comments.
/// </summary>
/// <remarks>
/// <para>
/// <b>ActiveState is NOT persisted</b> — the entity has no Active column. On
/// <see cref="LoadAsync"/> it is restored as <see cref="AlarmActiveState.Inactive"/>;
/// the engine re-derives it from the live predicate on startup (Phase 7 decision #14).
/// </para>
/// <para>
/// <b>LastTransitionUtc ↔ UpdatedAtUtc</b>: the table has no dedicated transition
/// column, so <c>LastTransitionUtc</c> is written into the row-write
/// <see cref="ScriptedAlarmState.UpdatedAtUtc"/> on save and read back from it on load.
/// </para>
/// <para>
/// <b>LastActiveUtc / LastClearedUtc are transient</b> — they have no columns and
/// default to <c>null</c> on load (they re-derive from the predicate alongside Active).
/// </para>
/// <para>
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
/// round-trips as <c>"[]"</c> (matching the entity default + <see cref="EfAlarmActorStateStore"/>).
/// </para>
/// </remarks>
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly ILogger<EfAlarmConditionStateStore> _logger;
/// <summary>Initializes a new instance of the <see cref="EfAlarmConditionStateStore"/>.</summary>
/// <param name="dbFactory">The factory for creating config database contexts.</param>
/// <param name="logger">The logger instance.</param>
public EfAlarmConditionStateStore(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ILogger<EfAlarmConditionStateStore> logger)
{
_dbFactory = dbFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task<AlarmConditionState?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmConditionState>> 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();
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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<AlarmComment> 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<AlarmComment> DeserializeComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
return dtos
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToImmutableList();
}
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>CommentsJson</c>.</summary>
private sealed class CommentDto
{
/// <summary>When the comment was recorded (UTC).</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor that wrote the comment.</summary>
public string? User { get; set; }
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
public string? Kind { get; set; }
/// <summary>Operator-supplied or engine-generated comment text.</summary>
public string? Text { get; set; }
}
}
@@ -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;
/// <summary>
/// Round-trip + upsert + lifecycle coverage for <see cref="EfAlarmConditionStateStore"/>,
/// the EF-backed <see cref="IAlarmStateStore"/> over the <c>ScriptedAlarmState</c> table.
/// </summary>
public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
{
private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> db)
{
db = NewInMemoryDbFactory();
return new EfAlarmConditionStateStore(db, NullLogger<EfAlarmConditionStateStore>.Instance);
}
private static AlarmConditionState Sample(
string alarmId,
AlarmActiveState active = AlarmActiveState.Inactive,
ShelvingState? shelving = null,
ImmutableList<AlarmComment>? 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<AlarmComment>.Empty);
}
/// <summary>Save then load round-trips every persisted operator-state + audit field.</summary>
[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");
}
/// <summary>Loading an id that was never saved returns null.</summary>
[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();
}
/// <summary>Active is not persisted — a saved Active state loads back as Inactive.</summary>
[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);
}
/// <summary>LastActiveUtc / LastClearedUtc have no columns and default to null on load.</summary>
[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();
}
/// <summary>Unshelved round-trips with a null UnshelveAtUtc.</summary>
[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();
}
/// <summary>OneShot shelving round-trips.</summary>
[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();
}
/// <summary>The append-only comment trail round-trips all four fields per comment.</summary>
[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");
}
/// <summary>An empty comment list round-trips as an empty list.</summary>
[Fact]
public async Task Empty_comments_round_trip()
{
var store = NewStore(out _);
await store.SaveAsync(
Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
CancellationToken.None);
var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
loaded.ShouldNotBeNull();
loaded.Comments.ShouldBeEmpty();
}
/// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
[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);
}
/// <summary>LoadAllAsync returns every saved state.</summary>
[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" });
}
/// <summary>RemoveAsync deletes a row so a subsequent load returns null; others survive.</summary>
[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();
}
/// <summary>Removing an unknown id is a no-op (does not throw).</summary>
[Fact]
public async Task RemoveAsync_for_unknown_id_is_noop()
{
var store = NewStore(out _);
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
}
}