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