diff --git a/docker-env2/site-x-node-a/appsettings.Site.json b/docker-env2/site-x-node-a/appsettings.Site.json index 5c2ada30..edcd4240 100644 --- a/docker-env2/site-x-node-a/appsettings.Site.json +++ b/docker-env2/site-x-node-a/appsettings.Site.json @@ -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" } } diff --git a/docker-env2/site-x-node-b/appsettings.Site.json b/docker-env2/site-x-node-b/appsettings.Site.json index b53344a1..459dbf04 100644 --- a/docker-env2/site-x-node-b/appsettings.Site.json +++ b/docker-env2/site-x-node-b/appsettings.Site.json @@ -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" } } diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json index 905ff385..7c841b41 100644 --- a/docker/site-a-node-a/appsettings.Site.json +++ b/docker/site-a-node-a/appsettings.Site.json @@ -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" } } diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json index f1db4f2a..299055cc 100644 --- a/docker/site-a-node-b/appsettings.Site.json +++ b/docker/site-a-node-b/appsettings.Site.json @@ -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" } } diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json index 93018c13..cd13388a 100644 --- a/docker/site-b-node-a/appsettings.Site.json +++ b/docker/site-b-node-a/appsettings.Site.json @@ -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" } } diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json index 755b0b0a..9b373702 100644 --- a/docker/site-b-node-b/appsettings.Site.json +++ b/docker/site-b-node-b/appsettings.Site.json @@ -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" } } diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json index 40d2269f..759cc46d 100644 --- a/docker/site-c-node-a/appsettings.Site.json +++ b/docker/site-c-node-a/appsettings.Site.json @@ -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" } } diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json index 93a6b292..0aeba3aa 100644 --- a/docker/site-c-node-b/appsettings.Site.json +++ b/docker/site-c-node-b/appsettings.Site.json @@ -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" } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs new file mode 100644 index 00000000..070dcd1e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs @@ -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; + +/// +/// Initializes the consolidated site database — the single ZB.MOM.WW.LocalDb-managed +/// file that holds the site node's replicated state. +/// +/// +/// +/// Ordering is load-bearing. Table DDL must run BEFORE +/// RegisterReplicated, 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. +/// +/// +/// Runs inside the AddZbLocalDb onReady callback, once, before any caller +/// receives the singleton. onReady is a synchronous +/// Action<ILocalDb>, hence the direct CreateConnection() rather than +/// blocking on the async execute API. A throw here propagates out of the first +/// GetRequiredService<ILocalDb>() and fails host startup, which is the +/// intent: a site node that cannot establish its schema must not come up half-working. +/// +/// +public static class SiteLocalDbSetup +{ + /// + /// Creates the site node's replicated tables and opts them into change capture. + /// + /// The LocalDb instance being initialized. + 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"); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 6d282c33..3c5f691c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -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. diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json index cf8d3fed..ca1c8315 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json @@ -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" } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs index 5b0b7bcd..dbd8de93 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs @@ -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] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs new file mode 100644 index 00000000..28a6dc57 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs @@ -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; + +/// +/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site +/// composition root. +/// +/// These tests build the actual container from +/// rather than asserting on a +/// hand-assembled ServiceCollection. 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 +/// IAuditWriter 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. +/// +/// +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 + { + ["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(); + + 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()); + } + + [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(); + + 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(); + + 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(); + var second = _host.Services.GetRequiredService(); + + 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(); + + Assert.True(File.Exists(_tempDbPath), + $"Expected the consolidated site database at '{_tempDbPath}'."); + } +}