From a560e9eaf949639444beae38b6af1115d412d9b6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 09:15:26 -0400 Subject: [PATCH] feat(localdb): wire 2-node replication for the site database, default OFF Task 7 of the LocalDb Phase 1 adoption plan. AddZbLocalDbReplication registers the engine; MapZbLocalDbSync exposes the passive endpoint the peer node dials. Both are unconditional but INERT by default: with no LocalDb:Replication:PeerAddress the initiating SyncBackgroundService starts and immediately idles, and nothing dials the endpoint. A site pair replicates only once an operator sets a peer on BOTH nodes. The endpoint shares the Kestrel h2c listener the site gRPC server already uses, so there are no listener or port changes. Deviation from the plan: it put AddZbLocalDbReplication in Program.cs. Doing so would have left it untested - the composition-root tests build SiteServiceRegistration's graph, not Program.cs - so a registration that silently went missing would still show green. It now sits next to AddZbLocalDb in SiteServiceRegistration, where the DI-graph tests actually cover it. MapZbLocalDbSync stays in Program.cs because it needs the WebApplication. Two pins added to SiteLocalDbWiringTests, which configures no peer: - the engine resolves and is idle (not connected, no peer, never synced) - default-off means inert, not unregistered; - OplogBacklog is 0, not null. It is deliberately nullable so a failed poll reads as "unknown" instead of a healthy zero; a real 0 proves the provider is wired to the oplog, which Task 9's health signal depends on. Inbound auth on the sync endpoint is Task 8. Until that lands the endpoint is unauthenticated - which is precisely why replication ships default-OFF and no config in this repo sets a peer. Verified: build 0 warnings; Host 296/296, SiteEventLogging 70/70, SiteRuntime 530/530, Commons 684/684, Communication 312/312. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts --- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 12 +++++++- .../SiteServiceRegistration.cs | 15 ++++++++-- .../SiteLocalDbWiringTests.cs | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 3ef98a9a..e8ea49ab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Health; using ZB.MOM.WW.Health.Akka; using ZB.MOM.WW.Health.EntityFrameworkCore; +using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.ScadaBridge.AuditLog; using ZB.MOM.WW.ScadaBridge.CentralUI; using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; @@ -516,7 +517,8 @@ try builder.Services.AddGrpc(); builder.Services.AddSingleton(); - // Existing site service registrations + // Existing site service registrations (this is also where LocalDb and its + // replication engine are registered — see SiteServiceRegistration) SiteServiceRegistration.Configure(builder.Services, builder.Configuration); var app = builder.Build(); @@ -535,6 +537,14 @@ try // Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI app.MapGrpcService(); + // The passive half of LocalDb replication: the peer node dials THIS endpoint. + // It shares the Kestrel h2c listener the site gRPC server already uses, so no + // listener or port changes are needed. Mapping it is harmless with no peer + // configured — nothing dials it. Inbound auth is the host's job and lands in + // Task 8; until then this endpoint is unauthenticated, which is why + // replication ships default-OFF. + app.MapZbLocalDbSync(); + // Site-shutdown ordering. ApplicationStopping // fires BEFORE IHostedService.StopAsync runs, so the gRPC server // refuses new streams (Unavailable) and cancels every active stream diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 3c5f691c..3abee357 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.StoreAndForward; using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Telemetry; @@ -56,12 +57,22 @@ public static class SiteServiceRegistration // // Storage is registered UNCONDITIONALLY and needs no flag: with no peer // configured, LocalDb is simply a local SQLite file and the replication - // initiator idles. Replication itself is opt-in via LocalDb:Replication:PeerAddress - // and is wired in Program.cs, which owns the gRPC host the sync endpoint needs. + // initiator idles. // // Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady); + // The replication engine, likewise unconditional but INERT by default: with no + // LocalDb:Replication:PeerAddress the initiating SyncBackgroundService starts and + // immediately idles, and nothing dials the passive endpoint. A site pair + // replicates only once an operator sets a peer on both nodes. + // + // Registered HERE rather than in Program.cs (where the plan first put it) so the + // composition-root tests, which build this graph and not Program.cs, actually + // cover it. The endpoint half — MapZbLocalDbSync — has to stay in Program.cs + // because it needs the WebApplication. + services.AddZbLocalDbReplication(config); + services.AddDataConnectionLayer(); // Local SQLite store by default; a local store replicating against a shared SQL-Server hub // only when Secrets:Replication:Enabled is true AND a hub connection string is present. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs index 28a6dc57..db34fa6b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.ScadaBridge.Host; using ZB.MOM.WW.ScadaBridge.Host.Actors; @@ -120,6 +121,35 @@ public class SiteLocalDbWiringTests : IDisposable Assert.Same(first, second); } + [Fact] + public void Site_Replication_IsRegistered_AndInertWithNoPeer() + { + // Task 7's default-OFF pin. This fixture configures NO + // LocalDb:Replication:PeerAddress, which is the shipping default, so the + // engine must resolve cleanly and sit idle rather than throw, retry, or + // report a connection. "Default-off" here means inert, NOT unregistered — + // the services are always in the graph. + var status = _host.Services.GetService(); + + Assert.NotNull(status); + Assert.False(status!.Connected); + Assert.Null(status.PeerNodeId); + Assert.Null(status.LastSyncUtc); + } + + [Fact] + public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode() + { + // OplogBacklog is deliberately nullable: null means "unknown" (no provider + // wired, or the poll failed) and must never be reported as a healthy 0. On a + // node whose local database is present and readable, a real 0 proves the + // backlog provider is actually wired to the oplog — a null here would mean + // Task 9's health signal is about to publish "unknown" forever. + var status = _host.Services.GetRequiredService(); + + Assert.Equal(0L, status.OplogBacklog); + } + [Fact] public void Site_LocalDb_CreatesTheConfiguredFile() {