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