af545efdf5
Copies the pre-consolidation store-and-forward queue into the consolidated
database on first boot, then renames the legacy file aside. Rows are in that
file precisely because the historian could not be reached, so dropping them
on upgrade would discard exactly the alarm audit trail the queue exists to
protect.
Runs last in OnReady, after every RegisterReplicated call. It is the only
thing in OnReady that writes rows, and capture is trigger-based: a migration
that ran before registration would recover the backlog locally and never
replicate a line of it, silently and permanently.
Ids are derived from the payload rather than the plan's mig-{node}-{legacyId}
scheme. Node-prefixing solves the collision the legacy AUTOINCREMENT key
would cause -- node A's row 7 and node B's row 7 are different alarms -- but
it preserves a duplication that should be collapsed instead. A warm pair's
two legacy files OVERLAP: HistorianAdapterActor default-writes while its
redundancy role is unknown, so both nodes accepted the same transitions
during every boot window. Prefixed ids would carry those duplicates into the
merged buffer forever; equal-payload ids converge them. The same property
makes a crash between commit and rename harmless under INSERT OR IGNORE.
OnReady now takes IConfiguration rather than offering an overload that skips
the migration. A wiring mistake that silently discarded a node's undelivered
alarm history is not a mistake worth making possible.
The copy is restricted to the columns the legacy table actually has. Naming
a column an older build never wrote throws "no such column", which would
discard every row in the table rather than the one field.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
173 lines
6.6 KiB
C#
173 lines
6.6 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 2) — pins the deployment-cache schema and the load-bearing
|
|
/// <c>DDL → RegisterReplicated → writes</c> ordering inside <see cref="LocalDbSetup.OnReady"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The ordering assertion is the point of this file. <c>RegisterReplicated</c> installs the
|
|
/// capture triggers; rows written <b>before</b> it are never captured into the oplog and
|
|
/// therefore never reach the peer — silently, and forever. A reordered <c>OnReady</c> passes
|
|
/// every schema-shape test while shipping a replication path that quietly moves nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// Built against a real temp-file <see cref="ILocalDb"/> rather than a hand-written schema:
|
|
/// the library has no in-memory mode, and asserting against a schema the test itself wrote
|
|
/// would only prove the test agrees with itself.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LocalDbSetupTests : IDisposable
|
|
{
|
|
private readonly string _dbPath =
|
|
Path.Combine(Path.GetTempPath(), $"otopcua-localdb-setup-{Guid.NewGuid():N}.db");
|
|
|
|
private ServiceProvider? _provider;
|
|
|
|
private ILocalDb BuildDb()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
|
.Build();
|
|
|
|
_provider = new ServiceCollection()
|
|
.AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration))
|
|
.BuildServiceProvider();
|
|
|
|
return _provider.GetRequiredService<ILocalDb>();
|
|
}
|
|
|
|
[Fact]
|
|
public void OnReady_RegistersExactlyTheThreeReplicatedTables()
|
|
{
|
|
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
|
|
// (that table then never replicates). "No more" catches an accidental registration —
|
|
// every replicated table costs three triggers and oplog volume on every write.
|
|
var db = BuildDb();
|
|
|
|
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
|
.ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeploymentArtifacts_PkIsDeploymentIdPlusChunkIndex()
|
|
{
|
|
// The composite PK is what makes an artifact chunkable. A single-column PK here would
|
|
// make every chunk of a deployment collide onto one row under LWW.
|
|
var db = BuildDb();
|
|
|
|
db.ReplicatedTables["deployment_artifacts"].PkColumns
|
|
.ShouldBe(["deployment_id", "chunk_index"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeploymentPointer_PkIsClusterId()
|
|
{
|
|
// One current-deployment pointer per cluster: the pair's two nodes converge on the same
|
|
// row rather than each keeping its own.
|
|
var db = BuildDb();
|
|
|
|
db.ReplicatedTables["deployment_pointer"].PkColumns.ShouldBe(["cluster_id"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmSfEvents_PkIsTheAppMintedId()
|
|
{
|
|
// A single TEXT PK the application mints. The legacy queue this table replaces keyed on
|
|
// `RowId INTEGER PRIMARY KEY AUTOINCREMENT`, which LWW cannot replicate: two nodes would
|
|
// independently allocate rowid 7 for different alarms and silently overwrite each other.
|
|
var db = BuildDb();
|
|
|
|
db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AlarmSfEventRows_EnterTheOplog()
|
|
{
|
|
// Same ordering assertion as the deployment-pointer test below, for the table Phase 2 adds.
|
|
// Worth its own case because the alarm table is registered by a different call site, and a
|
|
// registration added ahead of its DDL would fail loudly while one added after the migrator
|
|
// would fail silently.
|
|
var db = BuildDb();
|
|
|
|
await db.ExecuteAsync(
|
|
"""
|
|
INSERT INTO alarm_sf_events
|
|
(id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
|
|
VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0)
|
|
""",
|
|
new
|
|
{
|
|
Id = new string('c', 64),
|
|
AlarmId = "equip/alarm-1",
|
|
EnqueuedAtUtc = "2026-07-21T00:00:00.0000000Z",
|
|
PayloadJson = """{"AlarmId":"equip/alarm-1"}""",
|
|
},
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var oplogRows = await db.QueryAsync(
|
|
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'",
|
|
r => r.GetInt32(0),
|
|
parameters: null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
oplogRows[0].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RowsWrittenAfterOnReady_EnterTheOplog()
|
|
{
|
|
// THE ordering assertion. If DDL and RegisterReplicated were swapped — or a write were
|
|
// added to OnReady ahead of registration — the triggers would not exist at write time and
|
|
// this count would stay 0 while every other test in this file still passed.
|
|
var db = BuildDb();
|
|
|
|
await db.ExecuteAsync(
|
|
"""
|
|
INSERT INTO deployment_pointer
|
|
(cluster_id, deployment_id, revision_hash, artifact_sha256, applied_at_utc)
|
|
VALUES (@ClusterId, @DeploymentId, @RevisionHash, @Sha, @AppliedAtUtc)
|
|
""",
|
|
new
|
|
{
|
|
ClusterId = "SITE-A",
|
|
DeploymentId = "0123456789abcdef0123456789abcdef",
|
|
RevisionHash = new string('a', 64),
|
|
Sha = new string('b', 64),
|
|
AppliedAtUtc = "2026-07-20T00:00:00.0000000Z",
|
|
},
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var oplogRows = await db.QueryAsync(
|
|
"SELECT COUNT(*) FROM __localdb_oplog",
|
|
r => r.GetInt32(0),
|
|
parameters: null,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
oplogRows[0].ShouldBeGreaterThanOrEqualTo(1);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_provider?.Dispose();
|
|
|
|
// No in-memory mode, so these are real files. Pooled connections keep a handle open past
|
|
// dispose; clearing the pools first is what makes the delete actually succeed.
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
|
{
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|