LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators #23
@@ -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<ILocalDb></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<ILocalDb>()</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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user