refactor(mesh-phase4): retire EfAlarmConditionStateStore + drop dead ScriptedAlarmState table (Task 9)
v2-ci / build (push) Successful in 4m3s
v2-ci / unit-tests (push) Failing after 13m59s

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:
Joseph Doherty
2026-07-23 14:33:11 -04:00
parent 26fad75c8a
commit 3a590a0cb7
12 changed files with 1727 additions and 741 deletions
@@ -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,