LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators #23

Merged
dohertj2 merged 55 commits from feat/localdb-phase2 into main 2026-07-20 06:06:08 -04:00
3 changed files with 54 additions and 3 deletions
Showing only changes of commit a560e9eaf9 - Show all commits
+11 -1
View File
@@ -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<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// 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<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// 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
@@ -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.
@@ -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<ISyncStatus>();
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<ISyncStatus>();
Assert.Equal(0L, status.OplogBacklog);
}
[Fact]
public void Site_LocalDb_CreatesTheConfiguredFile()
{