feat(localdb): register the consolidated site database in the composition root

Task 3. Site nodes now open one ZB.MOM.WW.LocalDb-managed database holding
OperationTracking and site_events as replicated tables.

SiteLocalDbSetup.OnReady applies both schemas and then RegisterReplicated's them.
That order is load-bearing and easy to get silently wrong: capture is
trigger-based CDC, so rows written before registration are never captured or
snapshotted - they would be invisible to the peer forever, with no error anywhere.

Storage ships UNCONDITIONALLY, no feature flag. With no peer configured LocalDb is
just a local SQLite file, so this is behaviour-equivalent to what site nodes do
today. Replication is opt-in and lands in Program.cs (Task 7), which owns the gRPC
host the sync endpoint needs.

Config: LocalDb:Path added to all EIGHT site-node appsettings (the plan said six -
docker-env2's site-x pair exists too), plus the dev template and the wonder-app-vd03
production sample. All ten are required, not optional: LocalDbOptions.Path is
validated with ValidateOnStart, so a site node without it fails fast at startup.
That is the desired posture, and it is what caught ActorPathTests - the one Host
fixture that actually starts a host - whose config now sets the path too.

Note the path choice fixes an existing data-loss bug in passing: site-tracking.db
and site_events.db both defaulted to CWD-relative paths (/app/...), outside the
mounted volume, so they were silently lost on every container recreate. The
consolidated database lives on /app/data.

Tests are container-built against the REAL SiteServiceRegistration.Configure, not a
hand-assembled ServiceCollection - this family has shipped three wiring defects that
only a real graph would catch (inert Secrets replicator 0.2.0, singleton deadlock
0.2.2, Secrets.Ui missing IAuditWriter). They assert the library's own view of what
is registered rather than that we called the right method.

Verified (all after the change):
  dotnet build ZB.MOM.WW.ScadaBridge.slnx -> 0 Error(s), 0 Warning(s)
  Host.Tests            -> 294 passed (5 new, red-first)
  SiteRuntime.Tests     -> 530 passed
  CentralUI.Tests       -> 925 passed
  Commons.Tests         -> 684 passed
  Communication.Tests   -> 312 passed
  SiteEventLogging.Tests-> 70 passed

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-19 03:36:54 -04:00
parent 2742d54c9f
commit 2977e6c45c
13 changed files with 274 additions and 0 deletions
@@ -56,5 +56,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -56,5 +56,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -57,5 +57,13 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// On the mounted /app/data volume so it survives container recreate - unlike the
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
// outside the volume and were lost on every recreate.
// Replication is opt-in and configured separately; absent = local-only.
"LocalDb": {
"Path": "/app/data/site-localdb.db"
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Initializes the consolidated site database — the single <c>ZB.MOM.WW.LocalDb</c>-managed
/// file that holds the site node's replicated state.
/// </summary>
/// <remarks>
/// <para>
/// <b>Ordering is load-bearing.</b> Table DDL must run BEFORE
/// <c>RegisterReplicated</c>, and every row that should replicate must be written
/// AFTER it. Capture is trigger-based change-data-capture: the library neither
/// captures nor snapshots rows that already existed when the triggers were installed,
/// so a row written before registration is invisible to the peer forever — silently,
/// with no error anywhere.
/// </para>
/// <para>
/// Runs inside the <c>AddZbLocalDb</c> onReady callback, once, before any caller
/// receives the <see cref="ILocalDb"/> singleton. onReady is a synchronous
/// <c>Action&lt;ILocalDb&gt;</c>, hence the direct <c>CreateConnection()</c> rather than
/// blocking on the async execute API. A throw here propagates out of the first
/// <c>GetRequiredService&lt;ILocalDb&gt;()</c> and fails host startup, which is the
/// intent: a site node that cannot establish its schema must not come up half-working.
/// </para>
/// </remarks>
public static class SiteLocalDbSetup
{
/// <summary>
/// Creates the site node's replicated tables and opts them into change capture.
/// </summary>
/// <param name="db">The LocalDb instance being initialized.</param>
public static void OnReady(ILocalDb db)
{
ArgumentNullException.ThrowIfNull(db);
using (var connection = db.CreateConnection())
{
OperationTrackingSchema.Apply(connection);
SiteEventLogSchema.Apply(connection);
}
// Both tables qualify: each has an explicit primary key (RegisterReplicated
// rejects tables without one) and no BLOB columns (which json_object cannot
// capture). Registration is idempotent and installs the capture triggers.
db.RegisterReplicated("OperationTracking");
db.RegisterReplicated("site_events");
}
}
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService;
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.Secrets.DependencyInjection;
using ZB.MOM.WW.Telemetry;
@@ -49,6 +50,18 @@ public static class SiteServiceRegistration
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
// Consolidated site database (LocalDb Phase 1). Holds OperationTracking and
// site_events as replicated tables so the pair stops losing them on failover.
//
// 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.
//
// Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md
services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady);
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.
@@ -58,5 +58,11 @@
"Logging": {
"MinimumLevel": "Information"
}
},
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
// LocalDb:Path is REQUIRED and validated on start - a site node with no value here
// fails to boot, so every site config must set it.
"LocalDb": {
"Path": "./data/site-localdb.db"
}
}
@@ -163,10 +163,12 @@ public class SiteActorPathTests : IAsyncLifetime
private IHost? _host;
private ActorSystem? _actorSystem;
private string _tempDbPath = null!;
private string _tempLocalDbPath = null!;
public async Task InitializeAsync()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_localdb_{Guid.NewGuid()}.db");
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
@@ -183,6 +185,10 @@ public class SiteActorPathTests : IAsyncLifetime
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Required: LocalDbOptions.Path is validated with ValidateOnStart, and this
// fixture actually starts the host — a site node with no configured
// consolidated-database path fails fast rather than booting half-working.
["LocalDb:Path"] = _tempLocalDbPath,
// Configure a dummy central contact point to trigger ClusterClient creation
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
});
@@ -207,6 +213,7 @@ public class SiteActorPathTests : IAsyncLifetime
_host.Dispose();
}
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
[Fact]
@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
/// composition root.
/// <para>
/// These tests build the actual container from
/// <see cref="SiteServiceRegistration.Configure"/> rather than asserting on a
/// hand-assembled <c>ServiceCollection</c>. That is deliberate and load-bearing: this
/// family has now shipped three wiring defects that only a container-built graph would
/// have caught — the Secrets Akka replicator that registered into a no-op sink (0.2.0),
/// the singleton cycle that deadlocked a real host (0.2.2), and Secrets.Ui's missing
/// <c>IAuditWriter</c> that 500'd only behind a login wall (ScadaBridge#22). A test that
/// merely checks "AddZbLocalDb was called" would have passed in every one of those cases.
/// </para>
/// </summary>
public class SiteLocalDbWiringTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
private readonly string _tempSiteDbPath;
private readonly string _tempTrackingDbPath;
public SiteLocalDbWiringTests()
{
var stamp = Guid.NewGuid();
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{stamp}.db");
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_site_{stamp}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{stamp}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
// The consolidated site database. No Replication section at all — this
// fixture is also the default-OFF pin: storage must work standalone.
["LocalDb:Path"] = _tempDbPath,
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
(_host as IDisposable)?.Dispose();
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
{
try { File.Delete(path); } catch { /* best effort */ }
}
GC.SuppressFinalize(this);
}
[Fact]
public void Site_Resolves_ILocalDb()
{
Assert.NotNull(_host.Services.GetService<ILocalDb>());
}
[Fact]
public void Site_LocalDb_RegistersBothReplicatedTables()
{
// The whole point of Phase 1. If onReady silently failed to register these,
// storage would still work and every other test would pass — the pair would
// just never replicate anything. Assert on the library's own view of what is
// registered, not on our belief that we called it.
var db = _host.Services.GetRequiredService<ILocalDb>();
Assert.Contains("OperationTracking", db.ReplicatedTables.Keys);
Assert.Contains("site_events", db.ReplicatedTables.Keys);
}
[Fact]
public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
{
// RegisterReplicated refuses a table with no explicit PK, so reaching this
// assertion at all proves the DDL ran before registration. The PK names are
// pinned because they are what last-writer-wins conflict resolution keys on.
var db = _host.Services.GetRequiredService<ILocalDb>();
Assert.Equal(
["TrackedOperationId"], db.ReplicatedTables["OperationTracking"].PkColumns);
Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
}
[Fact]
public void Site_LocalDb_IsASingleton()
{
// AddZbLocalDb is one-DB-per-process and first-registration-wins. Two
// instances would mean two SQLite handles and two trigger installations
// against the same file.
var first = _host.Services.GetRequiredService<ILocalDb>();
var second = _host.Services.GetRequiredService<ILocalDb>();
Assert.Same(first, second);
}
[Fact]
public void Site_LocalDb_CreatesTheConfiguredFile()
{
// Resolving is what triggers onReady; the file should exist afterwards at the
// configured path and not somewhere CWD-relative.
_ = _host.Services.GetRequiredService<ILocalDb>();
Assert.True(File.Exists(_tempDbPath),
$"Expected the consolidated site database at '{_tempDbPath}'.");
}
}