Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs
T
Joseph Doherty 3e62f1617b fix(localdb): registration follows replication config — baseline on enable, deregister self-heal on disable (pair-symmetric)
LocalDbSetup.OnReady registered all four replicated tables unconditionally on
every driver node, replicating or not. That was not merely wasteful (an
unreplicated node paid two INSERTs plus a json_object of the whole row inside
every write transaction, for an oplog nothing ever drained) — it made
baselineExistingRows unusable, so enabling replication on a long-running
default-OFF node converged on SUBSEQUENT WRITES ONLY and the rows already in the
file stayed invisible to the peer, silently and forever.

Registration now follows one predicate, LocalDbSetup.ReplicationIsConfigured:
LocalDb:Replication:PeerAddress OR :ApiKey non-blank. The OR is load-bearing —
only the dialling half of a pair sets PeerAddress, so keying on it alone would
strip capture from every passive node and the pair would converge in one
direction only. SyncListenPort deliberately does not join it: the passive
endpoint is fail-closed on the key, so a port with no ApiKey syncs with nobody.

  configured   => RegisterReplicated(table, baselineExistingRows: true), per
                  table, in the unchanged load-bearing DDL->register order
  unconfigured => DeregisterReplicated(table), an idempotent boot self-heal that
                  drops the capture triggers an earlier build left behind and
                  prunes that table's oplog/row-version rows. One Information
                  line, only when it actually cleaned something.

PAIR SYMMETRY: the sync handshake compares registered-table digests fail-closed,
so flipping either key is a both-nodes-together change — set it on both and
restart both. Documented in CLAUDE.md, docs/Configuration.md and the replication
runbook (which claimed "this repo never calls DeregisterReplicated").

Verified against the pinned LocalDb 0.2.1: RegisterReplicated(string, bool) and
DeregisterReplicated(string) -> bool both exist; baselining seeds
__localdb_row_version at HLC 0 and the deregister return value distinguishes
"cleaned" from "nothing to clean". No pin bump needed.

Tests: LocalDbSetupTests gains pins for both branches (configured via
PeerAddress alone and via ApiKey alone; unconfigured => no tables, zero
triggers; late-enable => an HLC-0 baseline ledger entry; the log line fires once
and only when something was cleaned) — four went red against the old
unconditional code, and all four go red again under a constant-true predicate.
LocalDbWiringTests splits its exact-set pin into a replicating graph and a
default-OFF one. LocalDbPairHarness and AlarmSfLegacyMigratorTests built their
database from LocalDb:Path alone while configuring the replication engine from a
second config — production reads one configuration, so the two are now aligned.
Host.IntegrationTests: 224 passed, 0 failed, 5 skipped.
2026-08-18 07:25:01 -04:00

343 lines
14 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
/// <summary>
/// The one-time copy of the pre-consolidation <c>alarm-historian.db</c> queue into the
/// consolidated LocalDb.
/// </summary>
/// <remarks>
/// <para>
/// What is being protected is undelivered alarm history: rows that are in the legacy file
/// precisely because the historian could not be reached. Dropping them on upgrade would
/// discard exactly the audit trail the store-and-forward queue exists to preserve.
/// </para>
/// <para>
/// Run against a real temp-file <see cref="ILocalDb"/> built through the production
/// <see cref="LocalDbSetup.OnReady"/>, so the registration order the migrator depends on is
/// the one the host actually runs — a hand-written schema here would prove only that the
/// test agrees with itself.
/// </para>
/// </remarks>
public sealed class AlarmSfLegacyMigratorTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-mig-{Guid.NewGuid():N}.db");
private readonly string _legacyPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-legacy-{Guid.NewGuid():N}.db");
private ServiceProvider? _provider;
// ---- the cases ---------------------------------------------------------------------------
[Fact]
public async Task Every_legacy_row_lands_in_the_consolidated_table()
{
SeedLegacy(
(1, "eq/a", Payload("eq/a"), 0, dead: 0),
(2, "eq/b", Payload("eq/b"), 3, dead: 0),
(3, "eq/c", Payload("eq/c"), 9, dead: 1));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(3);
// Attempt counts and the dead-letter flag must survive: a migrated row that lost its
// attempt count would restart its retry budget, and one that lost its dead-letter flag
// would be re-sent to a historian that already refused it permanently.
var rows = await db.QueryAsync(
"SELECT alarm_id, attempt_count, dead_lettered FROM alarm_sf_events ORDER BY alarm_id",
r => (AlarmId: r.GetString(0), Attempts: r.GetInt64(1), Dead: r.GetInt64(2)),
parameters: null,
TestContext.Current.CancellationToken);
rows.ShouldBe([("eq/a", 0L, 0L), ("eq/b", 3L, 0L), ("eq/c", 9L, 1L)]);
}
[Fact]
public async Task Migrated_rows_enter_the_oplog()
{
// THE ordering pin. Capture is trigger-based, so rows copied in before
// RegisterReplicated would never enter the oplog and would never reach the peer —
// silently, and permanently. A migrator invoked too early passes every other case here.
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
var oplog = await db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'",
r => r.GetInt64(0),
parameters: null,
TestContext.Current.CancellationToken);
oplog[0].ShouldBe(1);
}
[Fact]
public async Task A_second_run_is_a_no_op()
{
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
var config = ConfigWithLegacyPath();
AlarmSfLegacyMigrator.Migrate(db, config);
AlarmSfLegacyMigrator.Migrate(db, config);
(await CountAsync(db)).ShouldBe(1);
File.Exists(_legacyPath).ShouldBeFalse("the legacy file is renamed once the copy commits");
File.Exists(_legacyPath + ".migrated").ShouldBeTrue();
}
[Fact]
public async Task Re_migrating_the_same_rows_cannot_duplicate_them()
{
// The sidecar rename is the normal guard, but it is not the only one that has to hold:
// a crash between commit and rename leaves the file in place, and the next boot copies it
// again. Ids are derived from the payload, so the re-copy collides with itself and is
// ignored rather than doubling the queue.
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
File.Move(_legacyPath + ".migrated", _legacyPath); // simulate the crash-before-rename
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(1);
}
[Fact]
public async Task An_older_legacy_file_missing_a_column_still_migrates()
{
// A file written by an older build predates LastError. Naming a missing column in the
// SELECT throws "no such column", which would discard every row in the table rather than
// the one field. Intersecting the column list first means the data migrates and the
// absent column keeps its schema default.
SeedLegacyWithoutLastError((1, "eq/a", Payload("eq/a")));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(1);
}
[Fact]
public async Task A_failed_copy_leaves_the_legacy_file_untouched()
{
// The legacy table exists but its payload column is missing, so the required-column guard
// rejects the file. Nothing is copied and nothing is renamed: the operator still has the
// original to recover from, which is the whole point of renaming only after commit.
SeedUnrecognisedLegacy();
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(0);
File.Exists(_legacyPath).ShouldBeTrue("an un-copied file must not be renamed away");
File.Exists(_legacyPath + ".migrated").ShouldBeFalse();
}
[Fact]
public async Task A_missing_legacy_file_is_a_clean_no_op()
{
// The expected case on a fresh install and on any node that never enabled the historian.
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(0);
}
[Theory]
[InlineData(":memory:")]
[InlineData("file:thing?mode=memory")]
[InlineData("")]
public async Task Non_file_legacy_sources_are_no_ops(string configured)
{
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, Config(configured));
(await CountAsync(db)).ShouldBe(0);
}
[Fact]
public async Task Two_nodes_migrating_the_same_event_converge_on_one_row()
{
// A warm pair's two legacy files overlap: HistorianAdapterActor default-writes while the
// redundancy role is unknown, so both nodes accepted the same transitions during every
// boot window. Node-prefixed ids would carry that duplication forward into the merged
// buffer permanently; payload-derived ids collapse it.
var shared = Payload("eq/shared");
SeedLegacy((1, "eq/shared", shared, 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
// The peer's file: a different legacy RowId for the very same event.
var peerLegacy = _legacyPath + ".peer";
SeedLegacyAt(peerLegacy, (77, "eq/shared", shared, 0, dead: 0));
AlarmSfLegacyMigrator.Migrate(db, Config(peerLegacy));
(await CountAsync(db)).ShouldBe(1);
try { File.Delete(peerLegacy + ".migrated"); } catch { /* best effort */ }
}
// ---- fixture -----------------------------------------------------------------------------
private ILocalDb BuildDb()
{
// The sync ApiKey is what makes OnReady register the tables for capture at all — since
// 2026-08-18 registration follows the replication config rather than being unconditional
// (see LocalDbSetup.ReplicationIsConfigured). Without it these databases would have no
// capture triggers and Migrated_rows_enter_the_oplog would pin nothing. A passive node
// carries exactly this and no PeerAddress, so it is also the realistic shape.
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = _dbPath,
["LocalDb:Replication:ApiKey"] = "test-sync-key",
})
.Build();
_provider = new ServiceCollection()
.AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration))
.BuildServiceProvider();
return _provider.GetRequiredService<ILocalDb>();
}
private IConfiguration ConfigWithLegacyPath() => Config(_legacyPath);
private static IConfiguration Config(string legacyPath) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AlarmHistorian:DatabasePath"] = legacyPath,
})
.Build();
private static async Task<long> CountAsync(ILocalDb db)
{
var rows = await db.QueryAsync(
"SELECT COUNT(*) FROM alarm_sf_events",
r => r.GetInt64(0),
parameters: null,
TestContext.Current.CancellationToken);
return rows[0];
}
/// <summary>A payload shaped like a serialized <c>AlarmHistorianEvent</c>.</summary>
private static string Payload(string alarmId) =>
$$"""{"AlarmId":"{{alarmId}}","EquipmentPath":"line/eq","AlarmName":"hi","TimestampUtc":"2026-07-20T00:00:00Z"}""";
private void SeedLegacy(params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows) =>
SeedLegacyAt(_legacyPath, rows);
/// <summary>Creates a legacy queue file with the full pre-cutover schema and the given rows.</summary>
private static void SeedLegacyAt(
string path, params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows)
{
using var conn = Open(path);
Exec(conn, """
CREATE TABLE Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
LastError TEXT NULL,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
""");
foreach (var row in rows)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson, AttemptCount, DeadLettered)
VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload, $attempts, $dead)
""";
cmd.Parameters.AddWithValue("$id", row.RowId);
cmd.Parameters.AddWithValue("$alarm", row.AlarmId);
cmd.Parameters.AddWithValue("$payload", row.Payload);
cmd.Parameters.AddWithValue("$attempts", row.Attempts);
cmd.Parameters.AddWithValue("$dead", row.dead);
cmd.ExecuteNonQuery();
}
}
/// <summary>A legacy file from an older build, predating the <c>LastError</c> column.</summary>
private void SeedLegacyWithoutLastError(params (long RowId, string AlarmId, string Payload)[] rows)
{
using var conn = Open(_legacyPath);
Exec(conn, """
CREATE TABLE Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
""");
foreach (var row in rows)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson)
VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload)
""";
cmd.Parameters.AddWithValue("$id", row.RowId);
cmd.Parameters.AddWithValue("$alarm", row.AlarmId);
cmd.Parameters.AddWithValue("$payload", row.Payload);
cmd.ExecuteNonQuery();
}
}
/// <summary>A file with a <c>Queue</c> table this build cannot read — no payload column.</summary>
private void SeedUnrecognisedLegacy()
{
using var conn = Open(_legacyPath);
Exec(conn, "CREATE TABLE Queue (RowId INTEGER PRIMARY KEY AUTOINCREMENT, Something TEXT);");
Exec(conn, "INSERT INTO Queue (Something) VALUES ('x');");
}
private static SqliteConnection Open(string path)
{
var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
conn.Open();
return conn;
}
private static void Exec(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
public void Dispose()
{
_provider?.Dispose();
SqliteConnection.ClearAllPools();
foreach (var baseName in new[] { _dbPath, _legacyPath, _legacyPath + ".migrated" })
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(baseName + suffix); } catch { /* best effort */ }
}
}
}
}