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
@@ -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>
@@ -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));
}
}