98b94771ae
Task 6 of the LocalDb Phase 1 adoption plan. Copies site-tracking.db and
site_events.db into the consolidated database, then renames each source to
<name>.migrated.
Runs AFTER RegisterReplicated, deliberately: capture is trigger-based, so rows
inserted before registration would never enter the oplog and would never reach
the peer - silently. There is a test asserting migrated rows land in
__localdb_oplog, because every other test in the file would still pass with that
ordering reversed.
Idempotent twice over. Tracking rows keep their existing TEXT primary key, so
INSERT OR IGNORE dedupes naturally. Legacy events had autoincrement integer ids,
which the consolidated schema replaced with GUIDs; they get DETERMINISTIC ids of
the form mig-{NodeName}-{legacyId} rather than fresh GUIDs, so a migration that
crashes between commit and rename cannot duplicate history on the next boot. The
NodeName prefix keeps the two nodes of a pair from colliding on their independent
legacy id sequences. Both properties are covered, including an explicit
crash-window test that undoes the rename and re-runs.
All-or-nothing: the copy commits in one transaction and the rename happens only
afterwards, so a failure throws out of OnReady, fails host startup, and leaves
the legacy files untouched. Deviation from the plan: it specified
BeginTransactionAsync, but OnReady is a synchronous Action<ILocalDb>. A raw
transaction on a CreateConnection() connection gives identical atomicity and
identical trigger capture without blocking on async in a startup callback.
Legacy paths come from the OLD config keys and fall back to the CWD-relative code
defaults, because that is where the old code actually wrote them. Note this means
both legacy databases have always lived outside the mounted data volume and were
already discarded on every container recreate - so on the rig this migrator will
usually find nothing, which is a legitimate no-op rather than a failure. Phase 1
incidentally fixes that data-loss bug.
Non-failure cases are covered too: missing files, a legacy file with no such
table, and in-memory legacy connection strings from test/dev configs.
Verified: build 0 warnings; Host 316/316 (9 migrator tests new).
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
328 lines
13 KiB
C#
328 lines
13 KiB
C#
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));
|
|
}
|
|
}
|