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(); } } }