fix(localdb): registration follows replication config — baseline on enable, deregister self-heal on disable (pair-symmetric)
LocalDbSetup.OnReady registered all four replicated tables unconditionally on
every driver node, replicating or not. That was not merely wasteful (an
unreplicated node paid two INSERTs plus a json_object of the whole row inside
every write transaction, for an oplog nothing ever drained) — it made
baselineExistingRows unusable, so enabling replication on a long-running
default-OFF node converged on SUBSEQUENT WRITES ONLY and the rows already in the
file stayed invisible to the peer, silently and forever.
Registration now follows one predicate, LocalDbSetup.ReplicationIsConfigured:
LocalDb:Replication:PeerAddress OR :ApiKey non-blank. The OR is load-bearing —
only the dialling half of a pair sets PeerAddress, so keying on it alone would
strip capture from every passive node and the pair would converge in one
direction only. SyncListenPort deliberately does not join it: the passive
endpoint is fail-closed on the key, so a port with no ApiKey syncs with nobody.
configured => RegisterReplicated(table, baselineExistingRows: true), per
table, in the unchanged load-bearing DDL->register order
unconfigured => DeregisterReplicated(table), an idempotent boot self-heal that
drops the capture triggers an earlier build left behind and
prunes that table's oplog/row-version rows. One Information
line, only when it actually cleaned something.
PAIR SYMMETRY: the sync handshake compares registered-table digests fail-closed,
so flipping either key is a both-nodes-together change — set it on both and
restart both. Documented in CLAUDE.md, docs/Configuration.md and the replication
runbook (which claimed "this repo never calls DeregisterReplicated").
Verified against the pinned LocalDb 0.2.1: RegisterReplicated(string, bool) and
DeregisterReplicated(string) -> bool both exist; baselining seeds
__localdb_row_version at HLC 0 and the deregister return value distinguishes
"cleaned" from "nothing to clean". No pin bump needed.
Tests: LocalDbSetupTests gains pins for both branches (configured via
PeerAddress alone and via ApiKey alone; unconfigured => no tables, zero
triggers; late-enable => an HLC-0 baseline ledger entry; the log line fires once
and only when something was cleaned) — four went red against the old
unconditional code, and all four go red again under a constant-true predicate.
LocalDbWiringTests splits its exact-set pin into a replicating graph and a
default-OFF one. LocalDbPairHarness and AlarmSfLegacyMigratorTests built their
database from LocalDb:Path alone while configuring the replication engine from a
second config — production reads one configuration, so the two are now aligned.
Host.IntegrationTests: 224 passed, 0 failed, 5 skipped.
This commit is contained in:
+10
-1
@@ -194,8 +194,17 @@ public sealed class AlarmSfLegacyMigratorTests : IDisposable
|
||||
|
||||
private ILocalDb BuildDb()
|
||||
{
|
||||
// The sync ApiKey is what makes OnReady register the tables for capture at all — since
|
||||
// 2026-08-18 registration follows the replication config rather than being unconditional
|
||||
// (see LocalDbSetup.ReplicationIsConfigured). Without it these databases would have no
|
||||
// capture triggers and Migrated_rows_enter_the_oplog would pin nothing. A passive node
|
||||
// carries exactly this and no PeerAddress, so it is also the realistic shape.
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LocalDb:Path"] = _dbPath,
|
||||
["LocalDb:Replication:ApiKey"] = "test-sync-key",
|
||||
})
|
||||
.Build();
|
||||
|
||||
_provider = new ServiceCollection()
|
||||
|
||||
@@ -91,8 +91,8 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
_apiKeyA = apiKeyA ?? DefaultApiKey;
|
||||
_apiKeyB = apiKeyB ?? DefaultApiKey;
|
||||
|
||||
_dbProviderA = BuildDatabaseProvider(_pathA);
|
||||
_dbProviderB = BuildDatabaseProvider(_pathB);
|
||||
_dbProviderA = BuildDatabaseProvider(_pathA, _apiKeyA);
|
||||
_dbProviderB = BuildDatabaseProvider(_pathB, _apiKeyB);
|
||||
}
|
||||
|
||||
/// <summary>Node A — the initiator, which dials the peer.</summary>
|
||||
@@ -148,7 +148,7 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
DeleteDatabaseFiles(_pathB);
|
||||
|
||||
_pathB = Path.Combine(Path.GetTempPath(), $"otopcua-pairB-{Guid.NewGuid():N}.db");
|
||||
_dbProviderB = BuildDatabaseProvider(_pathB);
|
||||
_dbProviderB = BuildDatabaseProvider(_pathB, _apiKeyB);
|
||||
_ = B;
|
||||
}
|
||||
|
||||
@@ -258,10 +258,20 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
/// A provider owning one deployment-cache database, initialised through the host's own
|
||||
/// <see cref="LocalDbSetup.OnReady"/> — same schema, same registration order.
|
||||
/// </summary>
|
||||
private static ServiceProvider BuildDatabaseProvider(string path)
|
||||
private static ServiceProvider BuildDatabaseProvider(string path, string apiKey)
|
||||
{
|
||||
// The ApiKey is not decoration here. Since 2026-08-18 OnReady registers the tables for
|
||||
// change capture only when this node is CONFIGURED to replicate — PeerAddress or ApiKey
|
||||
// (LocalDbSetup.ReplicationIsConfigured) — so a database built from `LocalDb:Path` alone
|
||||
// would come up with no capture triggers and every convergence scenario below would time
|
||||
// out with nothing to send. Production reads one configuration; this harness happens to
|
||||
// build the database and the replication engine from two, and they must agree.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = path })
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LocalDb:Path"] = path,
|
||||
["LocalDb:Replication:ApiKey"] = apiKey,
|
||||
})
|
||||
.Build();
|
||||
|
||||
return new ServiceCollection()
|
||||
|
||||
Reference in New Issue
Block a user