diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
index 87c307cc..11b1c8d9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
@@ -67,6 +67,14 @@ public static class LocalDbSetup
/// precisely the node that has no peer to capture for.
///
///
+ /// A failure part-way through either loop fails host start, and that is deliberate.
+ /// Neither branch catches, so a throw on the third of four tables leaves two flipped and
+ /// propagates out of the AddZbLocalDb singleton factory — a node that came up
+ /// anyway would be capturing (or not) on an arbitrary subset, which is silently partial
+ /// replication with nothing in the log to say so. Both library calls are idempotent, so
+ /// the retried boot simply completes the flip rather than compounding it.
+ ///
+ ///
/// The alarm buffer's tables are created unconditionally, regardless of whether this
/// node has AlarmHistorian:Enabled set, and regardless of replication. Creating
/// one lazily when the sink first appears would mean a node that enables the historian
@@ -229,7 +237,8 @@ public static class LocalDbSetup
{
logger.Information(
"LocalDb: removed stale change-capture from {TableCount} table(s) — this node has no "
- + "replication configured ({PeerAddressKey}/{ApiKeyKey} are both unset), so the capture "
+ + "replication configured ({PeerAddressKey}/{ApiKeyKey} are both absent — unset or blank), "
+ + "so the capture "
+ "triggers an earlier build installed have been dropped and their oplog/row-version "
+ "rows pruned.",
cleaned,
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
index d749d476..197439df 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
@@ -6,6 +6,7 @@ using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
+using ZB.MOM.WW.LocalDb.Registration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -70,10 +71,20 @@ public sealed class LocalDbSetupTests : IDisposable
/// Boots over the test's database file.
///
///
- /// Successive calls reopen the same file, which is how the self-heal and late-opt-in
- /// cases are expressed: boot once under one replication configuration, write rows, then boot
- /// again under another. That is exactly the sequence an operator performs by editing config
- /// and restarting the node.
+ ///
+ /// Successive calls reopen the same file, which is how the self-heal and
+ /// late-opt-in cases are expressed: boot once under one replication configuration, write
+ /// rows, then boot again under another. That is exactly the sequence an operator
+ /// performs by editing config and restarting the node.
+ ///
+ ///
+ /// A null lets OnReady fall back to Serilog's static
+ /// Log.Logger, which in this assembly is never assigned and is therefore the
+ /// library's silent default. That is a load-bearing assumption for the two cases that
+ /// DO pass a logger: were anything here to install a static logger, they would still
+ /// pass (they assert on their own sink) but the silent cases would start writing to it.
+ /// Pass a logger whenever the assertion is about logging.
+ ///
///
private ILocalDb Boot(
KeyValuePair[]? replication = null,
@@ -294,6 +305,36 @@ public sealed class LocalDbSetupTests : IDisposable
.ShouldBe(0);
}
+ [Fact]
+ public async Task DisablingReplication_PrunesBookkeepingButNeverTouchesTheCachedDataItself()
+ {
+ // Deregistration prunes the table's oplog and row-version rows — BOOKKEEPING. It must not
+ // touch the base table, because deployment_pointer/deployment_artifacts ARE the
+ // boot-from-cache backstop: a node whose central SQL is unreachable serves its address space
+ // from exactly these rows. A library regression that cleaned the base table along with the
+ // ledger would take that safety net out silently, on the very nodes that never replicate
+ // (central, site-b) and so would never notice a replication fault either.
+ var replicated = Boot(InitiatorReplication);
+ await InsertPointerAsync(replicated, "SITE-A");
+ (await ScalarAsync(replicated, "SELECT COUNT(*) FROM __localdb_row_version")).ShouldBeGreaterThan(0);
+
+ var unreplicated = Boot();
+
+ // The row survives, and with its content intact — a count alone would pass against a
+ // cleanup that blanked the columns it does not own.
+ var pointers = await unreplicated.QueryAsync(
+ "SELECT cluster_id || '|' || deployment_id || '|' || revision_hash FROM deployment_pointer",
+ static r => r.GetString(0),
+ parameters: null,
+ TestContext.Current.CancellationToken);
+
+ pointers.ShouldBe([$"SITE-A|0123456789abcdef0123456789abcdef|{new string('a', 64)}"]);
+
+ // …while the bookkeeping it DOES own is gone.
+ (await ScalarAsync(unreplicated, "SELECT COUNT(*) FROM __localdb_row_version")).ShouldBe(0);
+ (await ScalarAsync(unreplicated, "SELECT COUNT(*) FROM __localdb_oplog")).ShouldBe(0);
+ }
+
[Fact]
public async Task DisablingReplication_LogsOnceWhenItActuallyCleanedSomething()
{
@@ -321,6 +362,77 @@ public sealed class LocalDbSetupTests : IDisposable
sink.Events.ShouldBeEmpty();
}
+ [Fact]
+ public void AFailureMidwayThroughRegistration_FailsBootLoudly_AndTheRetriedBootConverges()
+ {
+ // OnReady flips four tables in a loop with no try/catch, so a failure on the third leaves
+ // two registered and two not. That is the RIGHT behaviour and this pins both halves of why:
+ // the throw propagates out of the AddZbLocalDb singleton factory and fails host start
+ // (a node half-capturing is a node whose replication is silently partial), and because both
+ // library APIs are idempotent the retried boot completes the flip rather than compounding it.
+ //
+ // Swallowing the throw would leave the node up, serving, and replicating two of four tables
+ // with nothing in the log to say so.
+ var real = Boot(InitiatorReplication);
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection([new KeyValuePair("LocalDb:Path", _dbPath), .. InitiatorReplication])
+ .Build();
+
+ var faulty = new FailsOnNthRegistration(real, failOnCall: 3);
+
+ Should.Throw(() => LocalDbSetup.OnReady(faulty, configuration));
+
+ // The wrapper really did reach the third call — otherwise the assertion above would be
+ // pinning some unrelated throw, and the "partial flip" it claims to model never happened.
+ faulty.Calls.ShouldBe(3);
+
+ // The retry: same file, no wrapper. Idempotent re-registration converges on all four.
+ LocalDbSetup.OnReady(real, configuration);
+
+ real.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
+ .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
+ }
+
+ ///
+ /// Passes everything through to a real database except the failOnCall-th
+ /// RegisterReplicated, which throws — the mid-loop partial-flip shape.
+ ///
+ private sealed class FailsOnNthRegistration(ILocalDb inner, int failOnCall) : ILocalDb
+ {
+ /// How many registrations were attempted, successful or not.
+ public int Calls { get; private set; }
+
+ public IReadOnlyDictionary ReplicatedTables => inner.ReplicatedTables;
+
+ public SqliteConnection CreateConnection() => inner.CreateConnection();
+
+ public Task ExecuteAsync(string sql, object? parameters, CancellationToken cancellationToken) =>
+ inner.ExecuteAsync(sql, parameters, cancellationToken);
+
+ public Task> QueryAsync(
+ string sql, Func map, object? parameters, CancellationToken cancellationToken) =>
+ inner.QueryAsync(sql, map, parameters, cancellationToken);
+
+ public Task BeginTransactionAsync(CancellationToken cancellationToken) =>
+ inner.BeginTransactionAsync(cancellationToken);
+
+ public ReplicatedTable RegisterReplicated(string tableName) =>
+ RegisterReplicated(tableName, baselineExistingRows: false);
+
+ public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows)
+ {
+ Calls++;
+
+ if (Calls == failOnCall)
+ throw new InvalidOperationException($"simulated registration failure on '{tableName}'");
+
+ return inner.RegisterReplicated(tableName, baselineExistingRows);
+ }
+
+ public bool DeregisterReplicated(string tableName) => inner.DeregisterReplicated(tableName);
+ }
+
private static Serilog.ILogger BuildLogger(ILogEventSink sink) =>
new Serilog.LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs
index d34c5ccc..4fc391ab 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs
@@ -23,10 +23,14 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache;
/// semantics — live entirely in SQL, so a mocked seam would assert nothing about them.
///
///
-/// This project cannot reference the Host, so the schema setup here mirrors
-/// LocalDbSetup.OnReady rather than calling it. The load-bearing
-/// DDL → RegisterReplicated ordering is pinned by the Host's own
-/// LocalDbSetupTests; what matters here is only that the tables exist.
+/// This project cannot reference the Host, so the setup here reproduces only the two
+/// cache tables — not LocalDbSetup.OnReady's behaviour. In particular it
+/// registers them unconditionally and without baselining, whereas the production callback
+/// registers only when the node is configured to replicate and deregisters when it is not.
+/// That divergence is intentional and inert here: these tests need a database whose tables
+/// exist and capture, and nothing below asserts anything about registration. The
+/// load-bearing DDL-then-RegisterReplicated ordering and the conditional-registration rules
+/// are pinned by the Host's own LocalDbSetupTests.
///
///
public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable