diff --git a/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json b/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json index 0272fcc7..512b83cc 100644 --- a/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json +++ b/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json @@ -37,10 +37,11 @@ { "id": 4, "subject": "Task 4: One-time alarm-historian.db legacy migrator", - "status": "pending", + "status": "completed", "blockedBy": [ 3 - ] + ], + "note": "AlarmSfLegacyMigrator runs LAST in OnReady (which now takes IConfiguration - no skip-migration overload exists). DEVIATION D-6: ids are the payload hash (AlarmSfSchema.DeriveId, lifted out of the sink) rather than mig-{node}-{legacyId} - a warm pair's two legacy files OVERLAP, and node-prefixing would carry that duplication forward forever. DEVIATION: tests live in Host.IntegrationTests; the plan's Host.Tests project does not exist." }, { "id": 5, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs index eaf4553c..7705dc02 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; @@ -35,6 +37,32 @@ public static class AlarmSfSchema /// The single primary-key column. public const string IdColumn = "id"; + /// + /// Derives a row's primary key from its serialized payload. + /// + /// + /// + /// Deterministic rather than a fresh GUID, so that the same event arriving twice + /// converges to one row under last-writer-wins instead of duplicating. That happens for + /// real in two places: HistorianAdapterActor default-writes while its redundancy + /// role is unknown, so both nodes of a pair accept the same fanned transition in the + /// window before the first snapshot; and both nodes independently migrate their own + /// legacy queue file, which for a warm pair holds overlapping history. + /// + /// + /// Two genuinely distinct events cannot collide. AlarmHistorianEvent carries a + /// full-precision timestamp alongside the alarm id, transition kind, message and user, + /// so an equal hash means an equal event. + /// + /// + /// The serialized AlarmHistorianEvent. + /// The row's primary key. + public static string DeriveId(string payloadJson) + { + ArgumentNullException.ThrowIfNull(payloadJson); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))); + } + /// /// Creates the buffer table if it does not already exist. Idempotent. /// diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs index 17285524..8c696694 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using Serilog; using ZB.MOM.WW.LocalDb; @@ -213,21 +211,6 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl catch (ObjectDisposedException) { /* raced with Dispose — nothing to re-arm */ } } - /// - /// Derives the row's primary key from its payload. - /// - /// - /// Deterministic rather than a fresh GUID so that the same event enqueued independently on - /// both nodes of a pair converges to one row under last-writer-wins instead of duplicating. - /// That happens for real: HistorianAdapterActor default-writes while its redundancy - /// role is unknown, so in the window before the first snapshot arrives both adapters accept - /// the same fanned transition. Two distinct events cannot collide — - /// carries a full-precision timestamp alongside the alarm - /// id, kind, message and user, so an equal hash means an equal event. - /// - private static string DeriveId(string payloadJson) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))); - /// public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) { @@ -249,7 +232,7 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl """, new { - Id = DeriveId(payload), + Id = AlarmSfSchema.DeriveId(payload), AlarmId = evt.AlarmId, EnqueuedAtUtc = _clock().ToString("O"), PayloadJson = payload, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs new file mode 100644 index 00000000..8e6cc31b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs @@ -0,0 +1,217 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// One-time copy of the pre-consolidation alarm-historian.db store-and-forward queue +/// into the consolidated ZB.MOM.WW.LocalDb database. +/// +/// +/// +/// What is at stake. Rows sit in that file precisely because the historian could not +/// be reached. Discarding them on upgrade would throw away exactly the alarm audit trail +/// the store-and-forward queue exists to protect — so the copy tolerates an older column +/// set rather than bailing out, and refuses to rename a file it did not actually read. +/// +/// +/// Runs after RegisterReplicated, deliberately. Capture is trigger-based, so +/// rows inserted before registration never enter the oplog and never reach the peer — +/// silently, and permanently, because nothing revisits history. Migrating after +/// registration means the recovered backlog replicates like any other write. +/// +/// +/// Ids come from the payload, not from the legacy row id. The legacy primary key was +/// RowId INTEGER PRIMARY KEY AUTOINCREMENT, which cannot survive into a replicated +/// table: each node allocated its own sequence, so node A's row 7 and node B's row 7 are +/// different alarms that would silently overwrite one another under last-writer-wins. +/// Hashing the payload () solves that and one more +/// problem besides — a warm pair's two legacy files overlap, because +/// HistorianAdapterActor default-writes while its redundancy role is unknown and +/// both nodes therefore accepted the same transitions during every boot window. A +/// node-prefixed id would carry that duplication into the merged buffer forever; an +/// equal-payload id collapses it. It is also what makes a re-run harmless under +/// INSERT OR IGNORE. +/// +/// +/// All-or-nothing. The copy runs in one transaction and the legacy file is renamed +/// to <name>.migrated only after the commit. A failure throws out of +/// OnReady, failing host startup with the legacy file untouched — no half-migrated +/// state to reason about, and the operator still holds the original. A later boot sees the +/// renamed file and no-ops. +/// +/// +public static class AlarmSfLegacyMigrator +{ + private const string MigratedSuffix = ".migrated"; + + /// The legacy queue table. + private const string LegacyTable = "Queue"; + + /// + /// The configuration key that used to carry the standalone queue file's path. Read as a raw + /// key because the corresponding AlarmHistorianOptions property was removed with the + /// bespoke file management it configured. + /// + public const string LegacyPathKey = "AlarmHistorian:DatabasePath"; + + /// The pre-removal default for . + private const string DefaultLegacyPath = "alarm-historian.db"; + + /// + /// The legacy column whose absence means this is not a queue file we can read. Without the + /// payload there is no event and no id to derive from one. + /// + private const string RequiredColumn = "PayloadJson"; + + /// + /// Every legacy column, mapped to its consolidated counterpart. Columns absent from an + /// older file are dropped from the copy rather than failing it — see + /// . + /// + private static readonly (string Legacy, string Current)[] ColumnMap = + [ + ("AlarmId", "alarm_id"), + ("EnqueuedUtc", "enqueued_at_utc"), + ("PayloadJson", "payload_json"), + ("AttemptCount", "attempt_count"), + ("LastAttemptUtc", "last_attempt_utc"), + ("LastError", "last_error"), + ("DeadLettered", "dead_lettered"), + ]; + + /// + /// Copies any legacy alarm queue into , then renames the legacy file. + /// + /// The consolidated database, with alarm_sf_events already registered. + /// Configuration supplying the legacy queue's path. + public static void Migrate(ILocalDb db, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(config); + + var legacyPath = ResolveLegacyPath(config); + if (!ShouldMigrate(legacyPath)) return; + + using (var legacy = OpenLegacyReadOnly(legacyPath)) + { + var present = PresentColumns(legacy); + + // An absent table probes as zero columns, so this one guard covers both "no queue + // table" and "a shape we do not recognise". Returning without renaming leaves the file + // for an operator to inspect. + if (!present.Contains(RequiredColumn)) return; + + using var connection = db.CreateConnection(); + using var transaction = connection.BeginTransaction(); + CopyRows(legacy, connection, transaction, present); + transaction.Commit(); + } + + // Only after the commit. A crash between the two leaves the file in place and the next + // boot copies it again, which the payload-derived ids make harmless. + File.Move(legacyPath, legacyPath + MigratedSuffix, overwrite: true); + } + + /// + /// Resolves the legacy queue path from the removed configuration key, falling back to the + /// code default it used to carry. + /// + /// + /// Relative paths resolve against the process working directory — not the LocalDb + /// directory — because that is where the old code actually put them. + /// + /// The application configuration. + /// An absolute path, or empty when there is nothing durable to migrate from. + internal static string ResolveLegacyPath(IConfiguration config) + { + var path = config[LegacyPathKey] ?? DefaultLegacyPath; + + // In-memory and URI-form sources (test / dev configurations) have nothing durable behind + // them, and Path.GetFullPath on them would produce nonsense. + if (string.IsNullOrWhiteSpace(path) || + path.Equals(":memory:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + return Path.GetFullPath(path); + } + + /// Whether there is a legacy file worth opening. + private static bool ShouldMigrate(string legacyPath) => + !string.IsNullOrEmpty(legacyPath) + && File.Exists(legacyPath) + && !File.Exists(legacyPath + MigratedSuffix); + + /// Opens the legacy file read-only, so a failed migration cannot damage it. + private static SqliteConnection OpenLegacyReadOnly(string path) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadOnly, + }.ToString()); + connection.Open(); + return connection; + } + + /// + /// Which of the columns we know about the legacy table actually has. + /// + /// + /// A file written by an older build predates some columns, and naming a missing one in the + /// SELECT throws "no such column" — which discards every row in the table rather than the + /// one field. Intersecting first means an old file migrates its data and simply leaves the + /// newer columns at their schema defaults. + /// + private static HashSet PresentColumns(SqliteConnection legacy) + { + var present = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var cmd = legacy.CreateCommand(); + cmd.CommandText = $"SELECT name FROM pragma_table_info('{LegacyTable}')"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + present.Add(reader.GetString(0)); + + return present; + } + + /// Copies every legacy row into the consolidated table, keyed by payload hash. + private static void CopyRows( + SqliteConnection legacy, + SqliteConnection target, + SqliteTransaction transaction, + HashSet present) + { + var columns = ColumnMap.Where(c => present.Contains(c.Legacy)).ToArray(); + var payloadOrdinal = Array.FindIndex(columns, c => c.Legacy == RequiredColumn); + + using var read = legacy.CreateCommand(); + read.CommandText = + $"SELECT {string.Join(", ", columns.Select(c => c.Legacy))} FROM {LegacyTable}"; + + using var reader = read.ExecuteReader(); + while (reader.Read()) + { + using var insert = target.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = $""" + INSERT OR IGNORE INTO {AlarmSfSchema.EventsTable} + ({AlarmSfSchema.IdColumn}, {string.Join(", ", columns.Select(c => c.Current))}) + VALUES + ($id, {string.Join(", ", columns.Select((_, i) => $"$c{i}"))}) + """; + + insert.Parameters.AddWithValue("$id", AlarmSfSchema.DeriveId(reader.GetString(payloadOrdinal))); + for (var i = 0; i < columns.Length; i++) + insert.Parameters.AddWithValue($"$c{i}", reader.GetValue(i) ?? DBNull.Value); + + insert.ExecuteNonQuery(); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs index c23fa6bc..aa7522ec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs @@ -88,7 +88,7 @@ public static class LocalDbRegistration ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - services.AddZbLocalDb(configuration, LocalDbSetup.OnReady); + services.AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)); services.AddZbLocalDbReplication(configuration); // The consumer-facing seam. WithOtOpcUaRuntimeActors resolves this optionally and threads it diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs index 2819ea17..ecbd6c8a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; @@ -27,8 +28,8 @@ public static class LocalDbSetup /// RegisterReplicated is what installs the three AFTER triggers that capture /// changes into the oplog. Any row written before that call is never captured, so it /// never reaches the peer — silently, and permanently, because nothing ever revisits - /// history. The Phase-2 legacy alarm migrator therefore runs after every - /// registration, not alongside the DDL. + /// history. therefore runs last, after every + /// registration — it writes rows, and they must be captured like any other write. /// /// /// The alarm buffer's tables are created unconditionally, regardless of whether this @@ -39,9 +40,15 @@ public static class LocalDbSetup /// /// /// The freshly constructed local database. - public static void OnReady(ILocalDb db) + /// + /// Application configuration, read by the legacy migrator for the pre-consolidation queue's + /// path. Required rather than optional: an overload that silently skipped the migration + /// would be one wiring mistake away from discarding a node's undelivered alarm history. + /// + public static void OnReady(ILocalDb db, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(configuration); // CreateConnection() hands back an already-open, pragma-configured connection carrying the // zb_hlc_next() UDF the capture triggers need. Calling Open() on it would throw. @@ -54,5 +61,8 @@ public static class LocalDbSetup db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable); db.RegisterReplicated(DeploymentCacheSchema.PointerTable); db.RegisterReplicated(AlarmSfSchema.EventsTable); + + // LAST, and only here. This is the one call in OnReady that writes rows. + AlarmSfLegacyMigrator.Migrate(db, configuration); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs new file mode 100644 index 00000000..0a581633 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs @@ -0,0 +1,333 @@ +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; + +/// +/// The one-time copy of the pre-consolidation alarm-historian.db queue into the +/// consolidated LocalDb. +/// +/// +/// +/// 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. +/// +/// +/// Run against a real temp-file built through the production +/// , 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. +/// +/// +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() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) + .Build(); + + _provider = new ServiceCollection() + .AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)) + .BuildServiceProvider(); + + return _provider.GetRequiredService(); + } + + private IConfiguration ConfigWithLegacyPath() => Config(_legacyPath); + + private static IConfiguration Config(string legacyPath) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AlarmHistorian:DatabasePath"] = legacyPath, + }) + .Build(); + + private static async Task 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]; + } + + /// A payload shaped like a serialized AlarmHistorianEvent. + 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); + + /// Creates a legacy queue file with the full pre-cutover schema and the given rows. + 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(); + } + } + + /// A legacy file from an older build, predating the LastError column. + 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(); + } + } + + /// A file with a Queue table this build cannot read — no payload column. + 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 */ } + } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs index a221dee1..abfd7e75 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs @@ -265,7 +265,7 @@ public sealed class LocalDbPairHarness : IAsyncDisposable .Build(); return new ServiceCollection() - .AddZbLocalDb(config, LocalDbSetup.OnReady) + .AddZbLocalDb(config, db => LocalDbSetup.OnReady(db, config)) .BuildServiceProvider(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs index 77481b9b..3e45a17d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs @@ -39,7 +39,7 @@ public sealed class LocalDbSetupTests : IDisposable .Build(); _provider = new ServiceCollection() - .AddZbLocalDb(configuration, LocalDbSetup.OnReady) + .AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)) .BuildServiceProvider(); return _provider.GetRequiredService();