refactor(mesh-phase4): retire EfAlarmConditionStateStore + drop dead ScriptedAlarmState table (Task 9)
Scripted-alarm condition state lives in LocalDb (Phase 4); the ConfigDb-backed Ef store + ScriptedAlarmState table are now dead. Removes the test-harness Ef fallback (a DB-backed node with no store now skips the alarm host), deletes the store + entity + model config, and drops the table via migration. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent runtime state for each scripted alarm. Survives process restart so
|
||||
/// operators don't re-ack and ack history survives for
|
||||
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
|
||||
/// per-generation) because ack state follows the alarm's stable identity across
|
||||
/// generations — a Modified alarm keeps its ack history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
|
||||
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
|
||||
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
|
||||
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
|
||||
/// serializes directly into this column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmState
|
||||
{
|
||||
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Enabled/Disabled. Persists across restart.</summary>
|
||||
public required string EnabledState { get; set; } = "Enabled";
|
||||
|
||||
/// <summary>Unacknowledged / Acknowledged.</summary>
|
||||
public required string AckedState { get; set; } = "Unacknowledged";
|
||||
|
||||
/// <summary>Unconfirmed / Confirmed.</summary>
|
||||
public required string ConfirmedState { get; set; } = "Unconfirmed";
|
||||
|
||||
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
|
||||
public required string ShelvingState { get; set; } = "Unshelved";
|
||||
|
||||
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
|
||||
public DateTime? ShelvingExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>User who last acknowledged. Null if never acked.</summary>
|
||||
public string? LastAckUser { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
|
||||
public string? LastAckComment { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the last acknowledgment.</summary>
|
||||
public DateTime? LastAckUtc { get; set; }
|
||||
|
||||
/// <summary>User who last confirmed.</summary>
|
||||
public string? LastConfirmUser { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the operator-supplied confirm comment. Null if no comment or never confirmed.</summary>
|
||||
public string? LastConfirmComment { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the last confirmation.</summary>
|
||||
public DateTime? LastConfirmUtc { get; set; }
|
||||
|
||||
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
|
||||
public string CommentsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Row write timestamp — tracks last state change.</summary>
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
}
|
||||
+1639
File diff suppressed because it is too large
Load Diff
+47
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropScriptedAlarmStateTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ScriptedAlarmState");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ScriptedAlarmState",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
|
||||
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
-68
@@ -1113,74 +1113,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b =>
|
||||
{
|
||||
b.Property<string>("ScriptedAlarmId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("AckedState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ConfirmedState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("EnabledState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("LastAckComment")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("LastAckUser")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateTime?>("LastAckUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("LastConfirmComment")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("LastConfirmUser")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateTime?>("LastConfirmUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<DateTime?>("ShelvingExpiresUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("ShelvingState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2(3)")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.HasKey("ScriptedAlarmId");
|
||||
|
||||
b.ToTable("ScriptedAlarmState", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
|
||||
{
|
||||
b.Property<string>("ClusterId")
|
||||
|
||||
@@ -56,8 +56,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
|
||||
/// <summary>Gets the DbSet of scripted alarms.</summary>
|
||||
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
|
||||
/// <summary>Gets the DbSet of scripted alarm states.</summary>
|
||||
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
|
||||
|
||||
// v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment).
|
||||
/// <summary>Gets the DbSet of deployments.</summary>
|
||||
@@ -98,7 +96,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureScript(modelBuilder);
|
||||
ConfigureVirtualTag(modelBuilder);
|
||||
ConfigureScriptedAlarm(modelBuilder);
|
||||
ConfigureScriptedAlarmState(modelBuilder);
|
||||
ConfigureDeployment(modelBuilder);
|
||||
ConfigureNodeDeploymentState(modelBuilder);
|
||||
ConfigureConfigEdit(modelBuilder);
|
||||
@@ -716,34 +713,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScriptedAlarmState>(e =>
|
||||
{
|
||||
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
|
||||
// stable identity across generations — Modified alarms keep their ack audit trail.
|
||||
e.ToTable("ScriptedAlarmState", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
e.HasKey(x => x.ScriptedAlarmId);
|
||||
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
|
||||
e.Property(x => x.EnabledState).HasMaxLength(16);
|
||||
e.Property(x => x.AckedState).HasMaxLength(16);
|
||||
e.Property(x => x.ConfirmedState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastAckUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastAckComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDeployment(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Deployment>(e =>
|
||||
|
||||
@@ -113,9 +113,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// Scripted-alarm condition-state store handed to the ScriptedAlarm engine, or
|
||||
/// <see langword="null"/> when none was wired (per-cluster mesh Phase 4). On a driver-role node
|
||||
/// this is the replicated LocalDb store (<c>LocalDbAlarmConditionStateStore</c>) so condition
|
||||
/// state survives without central SQL; when null the actor falls back to an
|
||||
/// <see cref="EfAlarmConditionStateStore"/> over <see cref="_dbFactory"/> (legacy admin/Direct +
|
||||
/// test harnesses), or — when there is no ConfigDb either — skips spawning the alarm host.
|
||||
/// state survives without central SQL; when null the actor skips spawning the alarm host (the
|
||||
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).
|
||||
/// </summary>
|
||||
private readonly IAlarmStateStore? _alarmStateStore;
|
||||
|
||||
@@ -135,7 +134,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
|
||||
private readonly IHistoryWriter _historyWriter;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ScriptRootLogger? _scriptRootLogger;
|
||||
private readonly IActorRef? _scriptedAlarmHostOverride;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
@@ -383,9 +381,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
|
||||
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
|
||||
/// production (the real host is spawned).</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger when spawning the ScriptedAlarm host;
|
||||
/// defaults to <see cref="NullLoggerFactory"/> when not provided.</param>
|
||||
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
|
||||
/// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
|
||||
/// unspawned — the graceful dev/None-deployment path.</param>
|
||||
@@ -398,9 +395,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param>
|
||||
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
|
||||
/// driver-role node this is the replicated LocalDb store, so condition state persists with no
|
||||
/// ConfigDb; null falls back to an <see cref="EfAlarmConditionStateStore"/> over
|
||||
/// <paramref name="dbFactory"/> (legacy admin/Direct + test harnesses), or skips the alarm host when
|
||||
/// there is no ConfigDb either. Defaults to null.</param>
|
||||
/// ConfigDb; null skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in
|
||||
/// Phase 4 Task 9). Defaults to null.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
@@ -454,8 +450,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param>
|
||||
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
|
||||
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger; defaults to <see cref="NullLoggerFactory"/>.</param>
|
||||
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
|
||||
/// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host; when null the host is left unspawned.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
|
||||
@@ -471,9 +467,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in
|
||||
/// tests that do not exercise the cache — caching is then simply skipped.</param>
|
||||
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
|
||||
/// driver-role node this is the replicated LocalDb store; null falls back to an
|
||||
/// <see cref="EfAlarmConditionStateStore"/> over <paramref name="dbFactory"/>, or skips the alarm
|
||||
/// host when there is no ConfigDb either.</param>
|
||||
/// driver-role node this is the replicated LocalDb store; null skips spawning the alarm host (the
|
||||
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -517,7 +512,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
|
||||
_historyWriter = historyWriter ?? NullHistoryWriter.Instance;
|
||||
_virtualTagHostOverride = virtualTagHostOverride;
|
||||
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
|
||||
_scriptRootLogger = scriptRootLogger;
|
||||
_scriptedAlarmHostOverride = scriptedAlarmHostOverride;
|
||||
|
||||
@@ -597,9 +591,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="_scriptRootLogger"/> (the engine + its script logging hang off it); when either
|
||||
/// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left
|
||||
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
|
||||
/// <see cref="DependencyMuxTagUpstreamSource"/> + an <see cref="EfAlarmConditionStateStore"/>;
|
||||
/// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with
|
||||
/// the driver host.
|
||||
/// <see cref="DependencyMuxTagUpstreamSource"/> + the wired <see cref="IAlarmStateStore"/> (the
|
||||
/// replicated <c>LocalDbAlarmConditionStateStore</c> on a driver-role node); when no store was
|
||||
/// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its
|
||||
/// PostStop, so it stops with the driver host.
|
||||
/// </summary>
|
||||
private void SpawnScriptedAlarmHost()
|
||||
{
|
||||
@@ -617,30 +612,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-cluster mesh Phase 4: prefer the wired condition-state store (the replicated LocalDb store
|
||||
// on a driver-role node — it persists with no ConfigDb). Only when no store was wired do we fall
|
||||
// back to the ConfigDb-backed EF store, and only if there IS a ConfigDb (legacy admin/Direct +
|
||||
// test harnesses). A driver-only node has neither — nowhere to persist condition state — so it
|
||||
// skips the alarm host outright rather than constructing an EF store over a null factory that
|
||||
// would NRE the moment the engine touched it (mirrors the _opcUaPublishActor-null skip above).
|
||||
IAlarmStateStore store;
|
||||
if (_alarmStateStore is not null)
|
||||
{
|
||||
store = _alarmStateStore;
|
||||
}
|
||||
else if (_dbFactory is not null)
|
||||
{
|
||||
store = new EfAlarmConditionStateStore(
|
||||
_dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>());
|
||||
}
|
||||
else
|
||||
// Per-cluster mesh Phase 4: condition state lives in the wired store — the replicated LocalDb
|
||||
// store on a driver-role node, which persists with no ConfigDb. The ConfigDb-backed EF fallback
|
||||
// was retired (Task 9); when no store was wired there is nowhere to persist condition state, so
|
||||
// we skip the alarm host outright rather than run the engine against a store it can never save to
|
||||
// (mirrors the _opcUaPublishActor-null skip above).
|
||||
if (_alarmStateStore is null)
|
||||
{
|
||||
_log.Debug(
|
||||
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store and no ConfigDb)",
|
||||
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store)",
|
||||
_localNode);
|
||||
return;
|
||||
}
|
||||
|
||||
var store = _alarmStateStore;
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
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. 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.
|
||||
/// </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).
|
||||
/// </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)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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, // 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<AlarmComment> 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<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; }
|
||||
}
|
||||
}
|
||||
@@ -286,8 +286,7 @@ public static class ServiceCollectionExtensions
|
||||
// Per-cluster mesh Phase 4: scripted-alarm condition-state store. Registered by the Host's
|
||||
// AddOtOpcUaLocalDb on driver-role nodes (the replicated LocalDb store), so condition state
|
||||
// persists with no ConfigDb; null elsewhere (admin-only graphs, test harnesses) → the actor
|
||||
// falls back to an EfAlarmConditionStateStore over dbFactory, or skips the alarm host when
|
||||
// there is no ConfigDb either.
|
||||
// skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in Phase 4 Task 9).
|
||||
var alarmStateStore = resolver.GetService<IAlarmStateStore>();
|
||||
// Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config.
|
||||
// FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache;
|
||||
@@ -473,7 +472,8 @@ public static class ServiceCollectionExtensions
|
||||
// gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor
|
||||
// guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central
|
||||
// persists acks from the ApplyAck) and serves scripted-alarm state from the LocalDb
|
||||
// alarmStateStore instead of an EfAlarmConditionStateStore.
|
||||
// alarmStateStore (there is no ConfigDb-backed EF fallback; the host skips the alarm
|
||||
// host when no store was wired).
|
||||
DriverHostActor.Props(dbFactory, roleInfo.LocalNode,
|
||||
ackRouter: useClusterClient ? nodeComm : null,
|
||||
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
|
||||
|
||||
@@ -36,8 +36,10 @@ public sealed class SchemaComplianceTests
|
||||
"DriverHostStatus",
|
||||
"DriverInstanceResilienceStatus",
|
||||
"LdapGroupRoleMapping",
|
||||
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables
|
||||
"Script", "ScriptedAlarm", "ScriptedAlarmState",
|
||||
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables.
|
||||
// ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm
|
||||
// condition state moved to the replicated LocalDb store.
|
||||
"Script", "ScriptedAlarm",
|
||||
// v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
|
||||
"Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
|
||||
};
|
||||
|
||||
@@ -10,9 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
|
||||
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
|
||||
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
|
||||
/// the plan decisions call for. Introspection only — no SQL Server required.
|
||||
/// <see cref="ScriptedAlarm"/>) register correctly in the EF model, map to the expected
|
||||
/// tables/columns/indexes, and carry the check constraints the plan decisions call for.
|
||||
/// Introspection only — no SQL Server required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptingEntitiesTests
|
||||
@@ -127,49 +127,9 @@ public sealed class ScriptingEntitiesTests
|
||||
alarm.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarmState");
|
||||
|
||||
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
|
||||
pk.Properties.Count.ShouldBe(1);
|
||||
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
|
||||
|
||||
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
|
||||
entity.FindProperty("GenerationId").ShouldBeNull(
|
||||
"ack state follows alarm identity across generations");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState default values match Part 9 initial states.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
||||
{
|
||||
var state = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EnabledState = "Enabled",
|
||||
AckedState = "Unacknowledged",
|
||||
ConfirmedState = "Unconfirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
};
|
||||
state.CommentsJson.ShouldBe("[]");
|
||||
state.LastAckUser.ShouldBeNull();
|
||||
state.LastAckUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
||||
}
|
||||
// The ConfigDb-backed ScriptedAlarmState entity + table were retired in per-cluster mesh Phase 4
|
||||
// (Task 9): scripted-alarm condition state lives in the replicated LocalDb store. The entity-model
|
||||
// tests that covered it are gone with it.
|
||||
|
||||
/// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary>
|
||||
[Fact]
|
||||
@@ -179,7 +139,6 @@ public sealed class ScriptingEntitiesTests
|
||||
ctx.Scripts.ShouldNotBeNull();
|
||||
ctx.VirtualTags.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarms.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the squashed V3Initial migration exists in the assembly.</summary>
|
||||
|
||||
@@ -177,11 +177,13 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression — a DB-backed actor (non-null <c>dbFactory</c>, no alarm store passed) still spawns
|
||||
/// the host on the EF-store fallback path, exactly as before Phase 4.
|
||||
/// Phase 4 Task 9 — the ConfigDb-backed EF fallback is retired. A DB-backed actor (non-null
|
||||
/// <c>dbFactory</c>) with no alarm store now SKIPS the ScriptedAlarm host: the wired
|
||||
/// <c>IAlarmStateStore</c> is the only source of condition-state persistence, and there is no
|
||||
/// longer an EF store constructed over <c>dbFactory</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DbBackedNode_WithNoStore_StillSpawnsScriptedAlarmHost_OnEfFallback()
|
||||
public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -192,7 +194,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
alarmStateStore: null));
|
||||
|
||||
AwaitStarted(actor);
|
||||
ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull();
|
||||
ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user