846c8d6335
Every Part 9 transition used to re-serialize the alarm's whole comment trail into alarm_condition_state.comments_json and replicate it to the pair peer: O(n) per transition, O(n^2) over an alarm's life (measured: one ack at 10k comments shipped 1,080,553 oplog bytes; now ~1.6 KB, the same as at 10). - New replicated table alarm_condition_comment, PK (scripted_alarm_id, seq), seq = the entry's ordinal in the trail. Ordinal (not content) keying makes the two pair nodes' copies of one logical entry (each stamped by its own clock, since both nodes apply every command) collapse to one row under LWW. - LocalDbAlarmConditionStateStore.SaveAsync appends only entries past the stored MAX(seq) (INSERT OR IGNORE, append-only) and upserts a fixed-size condition row with comments_json = "[]", in one transaction. Remove drops the trail with the row. Load merges the table with any inline trail. - comments_json column kept (always "[]") so the condition table's shape and replication digest are unchanged and old-shape rows still load exactly. - AlarmConditionStateSchema.MigrateInlineComments: idempotent per-row move of inline trails, run by LocalDbSetup.OnReady after RegisterReplicated so the moved entries replicate. Comment table joins ReplicatedTables (now five). Tests: constant transition payload at 10 vs 10k comments (+ instrument positive control), old-shape back-compat load, migration idempotency / partial re-run / lazy move, OnReady boot migration captured in the oplog, and two loopback pair tests (A->B and B->A trail convergence; the shared entry converges to one copy, with a distinct-alarm positive control). Refs #748 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
385 lines
15 KiB
C#
385 lines
15 KiB
C#
using System.Collections.Immutable;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
|
|
|
/// <summary>
|
|
/// Round-trip + upsert + lifecycle coverage for <see cref="LocalDbAlarmConditionStateStore"/>,
|
|
/// the LocalDb-backed <see cref="IAlarmStateStore"/> over the replicated
|
|
/// <c>alarm_condition_state</c> table.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ported verbatim from <c>EfAlarmConditionStateStoreTests</c> — the two stores share the
|
|
/// <see cref="IAlarmStateStore"/> contract, so the assertions transfer unchanged and prove
|
|
/// parity. Only store construction differs: this suite drives a real temp-file
|
|
/// <see cref="ILocalDb"/> (the library has no in-memory mode, and a bare
|
|
/// <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture triggers
|
|
/// call), whereas the EF suite used an in-memory DbContext factory.
|
|
/// </remarks>
|
|
public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
|
|
{
|
|
private readonly string _dbPath =
|
|
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-state-{Guid.NewGuid():N}.db");
|
|
|
|
private readonly ServiceProvider _provider;
|
|
private readonly ILocalDb _db;
|
|
|
|
public LocalDbAlarmConditionStateStoreTests()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
|
.Build();
|
|
|
|
_provider = new ServiceCollection()
|
|
.AddZbLocalDb(configuration, db =>
|
|
{
|
|
using (var connection = db.CreateConnection())
|
|
{
|
|
AlarmConditionStateSchema.Apply(connection);
|
|
}
|
|
|
|
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
|
|
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
|
|
})
|
|
.BuildServiceProvider();
|
|
|
|
_db = _provider.GetRequiredService<ILocalDb>();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_provider.Dispose();
|
|
|
|
// Pooled connections keep a handle open past dispose; clearing the pools first is what
|
|
// makes the delete actually succeed.
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
|
{
|
|
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
private LocalDbAlarmConditionStateStore NewStore()
|
|
=> new(_db, NullLogger<LocalDbAlarmConditionStateStore>.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();
|
|
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");
|
|
// Shouldly's DateTime ShouldBe compares ticks and ignores DateTimeKind, so an explicit Kind
|
|
// assertion is the only guard on the Utc-kind contract the round-trip ("O") format encodes.
|
|
loaded.LastTransitionUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
|
}
|
|
|
|
/// <summary>Unset audit fields (LastAckUtc/User, LastConfirmUtc/User) round-trip back as null.</summary>
|
|
[Fact]
|
|
public async Task Null_audit_fields_round_trip_as_null()
|
|
{
|
|
var store = NewStore();
|
|
var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
|
var state = new AlarmConditionState(
|
|
AlarmId: "alarm-null-audit",
|
|
Enabled: AlarmEnabledState.Enabled,
|
|
Active: AlarmActiveState.Inactive,
|
|
Acked: AlarmAckedState.Unacknowledged,
|
|
Confirmed: AlarmConfirmedState.Unconfirmed,
|
|
Shelving: ShelvingState.Unshelved,
|
|
LastTransitionUtc: t,
|
|
LastActiveUtc: null,
|
|
LastClearedUtc: null,
|
|
LastAckUtc: null,
|
|
LastAckUser: null,
|
|
LastAckComment: null,
|
|
LastConfirmUtc: null,
|
|
LastConfirmUser: null,
|
|
LastConfirmComment: null,
|
|
Comments: ImmutableList<AlarmComment>.Empty);
|
|
|
|
await store.SaveAsync(state, CancellationToken.None);
|
|
var loaded = await store.LoadAsync("alarm-null-audit", CancellationToken.None);
|
|
|
|
loaded.ShouldNotBeNull();
|
|
loaded.LastAckUtc.ShouldBeNull();
|
|
loaded.LastAckUser.ShouldBeNull();
|
|
loaded.LastAckComment.ShouldBeNull();
|
|
loaded.LastConfirmUtc.ShouldBeNull();
|
|
loaded.LastConfirmUser.ShouldBeNull();
|
|
loaded.LastConfirmComment.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Loading an id that was never saved returns null.</summary>
|
|
[Fact]
|
|
public async Task LoadAsync_for_unknown_id_returns_null()
|
|
{
|
|
var store = NewStore();
|
|
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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>An empty comment list persists as the literal <c>"[]"</c> in the column.</summary>
|
|
[Fact]
|
|
public async Task Empty_comments_persist_as_bracket_pair()
|
|
{
|
|
var store = NewStore();
|
|
await store.SaveAsync(
|
|
Sample("alarm-empty-literal", comments: ImmutableList<AlarmComment>.Empty),
|
|
CancellationToken.None);
|
|
|
|
using var conn = _db.CreateConnection();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText =
|
|
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-empty-literal'";
|
|
var json = (string?)cmd.ExecuteScalar();
|
|
|
|
json.ShouldBe("[]");
|
|
}
|
|
|
|
/// <summary>An unknown enabled string loads back as Enabled (safe default).</summary>
|
|
[Fact]
|
|
public async Task Unknown_enum_strings_map_to_safe_defaults()
|
|
{
|
|
// Write raw rows carrying garbage enum strings to prove the load-side mappers coerce them.
|
|
using (var conn = _db.CreateConnection())
|
|
using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText =
|
|
"""
|
|
INSERT INTO alarm_condition_state
|
|
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
|
comments_json, updated_at_utc)
|
|
VALUES
|
|
('garbage', 'nonsense', 'nonsense', 'nonsense', 'nonsense', '[]', '2026-06-10T12:00:00.0000000Z')
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
var loaded = await NewStore().LoadAsync("garbage", CancellationToken.None);
|
|
|
|
loaded.ShouldNotBeNull();
|
|
loaded.Enabled.ShouldBe(AlarmEnabledState.Enabled); // unknown → Enabled
|
|
loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); // unknown → Unacknowledged
|
|
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); // unknown → Unconfirmed
|
|
loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); // unknown → Unshelved
|
|
}
|
|
|
|
/// <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();
|
|
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 conn = _db.CreateConnection())
|
|
using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText =
|
|
"SELECT COUNT(*) FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-upsert'";
|
|
((long)cmd.ExecuteScalar()!).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();
|
|
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();
|
|
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();
|
|
|
|
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
|
|
}
|
|
}
|