LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators #23
@@ -0,0 +1,264 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.LocalDb;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time copy of the pre-Phase-1 site databases (<c>site-tracking.db</c> and
|
||||||
|
/// <c>site_events.db</c>) into the consolidated <c>ZB.MOM.WW.LocalDb</c> database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Runs after <c>RegisterReplicated</c>, deliberately.</b> Capture is trigger-based, so
|
||||||
|
/// rows inserted before registration would never enter the oplog and would never reach the
|
||||||
|
/// peer. Migrating after registration means the migrated history replicates like any other
|
||||||
|
/// write — which is the entire point of Phase 1.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Idempotent twice over.</b> Tracking rows carry the same TEXT primary key they always
|
||||||
|
/// had, so <c>INSERT OR IGNORE</c> is naturally idempotent. Legacy events had autoincrement
|
||||||
|
/// integer ids, which the consolidated schema replaced with GUIDs; they are given
|
||||||
|
/// <i>deterministic</i> ids of the form <c>mig-{NodeName}-{legacyId}</c> rather than fresh
|
||||||
|
/// GUIDs, so a migration that crashes and re-runs cannot duplicate events. The NodeName
|
||||||
|
/// prefix keeps the two nodes of a pair from colliding on their independent legacy id
|
||||||
|
/// sequences.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>All-or-nothing.</b> The copy runs in one transaction and the legacy file is renamed
|
||||||
|
/// to <c><name>.migrated</c> only after commit. A failure throws out of
|
||||||
|
/// <c>OnReady</c>, which fails host startup with the legacy files untouched — no
|
||||||
|
/// half-migrated state to reason about. A second boot sees the renamed file and no-ops.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Finding nothing is the expected case on the docker rig.</b> Neither legacy config key
|
||||||
|
/// is set in any rig appsettings, so both fall back to CWD-relative code defaults
|
||||||
|
/// (<c>/app/site-tracking.db</c>, <c>/app/site_events.db</c>) that sit OUTSIDE the mounted
|
||||||
|
/// data volume — meaning they were already being discarded on every container recreate.
|
||||||
|
/// Phase 1 incidentally fixes that data-loss bug by consolidating into
|
||||||
|
/// <c>/app/data/site-localdb.db</c>. A no-op here is a legitimate result, not a failure.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SiteLocalDbLegacyMigrator
|
||||||
|
{
|
||||||
|
private const string MigratedSuffix = ".migrated";
|
||||||
|
|
||||||
|
/// <summary>Default legacy tracking connection string (<c>OperationTrackingOptions.ConnectionString</c>).</summary>
|
||||||
|
private const string DefaultTrackingConnectionString = "Data Source=site-tracking.db";
|
||||||
|
|
||||||
|
/// <summary>Default legacy event-log path (<c>SiteEventLogOptions.DatabasePath</c>).</summary>
|
||||||
|
private const string DefaultEventLogPath = "site_events.db";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies any legacy site databases into <paramref name="db"/>, then renames them.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="db">The consolidated site database, with both tables already registered.</param>
|
||||||
|
/// <param name="config">Configuration supplying the legacy paths and the node name.</param>
|
||||||
|
public static void Migrate(ILocalDb db, IConfiguration config)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(db);
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
|
var nodeName = config["ScadaBridge:Node:NodeName"] ?? "unknown-node";
|
||||||
|
|
||||||
|
MigrateTracking(db, ResolveTrackingPath(config));
|
||||||
|
MigrateEvents(db, ResolveEventLogPath(config), nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the legacy tracking database path from the OLD connection-string key,
|
||||||
|
/// falling back to the code default. Relative paths resolve against the process working
|
||||||
|
/// directory — NOT the data volume — because that is where the old code actually put them.
|
||||||
|
/// </summary>
|
||||||
|
internal static string ResolveTrackingPath(IConfiguration config)
|
||||||
|
{
|
||||||
|
var connectionString =
|
||||||
|
config["ScadaBridge:OperationTracking:ConnectionString"] ?? DefaultTrackingConnectionString;
|
||||||
|
|
||||||
|
string dataSource;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dataSource = new SqliteConnectionStringBuilder(connectionString).DataSource;
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// An unparseable legacy connection string means there is nothing to migrate
|
||||||
|
// from. Do not fail the host over a stale key we are about to stop reading.
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory legacy databases (test/dev configurations) have nothing durable to
|
||||||
|
// migrate, and Path.GetFullPath on them would produce nonsense.
|
||||||
|
if (string.IsNullOrWhiteSpace(dataSource) ||
|
||||||
|
dataSource.Equals(":memory:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dataSource.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves the legacy event-log path from the OLD key, falling back to the code default.</summary>
|
||||||
|
internal static string ResolveEventLogPath(IConfiguration config)
|
||||||
|
{
|
||||||
|
var path = config["ScadaBridge:SiteEventLog:DatabasePath"] ?? DefaultEventLogPath;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(path) ||
|
||||||
|
path.Equals(":memory:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MigrateTracking(ILocalDb db, string legacyPath)
|
||||||
|
{
|
||||||
|
if (!ShouldMigrate(legacyPath)) return;
|
||||||
|
|
||||||
|
var rows = ReadAll(
|
||||||
|
legacyPath,
|
||||||
|
"""
|
||||||
|
SELECT TrackedOperationId, Kind, TargetSummary, Status,
|
||||||
|
RetryCount, LastError, HttpStatus,
|
||||||
|
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
|
||||||
|
SourceInstanceId, SourceScript, SourceNode
|
||||||
|
FROM OperationTracking;
|
||||||
|
""",
|
||||||
|
expectedColumns: 13);
|
||||||
|
|
||||||
|
// A legacy file with no OperationTracking table at all reads as "nothing to do";
|
||||||
|
// it still gets renamed so the probe does not repeat on every boot.
|
||||||
|
if (rows is not null)
|
||||||
|
{
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.Transaction = transaction;
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR IGNORE INTO OperationTracking (
|
||||||
|
TrackedOperationId, Kind, TargetSummary, Status,
|
||||||
|
RetryCount, LastError, HttpStatus,
|
||||||
|
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
|
||||||
|
SourceInstanceId, SourceScript, SourceNode
|
||||||
|
) VALUES (
|
||||||
|
$p0, $p1, $p2, $p3, $p4, $p5, $p6,
|
||||||
|
$p7, $p8, $p9, $p10, $p11, $p12
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
Bind(cmd, row);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkMigrated(legacyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MigrateEvents(ILocalDb db, string legacyPath, string nodeName)
|
||||||
|
{
|
||||||
|
if (!ShouldMigrate(legacyPath)) return;
|
||||||
|
|
||||||
|
var rows = ReadAll(
|
||||||
|
legacyPath,
|
||||||
|
"""
|
||||||
|
SELECT id, timestamp, event_type, severity, instance_id, source, message, details
|
||||||
|
FROM site_events;
|
||||||
|
""",
|
||||||
|
expectedColumns: 8);
|
||||||
|
|
||||||
|
if (rows is not null)
|
||||||
|
{
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.Transaction = transaction;
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT OR IGNORE INTO site_events (
|
||||||
|
id, timestamp, event_type, severity, instance_id, source, message, details
|
||||||
|
) VALUES (
|
||||||
|
$id, $p1, $p2, $p3, $p4, $p5, $p6, $p7
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Deterministic id, NOT a fresh GUID: a crashed-then-rerun migration must
|
||||||
|
// not duplicate events, and INSERT OR IGNORE can only dedupe on a stable key.
|
||||||
|
cmd.Parameters.AddWithValue("$id", $"mig-{nodeName}-{row[0] ?? "null"}");
|
||||||
|
for (var i = 1; i < row.Length; i++)
|
||||||
|
cmd.Parameters.AddWithValue($"$p{i}", row[i] ?? (object)DBNull.Value);
|
||||||
|
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkMigrated(legacyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when there is a legacy file present that has not already been migrated.</summary>
|
||||||
|
private static bool ShouldMigrate(string legacyPath)
|
||||||
|
=> !string.IsNullOrEmpty(legacyPath)
|
||||||
|
&& File.Exists(legacyPath)
|
||||||
|
&& !File.Exists(legacyPath + MigratedSuffix);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads every row of <paramref name="sql"/> from the legacy file, or null when the
|
||||||
|
/// table does not exist (an old file predating the table is not an error).
|
||||||
|
/// </summary>
|
||||||
|
private static List<object?[]>? ReadAll(string legacyPath, string sql, int expectedColumns)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection($"Data Source={legacyPath};Mode=ReadOnly");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
|
||||||
|
SqliteDataReader reader;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader = cmd.ExecuteReader();
|
||||||
|
}
|
||||||
|
catch (SqliteException)
|
||||||
|
{
|
||||||
|
// "no such table" / "no such column" — a legacy file from before this table
|
||||||
|
// existed, or a shape we do not recognise. Nothing to copy.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<object?[]>();
|
||||||
|
using (reader)
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var row = new object?[expectedColumns];
|
||||||
|
for (var i = 0; i < expectedColumns; i++)
|
||||||
|
row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i);
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Bind(SqliteCommand cmd, object?[] row)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < row.Length; i++)
|
||||||
|
cmd.Parameters.AddWithValue($"$p{i}", row[i] ?? (object)DBNull.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renames the legacy file so a later boot skips it. Done only after the copy has
|
||||||
|
/// committed; the file is kept rather than deleted so an operator can still inspect it.
|
||||||
|
/// </summary>
|
||||||
|
private static void MarkMigrated(string legacyPath)
|
||||||
|
=> File.Move(legacyPath, legacyPath + MigratedSuffix);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using ZB.MOM.WW.LocalDb;
|
using ZB.MOM.WW.LocalDb;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
||||||
@@ -29,12 +30,15 @@ namespace ZB.MOM.WW.ScadaBridge.Host;
|
|||||||
public static class SiteLocalDbSetup
|
public static class SiteLocalDbSetup
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the site node's replicated tables and opts them into change capture.
|
/// Creates the site node's replicated tables, opts them into change capture, and
|
||||||
|
/// migrates any pre-Phase-1 databases in.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="db">The LocalDb instance being initialized.</param>
|
/// <param name="db">The LocalDb instance being initialized.</param>
|
||||||
public static void OnReady(ILocalDb db)
|
/// <param name="config">Configuration, for the legacy migrator's old path keys and node name.</param>
|
||||||
|
public static void OnReady(ILocalDb db, IConfiguration config)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(db);
|
ArgumentNullException.ThrowIfNull(db);
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
using (var connection = db.CreateConnection())
|
using (var connection = db.CreateConnection())
|
||||||
{
|
{
|
||||||
@@ -47,5 +51,9 @@ public static class SiteLocalDbSetup
|
|||||||
// capture). Registration is idempotent and installs the capture triggers.
|
// capture). Registration is idempotent and installs the capture triggers.
|
||||||
db.RegisterReplicated("OperationTracking");
|
db.RegisterReplicated("OperationTracking");
|
||||||
db.RegisterReplicated("site_events");
|
db.RegisterReplicated("site_events");
|
||||||
|
|
||||||
|
// AFTER registration, so migrated rows enter the oplog and reach the peer like
|
||||||
|
// any other write. Before it, they would be invisible to replication forever.
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public static class SiteServiceRegistration
|
|||||||
// initiator idles.
|
// initiator idles.
|
||||||
//
|
//
|
||||||
// Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md
|
// Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md
|
||||||
services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady);
|
services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config));
|
||||||
|
|
||||||
// The replication engine, likewise unconditional but INERT by default: with no
|
// The replication engine, likewise unconditional but INERT by default: with no
|
||||||
// LocalDb:Replication:PeerAddress the initiating SyncBackgroundService starts and
|
// LocalDb:Replication:PeerAddress the initiating SyncBackgroundService starts and
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.LocalDb;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into
|
||||||
|
/// the consolidated one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a data migration that runs during host startup, so its failure modes are
|
||||||
|
/// expensive: a duplicate-on-rerun bug silently doubles a site's event history, and a
|
||||||
|
/// half-committed copy leaves an operator with no clean state to recover to. Every bullet
|
||||||
|
/// of the plan's semantics gets its own test.
|
||||||
|
/// </remarks>
|
||||||
|
public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly List<ServiceProvider> _providers = [];
|
||||||
|
|
||||||
|
public SiteLocalDbLegacyMigratorTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), $"localdb-migrator-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var provider in _providers)
|
||||||
|
{
|
||||||
|
try { provider.Dispose(); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Path_(string name) => System.IO.Path.Combine(_root, name);
|
||||||
|
|
||||||
|
/// <summary>A consolidated database with both tables created and registered, as the host has it.</summary>
|
||||||
|
private ILocalDb CreateConsolidated(IConfiguration config)
|
||||||
|
{
|
||||||
|
var provider = new ServiceCollection()
|
||||||
|
.AddZbLocalDb(config, db =>
|
||||||
|
{
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection);
|
||||||
|
ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogSchema.Apply(connection);
|
||||||
|
db.RegisterReplicated("OperationTracking");
|
||||||
|
db.RegisterReplicated("site_events");
|
||||||
|
})
|
||||||
|
.BuildServiceProvider();
|
||||||
|
_providers.Add(provider);
|
||||||
|
|
||||||
|
return provider.GetRequiredService<ILocalDb>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IConfiguration Config(
|
||||||
|
string? trackingPath = null, string? eventsPath = null, string nodeName = "node-a")
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["LocalDb:Path"] = Path_("consolidated.db"),
|
||||||
|
["ScadaBridge:Node:NodeName"] = nodeName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trackingPath is not null)
|
||||||
|
values["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={trackingPath}";
|
||||||
|
if (eventsPath is not null)
|
||||||
|
values["ScadaBridge:SiteEventLog:DatabasePath"] = eventsPath;
|
||||||
|
|
||||||
|
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedLegacyTracking(string path, params string[] ids)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection($"Data Source={path}");
|
||||||
|
connection.Open();
|
||||||
|
ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection);
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO OperationTracking (
|
||||||
|
TrackedOperationId, Kind, TargetSummary, Status, RetryCount,
|
||||||
|
CreatedAtUtc, UpdatedAtUtc)
|
||||||
|
VALUES ($id, 'ApiCallCached', 'ERP.GetOrder', 'Submitted', 0, $now, $now);
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$id", id);
|
||||||
|
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("o"));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds the LEGACY event schema: an autoincrement integer id, not a GUID.</summary>
|
||||||
|
private static void SeedLegacyEvents(string path, int count)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection($"Data Source={path}");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using (var ddl = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
ddl.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS site_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL,
|
||||||
|
instance_id TEXT, source TEXT NOT NULL, message TEXT NOT NULL, details TEXT
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
ddl.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO site_events (timestamp, event_type, severity, source, message)
|
||||||
|
VALUES ($ts, 'script', 'Info', 'src', $msg);
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.AddMinutes(-i).ToString("o"));
|
||||||
|
cmd.Parameters.AddWithValue("$msg", $"legacy message {i}");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long CountRows(ILocalDb db, string table)
|
||||||
|
{
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
|
||||||
|
return (long)cmd.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SelectIds(ILocalDb db, string table, string idColumn)
|
||||||
|
{
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = $"SELECT {idColumn} FROM {table} ORDER BY {idColumn}";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
|
||||||
|
var ids = new List<string>();
|
||||||
|
while (reader.Read()) ids.Add(reader.GetString(0));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingLegacyFiles_AreANoOp()
|
||||||
|
{
|
||||||
|
// The expected case on the docker rig: neither legacy key is set anywhere, and
|
||||||
|
// the CWD-relative defaults point outside the data volume, so there is usually
|
||||||
|
// nothing there at all. That must be a clean no-op, not a startup failure.
|
||||||
|
var config = Config(Path_("absent-tracking.db"), Path_("absent-events.db"));
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(0, CountRows(db, "OperationTracking"));
|
||||||
|
Assert.Equal(0, CountRows(db, "site_events"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyTrackingRows_AreCopiedAndTheFileIsRenamed()
|
||||||
|
{
|
||||||
|
var trackingPath = Path_("site-tracking.db");
|
||||||
|
SeedLegacyTracking(trackingPath, "op-1", "op-2", "op-3");
|
||||||
|
var config = Config(trackingPath, Path_("absent-events.db"));
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(3, CountRows(db, "OperationTracking"));
|
||||||
|
Assert.Equal(["op-1", "op-2", "op-3"], SelectIds(db, "OperationTracking", "TrackedOperationId"));
|
||||||
|
|
||||||
|
// Renamed, not deleted — the operator can still inspect the original.
|
||||||
|
Assert.False(File.Exists(trackingPath));
|
||||||
|
Assert.True(File.Exists(trackingPath + ".migrated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyEvents_GetDeterministicIds_NotFreshGuids()
|
||||||
|
{
|
||||||
|
// The whole reason a rerun cannot duplicate. Fresh GUIDs would make
|
||||||
|
// INSERT OR IGNORE useless and double the history on every retry.
|
||||||
|
var eventsPath = Path_("site_events.db");
|
||||||
|
SeedLegacyEvents(eventsPath, count: 3);
|
||||||
|
var config = Config(Path_("absent-tracking.db"), eventsPath, nodeName: "node-b");
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["mig-node-b-1", "mig-node-b-2", "mig-node-b-3"],
|
||||||
|
SelectIds(db, "site_events", "id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondBoot_IsANoOp_BecauseTheFileIsAlreadyMarkedMigrated()
|
||||||
|
{
|
||||||
|
var trackingPath = Path_("site-tracking.db");
|
||||||
|
var eventsPath = Path_("site_events.db");
|
||||||
|
SeedLegacyTracking(trackingPath, "op-1", "op-2");
|
||||||
|
SeedLegacyEvents(eventsPath, count: 2);
|
||||||
|
var config = Config(trackingPath, eventsPath);
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(2, CountRows(db, "OperationTracking"));
|
||||||
|
Assert.Equal(2, CountRows(db, "site_events"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RerunAgainstAnUnrenamedFile_DoesNotDuplicate()
|
||||||
|
{
|
||||||
|
// Simulates the crash window: the copy committed but the rename did not. On the
|
||||||
|
// next boot the migrator sees the legacy file again and re-copies it. Both tables
|
||||||
|
// must absorb that with no duplicates — tracking via its natural TEXT key, events
|
||||||
|
// via the deterministic mig- id.
|
||||||
|
var trackingPath = Path_("site-tracking.db");
|
||||||
|
var eventsPath = Path_("site_events.db");
|
||||||
|
SeedLegacyTracking(trackingPath, "op-1", "op-2");
|
||||||
|
SeedLegacyEvents(eventsPath, count: 2);
|
||||||
|
var config = Config(trackingPath, eventsPath);
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
// Undo the rename, exactly as a crash between commit and rename would leave it.
|
||||||
|
File.Move(trackingPath + ".migrated", trackingPath);
|
||||||
|
File.Move(eventsPath + ".migrated", eventsPath);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(2, CountRows(db, "OperationTracking"));
|
||||||
|
Assert.Equal(2, CountRows(db, "site_events"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MigratedRows_EnterTheOplog_SoTheyActuallyReplicate()
|
||||||
|
{
|
||||||
|
// The reason the migrator runs AFTER RegisterReplicated. Capture is trigger-based:
|
||||||
|
// migrate before registration and the rows are invisible to the peer forever, with
|
||||||
|
// no error anywhere. Asserting on the oplog is the only way to catch that ordering
|
||||||
|
// being reversed — every other test here would still pass.
|
||||||
|
var trackingPath = Path_("site-tracking.db");
|
||||||
|
SeedLegacyTracking(trackingPath, "op-1", "op-2");
|
||||||
|
var config = Config(trackingPath, Path_("absent-events.db"));
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
using var connection = db.CreateConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'OperationTracking'";
|
||||||
|
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure()
|
||||||
|
{
|
||||||
|
// A file predating the table (or an unrecognised shape) must not fail host boot.
|
||||||
|
var trackingPath = Path_("site-tracking.db");
|
||||||
|
using (var connection = new SqliteConnection($"Data Source={trackingPath}"))
|
||||||
|
{
|
||||||
|
connection.Open();
|
||||||
|
using var ddl = connection.CreateCommand();
|
||||||
|
ddl.CommandText = "CREATE TABLE something_else (x INTEGER);";
|
||||||
|
ddl.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = Config(trackingPath, Path_("absent-events.db"));
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(0, CountRows(db, "OperationTracking"));
|
||||||
|
Assert.True(File.Exists(trackingPath + ".migrated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InMemoryLegacyConnectionStrings_AreSkipped()
|
||||||
|
{
|
||||||
|
// Test/dev configurations point the legacy stores at in-memory databases. There is
|
||||||
|
// nothing durable to migrate, and Path.GetFullPath on ":memory:" would be nonsense.
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["LocalDb:Path"] = Path_("consolidated.db"),
|
||||||
|
["ScadaBridge:Node:NodeName"] = "node-a",
|
||||||
|
["ScadaBridge:OperationTracking:ConnectionString"] =
|
||||||
|
"Data Source=file:legacy?mode=memory&cache=shared",
|
||||||
|
["ScadaBridge:SiteEventLog:DatabasePath"] = ":memory:",
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveTrackingPath(config));
|
||||||
|
Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveEventLogPath(config));
|
||||||
|
|
||||||
|
var db = CreateConsolidated(config);
|
||||||
|
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||||
|
|
||||||
|
Assert.Equal(0, CountRows(db, "site_events"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsetLegacyKeys_FallBackToTheCwdRelativeCodeDefaults()
|
||||||
|
{
|
||||||
|
// Neither key is set in any docker appsettings, so the OLD code used these
|
||||||
|
// CWD-relative defaults. Resolving them anywhere else (e.g. against the data
|
||||||
|
// volume) would look for the legacy data in a directory it was never written to.
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["LocalDb:Path"] = Path_("consolidated.db"),
|
||||||
|
}).Build();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
System.IO.Path.GetFullPath("site-tracking.db"),
|
||||||
|
SiteLocalDbLegacyMigrator.ResolveTrackingPath(config));
|
||||||
|
Assert.Equal(
|
||||||
|
System.IO.Path.GetFullPath("site_events.db"),
|
||||||
|
SiteLocalDbLegacyMigrator.ResolveEventLogPath(config));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user