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
384 lines
17 KiB
C#
384 lines
17 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>
|
|
/// The alarm comment audit trail lives in its own append-only table
|
|
/// (<see cref="AlarmConditionStateSchema.CommentsTable"/>) keyed by alarm id, so the condition
|
|
/// row stays a fixed size and a transition's write — and therefore its replication payload —
|
|
/// does not grow with the alarm's history.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The payload is measured where it costs: the bytes the LocalDb capture triggers write into
|
|
/// <c>__localdb_oplog</c> for one save, which is exactly what ships to the pair peer. Both
|
|
/// tables are registered for capture here, as they are on a replicating node.
|
|
/// </remarks>
|
|
public sealed class LocalDbAlarmCommentAuditTests : IDisposable
|
|
{
|
|
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
|
|
|
private readonly string _dbPath =
|
|
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-comments-{Guid.NewGuid():N}.db");
|
|
|
|
private readonly ServiceProvider _provider;
|
|
private readonly ILocalDb _db;
|
|
|
|
public LocalDbAlarmCommentAuditTests()
|
|
{
|
|
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();
|
|
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 CancellationToken Ct => CancellationToken.None;
|
|
|
|
private static ImmutableList<AlarmComment> Comments(int count, string prefix = "c")
|
|
{
|
|
var builder = ImmutableList.CreateBuilder<AlarmComment>();
|
|
for (var i = 0; i < count; i++)
|
|
builder.Add(new AlarmComment(T0.AddSeconds(i), "jane", "AddComment", $"{prefix}-{i:D6}"));
|
|
return builder.ToImmutable();
|
|
}
|
|
|
|
/// <summary>An unacknowledged state carrying <paramref name="comments"/>, ready to be acked.</summary>
|
|
private static AlarmConditionState Unacked(string alarmId, ImmutableList<AlarmComment> comments) =>
|
|
AlarmConditionState.Fresh(alarmId, T0) with
|
|
{
|
|
Active = AlarmActiveState.Active,
|
|
Acked = AlarmAckedState.Unacknowledged,
|
|
Comments = comments,
|
|
};
|
|
|
|
private async Task<long> OplogHighWaterAsync() =>
|
|
(await _db.QueryAsync("SELECT COALESCE(MAX(seq), 0) FROM __localdb_oplog", r => r.GetInt64(0), null, Ct))[0];
|
|
|
|
/// <summary>Bytes captured into the oplog above <paramref name="afterSeq"/> — the replication payload.</summary>
|
|
private async Task<long> OplogBytesAboveAsync(long afterSeq) =>
|
|
(await _db.QueryAsync(
|
|
"SELECT COALESCE(SUM(LENGTH(pk_json) + COALESCE(LENGTH(row_json), 0)), 0) FROM __localdb_oplog WHERE seq > @S",
|
|
r => r.GetInt64(0),
|
|
new { S = afterSeq },
|
|
Ct))[0];
|
|
|
|
private async Task<long> OplogEntriesAboveAsync(long afterSeq, string table) =>
|
|
(await _db.QueryAsync(
|
|
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > @S AND table_name = @T",
|
|
r => r.GetInt64(0),
|
|
new { S = afterSeq, T = table },
|
|
Ct))[0];
|
|
|
|
private async Task<long> ScalarAsync(string sql) =>
|
|
(await _db.QueryAsync(sql, r => r.GetInt64(0), null, Ct))[0];
|
|
|
|
/// <summary>
|
|
/// Seeds <paramref name="alarmId"/> with <paramref name="historyLength"/> comments, then
|
|
/// returns the oplog bytes one acknowledge transition (one new comment) costs.
|
|
/// </summary>
|
|
private async Task<long> AckTransitionBytesAsync(string alarmId, int historyLength, string ackComment)
|
|
{
|
|
var store = NewStore();
|
|
var seeded = Unacked(alarmId, Comments(historyLength));
|
|
await store.SaveAsync(seeded, Ct);
|
|
|
|
var before = await OplogHighWaterAsync();
|
|
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", ackComment, T0.AddDays(1)).State;
|
|
await store.SaveAsync(acked, Ct);
|
|
return await OplogBytesAboveAsync(before);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransitionPayload_IsConstant_WhetherTheAlarmCarries10OrTenThousandComments()
|
|
{
|
|
var small = await AckTransitionBytesAsync("alarm-small", 10, "ack");
|
|
var large = await AckTransitionBytesAsync("alarm-large", 10_000, "ack");
|
|
|
|
// Constant: the only difference allowed is the width of the new comment's ordinal ("10" vs
|
|
// "10000", in pk_json and row_json) — a handful of bytes, not O(n). The alarm ids are the
|
|
// same length on purpose.
|
|
small.ShouldBeGreaterThan(0);
|
|
Math.Abs(large - small).ShouldBeLessThanOrEqualTo(16,
|
|
$"one ack transition cost {small} bytes at 10 comments but {large} at 10,000");
|
|
large.ShouldBeLessThan(2_048);
|
|
|
|
// Positive control on the instrument: the seeded history really is large and really was
|
|
// captured — otherwise a "constant" result could just mean capture is off.
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-large'"))
|
|
.ShouldBe(10_001);
|
|
var historyBytes = await ScalarAsync(
|
|
"SELECT SUM(LENGTH(row_json)) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'");
|
|
historyBytes.ShouldBeGreaterThan(1_000_000);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransitionPayload_PositiveControl_TheMeasurementSeesTheNewCommentsBytes()
|
|
{
|
|
// Proves the measurement above is not vacuous: the oplog delta of a transition does carry
|
|
// the appended comment, so a 4 KB comment costs at least 4 KB.
|
|
var plain = await AckTransitionBytesAsync("alarm-plain", 10, "ack");
|
|
var heavy = await AckTransitionBytesAsync("alarm-heavy", 10, new string('x', 4_096));
|
|
|
|
(heavy - plain).ShouldBeGreaterThanOrEqualTo(4_096 * 2 - 16,
|
|
"the comment text rides both the comment row and last_ack_comment on the condition row");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Transition_WritesOneConditionRowAndOnlyTheAppendedCommentRow()
|
|
{
|
|
var store = NewStore();
|
|
var seeded = Unacked("alarm-append", Comments(50));
|
|
await store.SaveAsync(seeded, Ct);
|
|
|
|
var before = await OplogHighWaterAsync();
|
|
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", "ack", T0.AddDays(1)).State;
|
|
await store.SaveAsync(acked, Ct);
|
|
|
|
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1);
|
|
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(1);
|
|
|
|
var loaded = await store.LoadAsync("alarm-append", Ct);
|
|
loaded.ShouldNotBeNull();
|
|
loaded.Comments.ShouldBe(acked.Comments);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConditionRow_StaysFixedSize_InlineColumnIsAlwaysEmpty()
|
|
{
|
|
var store = NewStore();
|
|
await store.SaveAsync(Unacked("alarm-fixed", Comments(500)), Ct);
|
|
|
|
(await _db.QueryAsync(
|
|
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-fixed'",
|
|
r => r.GetString(0), null, Ct))[0]
|
|
.ShouldBe("[]");
|
|
(await store.LoadAsync("alarm-fixed", Ct))!.Comments.Count.ShouldBe(500);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnchangedSave_WritesNoCommentRows()
|
|
{
|
|
var store = NewStore();
|
|
var state = Unacked("alarm-idle", Comments(20));
|
|
await store.SaveAsync(state, Ct);
|
|
|
|
var before = await OplogHighWaterAsync();
|
|
await store.SaveAsync(state with { LastTransitionUtc = T0.AddMinutes(1) }, Ct);
|
|
|
|
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
|
|
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1,
|
|
"positive control: the save itself was captured");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAll_ReturnsEachAlarmsOwnTrailInOrder()
|
|
{
|
|
var store = NewStore();
|
|
var a = Unacked("alarm-a", Comments(3, "a"));
|
|
var b = Unacked("alarm-b", Comments(5, "b"));
|
|
await store.SaveAsync(a, Ct);
|
|
await store.SaveAsync(b, Ct);
|
|
|
|
var all = (await store.LoadAllAsync(Ct)).ToDictionary(s => s.AlarmId);
|
|
|
|
all["alarm-a"].Comments.ShouldBe(a.Comments);
|
|
all["alarm-b"].Comments.ShouldBe(b.Comments);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Remove_DeletesTheAlarmsTrail_AndAReCreatedAlarmStartsEmpty()
|
|
{
|
|
var store = NewStore();
|
|
await store.SaveAsync(Unacked("alarm-gone", Comments(4)), Ct);
|
|
await store.SaveAsync(Unacked("alarm-kept", Comments(2)), Ct);
|
|
|
|
await store.RemoveAsync("alarm-gone", Ct);
|
|
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-gone'"))
|
|
.ShouldBe(0);
|
|
(await store.LoadAsync("alarm-kept", Ct))!.Comments.Count.ShouldBe(2, "positive control");
|
|
|
|
var recreated = AlarmConditionState.Fresh("alarm-gone", T0)
|
|
with { Comments = ImmutableList.Create(new AlarmComment(T0, "amy", "AddComment", "new life")) };
|
|
await store.SaveAsync(recreated, Ct);
|
|
(await store.LoadAsync("alarm-gone", Ct))!.Comments.ShouldBe(recreated.Comments);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommentsAlreadyHeldAtAnOrdinal_AreNeverRewritten()
|
|
{
|
|
// Append-only: a save whose list disagrees with what is stored at an existing ordinal (the
|
|
// peer's copy of the same logical entry already arrived) leaves the stored entry alone.
|
|
var store = NewStore();
|
|
var original = Unacked("alarm-ao", Comments(3));
|
|
await store.SaveAsync(original, Ct);
|
|
|
|
var rewritten = original with { Comments = original.Comments.SetItem(1, new AlarmComment(T0, "eve", "AddComment", "forged")) };
|
|
await store.SaveAsync(rewritten, Ct);
|
|
|
|
(await store.LoadAsync("alarm-ao", Ct))!.Comments.ShouldBe(original.Comments);
|
|
}
|
|
|
|
// ---- back-compat + migration -----------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The exact shape the pre-split store wrote into <c>comments_json</c>
|
|
/// (<c>JsonSerializerDefaults.Web</c>: camelCase, Utc timestamps with a trailing <c>Z</c>).
|
|
/// </summary>
|
|
private const string OldShapeCommentsJson =
|
|
"""[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"checking pump"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"bob","kind":"Confirm","text":"all clear"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"system","kind":"AutoUnshelve","text":""}]""";
|
|
|
|
private static readonly ImmutableList<AlarmComment> OldShapeComments = ImmutableList.Create(
|
|
new AlarmComment(new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"),
|
|
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "bob", "Confirm", "all clear"),
|
|
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "system", "AutoUnshelve", ""));
|
|
|
|
private async Task InsertOldShapeRowAsync(string alarmId, string commentsJson = OldShapeCommentsJson) =>
|
|
await _db.ExecuteAsync(
|
|
"""
|
|
INSERT INTO alarm_condition_state
|
|
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
|
shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc,
|
|
last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc)
|
|
VALUES
|
|
(@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
|
|
NULL, 'jane', 'checking pump', '2026-06-10T10:00:00.0000000Z',
|
|
'bob', 'all clear', '2026-06-10T11:30:00.1234567Z', @Json, '2026-06-10T11:30:00.1234567Z')
|
|
""",
|
|
new { Id = alarmId, Json = commentsJson },
|
|
Ct);
|
|
|
|
private static void ShouldBeTheOldShapeState(AlarmConditionState? loaded, string alarmId)
|
|
{
|
|
loaded.ShouldNotBeNull();
|
|
loaded.AlarmId.ShouldBe(alarmId);
|
|
loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
|
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
|
loaded.LastAckUser.ShouldBe("jane");
|
|
loaded.LastConfirmComment.ShouldBe("all clear");
|
|
loaded.Comments.ShouldBe(OldShapeComments);
|
|
loaded.Comments.ShouldAllBe(c => c.TimestampUtc.Kind == DateTimeKind.Utc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OldShapeRow_WithInlineComments_LoadsExactlyAsBefore()
|
|
{
|
|
await InsertOldShapeRowAsync("alarm-legacy");
|
|
|
|
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-legacy", Ct), "alarm-legacy");
|
|
ShouldBeTheOldShapeState((await NewStore().LoadAllAsync(Ct)).Single(), "alarm-legacy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Migration_MovesInlineCommentsToTheAuditTable_AndClearsTheInlineColumn()
|
|
{
|
|
await InsertOldShapeRowAsync("alarm-m1");
|
|
await InsertOldShapeRowAsync("alarm-m2");
|
|
await InsertOldShapeRowAsync("alarm-empty", "[]");
|
|
|
|
int migrated;
|
|
using (var connection = _db.CreateConnection())
|
|
migrated = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
|
|
|
migrated.ShouldBe(2);
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(6);
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_state WHERE comments_json <> '[]'")).ShouldBe(0);
|
|
|
|
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m1", Ct), "alarm-m1");
|
|
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m2", Ct), "alarm-m2");
|
|
(await NewStore().LoadAsync("alarm-empty", Ct))!.Comments.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Migration_IsIdempotent_ASecondRunChangesNothing()
|
|
{
|
|
await InsertOldShapeRowAsync("alarm-idem");
|
|
using (var connection = _db.CreateConnection())
|
|
AlarmConditionStateSchema.MigrateInlineComments(connection).ShouldBe(1, "positive control");
|
|
|
|
var before = await OplogHighWaterAsync();
|
|
using (var connection = _db.CreateConnection())
|
|
AlarmConditionStateSchema.MigrateInlineComments(connection).ShouldBe(0);
|
|
|
|
(await OplogHighWaterAsync()).ShouldBe(before, "a re-run must write nothing");
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
|
|
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-idem", Ct), "alarm-idem");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Migration_OverAPartiallyMigratedRow_DoesNotDuplicateOrReorder()
|
|
{
|
|
// An inline row reappearing after its comments were already moved (e.g. a restored backup):
|
|
// the ordinals already held are kept, nothing is duplicated.
|
|
await InsertOldShapeRowAsync("alarm-partial");
|
|
using (var connection = _db.CreateConnection())
|
|
AlarmConditionStateSchema.MigrateInlineComments(connection);
|
|
await _db.ExecuteAsync(
|
|
"UPDATE alarm_condition_state SET comments_json = @Json WHERE scripted_alarm_id = 'alarm-partial'",
|
|
new { Json = OldShapeCommentsJson }, Ct);
|
|
|
|
using (var connection = _db.CreateConnection())
|
|
AlarmConditionStateSchema.MigrateInlineComments(connection).ShouldBe(1);
|
|
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
|
|
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-partial", Ct), "alarm-partial");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SavingALoadedOldShapeState_MovesItsCommentsAndAppendsTheNewOne()
|
|
{
|
|
// The lazy path: an old-shape row the boot migration never saw (it was written before the
|
|
// first save on this build) still converges on the first save.
|
|
await InsertOldShapeRowAsync("alarm-lazy");
|
|
var store = NewStore();
|
|
var loaded = await store.LoadAsync("alarm-lazy", Ct);
|
|
|
|
var next = Part9StateMachine.ApplyAddComment(loaded!, "amy", "follow-up", T0.AddDays(2)).State;
|
|
await store.SaveAsync(next, Ct);
|
|
|
|
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-lazy'"))
|
|
.ShouldBe(4);
|
|
(await _db.QueryAsync(
|
|
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-lazy'",
|
|
r => r.GetString(0), null, Ct))[0]
|
|
.ShouldBe("[]");
|
|
(await NewStore().LoadAsync("alarm-lazy", Ct))!.Comments.ShouldBe(next.Comments);
|
|
}
|
|
}
|