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
118 lines
5.6 KiB
C#
118 lines
5.6 KiB
C#
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
|
|
|
|
/// <summary>
|
|
/// Review R05:4 — the scripted-alarm comment trail moved off the condition row into its own
|
|
/// append-only <c>alarm_condition_comment</c> table. This proves the move kept the property that
|
|
/// mattered: the trail still reaches the redundant pair peer, over a real loopback h2c transport
|
|
/// through the real fail-closed interceptor, with both nodes initialised by the production
|
|
/// <c>LocalDbSetup.OnReady</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// State is written and read through the production <see cref="LocalDbAlarmConditionStateStore"/>
|
|
/// on each node, so the ordinal keying, the incremental append and the load-side merge under
|
|
/// test are the ones production runs.
|
|
/// </remarks>
|
|
[Collection("LocalDbPairConvergence")]
|
|
public sealed class AlarmConditionCommentConvergenceTests
|
|
{
|
|
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
|
|
|
private static LocalDbAlarmConditionStateStore Store(ILocalDb db) =>
|
|
new(db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private static async Task<int> CommentCountAsync(ILocalDb db, string alarmId) =>
|
|
(await Store(db).LoadAsync(alarmId, Ct))?.Comments.Count ?? -1;
|
|
|
|
[Fact]
|
|
public async Task CommentsWrittenOnA_ArriveOnB_AndBsAppendArrivesBackOnA()
|
|
{
|
|
await using var pair = new LocalDbPairHarness();
|
|
await pair.StartAsync();
|
|
|
|
var storeA = Store(pair.A);
|
|
var state = AlarmConditionState.Fresh("eq/alarm-1", T0) with
|
|
{
|
|
Active = AlarmActiveState.Active,
|
|
Acked = AlarmAckedState.Unacknowledged,
|
|
};
|
|
state = Part9StateMachine.ApplyAddComment(state, "jane", "first look", T0.AddMinutes(1)).State;
|
|
state = Part9StateMachine.ApplyAcknowledge(state, "bob", "on it", T0.AddMinutes(2)).State;
|
|
await storeA.SaveAsync(state, Ct);
|
|
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await CommentCountAsync(pair.B, "eq/alarm-1") == 2,
|
|
"node B to hold node A's two comment-trail entries");
|
|
|
|
var onB = await Store(pair.B).LoadAsync("eq/alarm-1", Ct);
|
|
onB.ShouldNotBeNull();
|
|
onB.Comments.ShouldBe(state.Comments);
|
|
onB.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
|
|
|
// The reverse direction, as an incremental append: B adds one entry to the trail it loaded,
|
|
// and only that entry has to travel for A to see the full trail.
|
|
var appended = Part9StateMachine.ApplyAddComment(onB, "amy", "from B", T0.AddMinutes(3)).State;
|
|
await Store(pair.B).SaveAsync(appended, Ct);
|
|
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await CommentCountAsync(pair.A, "eq/alarm-1") == 3,
|
|
"node A to receive the entry node B appended");
|
|
|
|
(await storeA.LoadAsync("eq/alarm-1", Ct))!.Comments.ShouldBe(appended.Comments);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BothNodesRecordingTheSameEntry_ConvergeToOneEntry_NotTwo()
|
|
{
|
|
// Both pair nodes run the engine and both apply every operator command, each stamping the
|
|
// entry with its own clock. Ordinal keying is what collapses the two copies to one entry;
|
|
// a content-derived key would leave the trail doubled on both nodes.
|
|
await using var pair = new LocalDbPairHarness();
|
|
await pair.StartAsync();
|
|
|
|
var fresh = AlarmConditionState.Fresh("eq/alarm-2", T0);
|
|
var onA = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1)).State;
|
|
var onB = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1).AddMilliseconds(7)).State;
|
|
await Store(pair.A).SaveAsync(onA, Ct);
|
|
await Store(pair.B).SaveAsync(onB, Ct);
|
|
|
|
// Positive control: a DISTINCT second alarm written only on B. Without it "one entry on
|
|
// both" would also hold for two databases that never replicated at all.
|
|
var onlyOnB = Part9StateMachine.ApplyAddComment(
|
|
AlarmConditionState.Fresh("eq/only-on-b", T0), "amy", "b only", T0.AddSeconds(2)).State;
|
|
await Store(pair.B).SaveAsync(onlyOnB, Ct);
|
|
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await CommentCountAsync(pair.A, "eq/only-on-b") == 1,
|
|
"node A to receive node B's distinct alarm");
|
|
|
|
// LWW picks ONE node's copy and both nodes end up holding that same copy.
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () =>
|
|
{
|
|
var a = await Store(pair.A).LoadAsync("eq/alarm-2", Ct);
|
|
var b = await Store(pair.B).LoadAsync("eq/alarm-2", Ct);
|
|
return a is not null && b is not null
|
|
&& a.Comments.Count == 1 && b.Comments.Count == 1
|
|
&& a.Comments[0] == b.Comments[0];
|
|
},
|
|
"both nodes to agree on a single copy of the shared entry");
|
|
|
|
// Held, not merely reached: nothing late may double the trail on either node.
|
|
(await LocalDbPairHarness.HeldWithinAsync(
|
|
async () => await CommentCountAsync(pair.A, "eq/alarm-2") != 1
|
|
|| await CommentCountAsync(pair.B, "eq/alarm-2") != 1,
|
|
TimeSpan.FromSeconds(2)))
|
|
.ShouldBeFalse("the shared entry must stay one entry on both nodes, never two");
|
|
}
|
|
}
|