feat(localdb): migrate legacy store-and-forward.db into the consolidated DB
Task 8. Adds ResolveStoreAndForwardPath + MigrateStoreAndForward, wired as a third call in Migrate alongside the two Phase 1 files. Unlike the Phase 1 paths, the store-and-forward default sits INSIDE the data volume, so a real deployment has a real file here holding undelivered messages. This migration genuinely moves data; losing it would discard exactly the buffered calls store-and-forward exists to protect. No id synthesis: sf_messages.id is already a caller-assigned TEXT primary key, so INSERT OR IGNORE is idempotent across a crash-then-rerun. The copy intersects the legacy column set with the current one rather than naming all 16 columns outright. A file from an older build predates execution_id / parent_execution_id / last_attempt_at_ms, and naming a missing column throws 'no such column' — which the existing reader treats as an unrecognised shape and silently discards every row. A required-column (PK) guard keeps that tolerance from degrading into copying NULL-keyed rows. Four tests: copy+rename, crash-before-rename idempotence, the __localdb_oplog assertion that catches migrate-before-register, and the older-column-set case. All four verified to fail without the Migrate call. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -40,7 +40,14 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||
|
||||
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>
|
||||
/// <summary>A consolidated database with the tables created and registered, as the host has it.</summary>
|
||||
/// <remarks>
|
||||
/// <c>sf_messages</c> is registered here even though production <c>OnReady</c> does not
|
||||
/// register it until the Task 14 cutover. The oplog assertion below needs live capture
|
||||
/// triggers to mean anything, and the property under test — that the migrator runs after
|
||||
/// registration — is the same either way. The corollary is a real constraint on Task 14:
|
||||
/// <c>Migrate</c> must stay the LAST call in <c>OnReady</c>, after every registration.
|
||||
/// </remarks>
|
||||
private ILocalDb CreateConsolidated(IConfiguration config)
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
@@ -49,8 +56,10 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||
using var connection = db.CreateConnection();
|
||||
ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection);
|
||||
ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogSchema.Apply(connection);
|
||||
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
|
||||
db.RegisterReplicated("OperationTracking");
|
||||
db.RegisterReplicated("site_events");
|
||||
db.RegisterReplicated("sf_messages");
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
_providers.Add(provider);
|
||||
@@ -59,12 +68,22 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||
}
|
||||
|
||||
private IConfiguration Config(
|
||||
string? trackingPath = null, string? eventsPath = null, string nodeName = "node-a")
|
||||
string? trackingPath = null,
|
||||
string? eventsPath = null,
|
||||
string nodeName = "node-a",
|
||||
string? storeAndForwardPath = null)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["LocalDb:Path"] = Path_("consolidated.db"),
|
||||
["ScadaBridge:Node:NodeName"] = nodeName,
|
||||
|
||||
// Always pinned inside the test's own directory, even when a test does not care
|
||||
// about it. Left unset, the resolver falls back to the CWD-relative code default
|
||||
// "./data/store-and-forward.db" — and the test run's CWD is the test binary's
|
||||
// output directory, so an unlucky run could migrate (and RENAME) a real file.
|
||||
["ScadaBridge:StoreAndForward:SqliteDbPath"] =
|
||||
storeAndForwardPath ?? Path_("absent-store-and-forward.db"),
|
||||
};
|
||||
|
||||
if (trackingPath is not null)
|
||||
@@ -75,6 +94,28 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
/// <summary>Seeds a legacy store-and-forward file with the CURRENT column set.</summary>
|
||||
private static void SeedLegacyStoreAndForward(string path, params string[] ids)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path}");
|
||||
connection.Open();
|
||||
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO sf_messages (
|
||||
id, category, target, payload_json, retry_count, max_retries,
|
||||
retry_interval_ms, created_at, status, execution_id)
|
||||
VALUES ($id, 0, 'ERP', '{"order":1}', 0, 50, 30000, $now, 0, 'exec-1');
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("o"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SeedLegacyTracking(string path, params string[] ids)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path}");
|
||||
@@ -261,6 +302,99 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
|
||||
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SfMessages_AreCopiedFromTheLegacyFile()
|
||||
{
|
||||
// Phase 2 (Task 8). Unlike the two Phase 1 files, the store-and-forward default path
|
||||
// is inside the data volume, so on a real deployment this actually moves data:
|
||||
// undelivered messages that a lost migration would silently discard.
|
||||
var sfPath = Path_("store-and-forward.db");
|
||||
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2", "msg-3");
|
||||
var config = Config(storeAndForwardPath: sfPath);
|
||||
var db = CreateConsolidated(config);
|
||||
|
||||
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||
|
||||
Assert.Equal(3, CountRows(db, "sf_messages"));
|
||||
Assert.Equal(["msg-1", "msg-2", "msg-3"], SelectIds(db, "sf_messages", "id"));
|
||||
|
||||
// Renamed, not deleted — same contract as the Phase 1 files.
|
||||
Assert.False(File.Exists(sfPath));
|
||||
Assert.True(File.Exists(sfPath + ".migrated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SfMessages_Migration_IsIdempotent_WhenRerunAfterACrashBeforeRename()
|
||||
{
|
||||
// The crash window: copy committed, rename did not. sf_messages.id is a natural
|
||||
// TEXT key, so INSERT OR IGNORE absorbs the re-copy with no id synthesis needed.
|
||||
var sfPath = Path_("store-and-forward.db");
|
||||
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2");
|
||||
var config = Config(storeAndForwardPath: sfPath);
|
||||
var db = CreateConsolidated(config);
|
||||
|
||||
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||
File.Move(sfPath + ".migrated", sfPath);
|
||||
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||
|
||||
Assert.Equal(2, CountRows(db, "sf_messages"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigratedSfMessages_EnterTheOplog_SoTheyActuallyReplicate()
|
||||
{
|
||||
// Same ordering trap as the Phase 1 tables: migrate before RegisterReplicated and
|
||||
// the rows never enter the oplog, never reach the peer, and nothing errors. Only an
|
||||
// assertion on __localdb_oplog catches the ordering being reversed.
|
||||
var sfPath = Path_("store-and-forward.db");
|
||||
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2");
|
||||
var config = Config(storeAndForwardPath: sfPath);
|
||||
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 = 'sf_messages'";
|
||||
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SfMessages_FromAnOlderBuild_MigrateDespiteMissingColumns()
|
||||
{
|
||||
// A legacy file written before execution_id / parent_execution_id / last_attempt_at_ms
|
||||
// existed. Naming a missing column in the SELECT throws "no such column", and the
|
||||
// reader treats that as an unrecognised shape — which would silently discard every
|
||||
// buffered message. The copy intersects the column sets instead.
|
||||
var sfPath = Path_("store-and-forward.db");
|
||||
using (var legacy = new SqliteConnection($"Data Source={sfPath}"))
|
||||
{
|
||||
legacy.Open();
|
||||
using var ddl = legacy.CreateCommand();
|
||||
ddl.CommandText = """
|
||||
CREATE TABLE sf_messages (
|
||||
id TEXT PRIMARY KEY, category INTEGER NOT NULL, target TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 50,
|
||||
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
|
||||
created_at TEXT NOT NULL, last_attempt_at TEXT,
|
||||
status INTEGER NOT NULL DEFAULT 0, last_error TEXT, origin_instance TEXT
|
||||
);
|
||||
INSERT INTO sf_messages (id, category, target, payload_json, created_at)
|
||||
VALUES ('old-1', 0, 'ERP', '{}', '2026-01-01T00:00:00Z');
|
||||
""";
|
||||
ddl.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var config = Config(storeAndForwardPath: sfPath);
|
||||
var db = CreateConsolidated(config);
|
||||
|
||||
SiteLocalDbLegacyMigrator.Migrate(db, config);
|
||||
|
||||
Assert.Equal(1, CountRows(db, "sf_messages"));
|
||||
Assert.Equal(["old-1"], SelectIds(db, "sf_messages", "id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user