Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs
T
Joseph Doherty 846c8d6335 fix(alarms): move scripted-alarm comment trail to its own append-only table (R05:4)
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
2026-10-22 16:36:33 -04:00

65 lines
2.3 KiB
C#

using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
/// <summary>
/// Verifies <see cref="AlarmConditionStateSchema.Apply"/> creates its table and is idempotent —
/// the DDL depends on nothing but a <see cref="SqliteConnection"/>, so a bare in-memory
/// connection is a faithful harness (no <c>zb_hlc_next()</c> UDF is involved in <c>CREATE TABLE</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AlarmConditionStateSchemaTests
{
private static SqliteConnection OpenInMemory()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
return connection;
}
/// <summary>A single Apply creates the table.</summary>
[Fact]
public void Apply_creates_the_state_table()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @Name";
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.StateTable);
((long)cmd.ExecuteScalar()!).ShouldBe(1);
}
/// <summary>Apply also creates the append-only comment table, keyed by alarm id + ordinal.</summary>
[Fact]
public void Apply_creates_the_comment_table_with_a_composite_key()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM pragma_table_info(@Name) WHERE pk > 0 ORDER BY pk";
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.CommentsTable);
var pk = new List<string>();
using (var reader = cmd.ExecuteReader())
while (reader.Read()) pk.Add(reader.GetString(0));
pk.ShouldBe(["scripted_alarm_id", "seq"]);
}
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
[Fact]
public void Apply_is_idempotent()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection));
}
}