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() {