3e62f1617b
LocalDbSetup.OnReady registered all four replicated tables unconditionally on
every driver node, replicating or not. That was not merely wasteful (an
unreplicated node paid two INSERTs plus a json_object of the whole row inside
every write transaction, for an oplog nothing ever drained) — it made
baselineExistingRows unusable, so enabling replication on a long-running
default-OFF node converged on SUBSEQUENT WRITES ONLY and the rows already in the
file stayed invisible to the peer, silently and forever.
Registration now follows one predicate, LocalDbSetup.ReplicationIsConfigured:
LocalDb:Replication:PeerAddress OR :ApiKey non-blank. The OR is load-bearing —
only the dialling half of a pair sets PeerAddress, so keying on it alone would
strip capture from every passive node and the pair would converge in one
direction only. SyncListenPort deliberately does not join it: the passive
endpoint is fail-closed on the key, so a port with no ApiKey syncs with nobody.
configured => RegisterReplicated(table, baselineExistingRows: true), per
table, in the unchanged load-bearing DDL->register order
unconfigured => DeregisterReplicated(table), an idempotent boot self-heal that
drops the capture triggers an earlier build left behind and
prunes that table's oplog/row-version rows. One Information
line, only when it actually cleaned something.
PAIR SYMMETRY: the sync handshake compares registered-table digests fail-closed,
so flipping either key is a both-nodes-together change — set it on both and
restart both. Documented in CLAUDE.md, docs/Configuration.md and the replication
runbook (which claimed "this repo never calls DeregisterReplicated").
Verified against the pinned LocalDb 0.2.1: RegisterReplicated(string, bool) and
DeregisterReplicated(string) -> bool both exist; baselining seeds
__localdb_row_version at HLC 0 and the deregister return value distinguishes
"cleaned" from "nothing to clean". No pin bump needed.
Tests: LocalDbSetupTests gains pins for both branches (configured via
PeerAddress alone and via ApiKey alone; unconfigured => no tables, zero
triggers; late-enable => an HLC-0 baseline ledger entry; the log line fires once
and only when something was cleaned) — four went red against the old
unconditional code, and all four go red again under a constant-true predicate.
LocalDbWiringTests splits its exact-set pin into a replicating graph and a
default-OFF one. LocalDbPairHarness and AlarmSfLegacyMigratorTests built their
database from LocalDb:Path alone while configuring the replication engine from a
second config — production reads one configuration, so the two are now aligned.
Host.IntegrationTests: 224 passed, 0 failed, 5 skipped.
216 lines
8.7 KiB
C#
216 lines
8.7 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 11) — DI pins over the real composition methods.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// DI extension methods have shipped inert three times in this family (Secrets 0.2.0 / 0.2.2,
|
|
/// ScadaBridge #22): a registration that compiles, deploys, and does nothing because the seam
|
|
/// was never resolved from a built container. These tests build a real container from the
|
|
/// same <c>AddOtOpcUa*</c> methods <c>Program.cs</c> calls and resolve through it.
|
|
/// </para>
|
|
/// <para>
|
|
/// They deliberately do NOT boot the full host. <c>WebApplicationFactory<Program></c>
|
|
/// would start hosted services — Akka, the OPC UA server, <c>ValidateOnStart</c> — which need
|
|
/// SQL Server and an Akka mesh (see <c>TwoNodeClusterHarness</c> on why the real host is not
|
|
/// driven here). What is under test is DI resolution, so the container is built and resolved
|
|
/// without ever starting it.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LocalDbWiringTests : IDisposable
|
|
{
|
|
private readonly List<string> _dbPaths = [];
|
|
private readonly List<WebApplication> _apps = [];
|
|
|
|
/// <summary>
|
|
/// Builds a container the way <c>Program.cs</c>'s driver branch does for LocalDb: the real
|
|
/// <see cref="LocalDbRegistration.AddOtOpcUaLocalDb"/>, health, and observability methods,
|
|
/// over a temp-file <c>LocalDb:Path</c>.
|
|
/// </summary>
|
|
/// <param name="replicating">
|
|
/// Whether this node carries a replication configuration. Since 2026-08-18 that is what
|
|
/// decides whether <c>LocalDbSetup.OnReady</c> registers the tables for change capture at
|
|
/// all, so the two graphs are genuinely different nodes and both are pinned below. Most of
|
|
/// the fleet is the default-OFF one.
|
|
/// </param>
|
|
private IServiceProvider BuildDriverGraph(bool replicating = false)
|
|
{
|
|
var dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-wiring-{Guid.NewGuid():N}.db");
|
|
_dbPaths.Add(dbPath);
|
|
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["LocalDb:Path"] = dbPath,
|
|
};
|
|
|
|
if (replicating)
|
|
settings["LocalDb:Replication:ApiKey"] = "wiring-test-sync-key";
|
|
|
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
|
builder.Configuration.AddInMemoryCollection(settings);
|
|
|
|
// The exact LocalDb composition Program.cs runs under hasDriver.
|
|
builder.Services.AddOtOpcUaLocalDb(builder.Configuration);
|
|
builder.Services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());
|
|
builder.Services.AddOtOpcUaHealth();
|
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
|
|
|
var app = builder.Build();
|
|
_apps.Add(app);
|
|
return app.Services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an admin-only container: the shared registrations that run on every node, but NOT
|
|
/// <see cref="LocalDbRegistration.AddOtOpcUaLocalDb"/> — exactly as Program.cs gates it under
|
|
/// hasDriver.
|
|
/// </summary>
|
|
private IServiceProvider BuildAdminOnlyGraph()
|
|
{
|
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
|
|
|
// No LocalDb:Path configured — an admin-only node has no reason to set it.
|
|
builder.Services.AddOtOpcUaHealth();
|
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
|
|
|
var app = builder.Build();
|
|
_apps.Add(app);
|
|
return app.Services;
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicatingDriverGraph_LocalDbResolvesAsASingletonWithEveryReplicatedTableRegistered()
|
|
{
|
|
var sp = BuildDriverGraph(replicating: true);
|
|
|
|
var first = sp.GetService<ILocalDb>();
|
|
first.ShouldNotBeNull();
|
|
sp.GetService<ILocalDb>().ShouldBeSameAs(first); // singleton
|
|
|
|
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs
|
|
// three triggers and oplog volume on every write.
|
|
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
|
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOffDriverGraph_LocalDbStillResolvesButRegistersNoTables()
|
|
{
|
|
// BEHAVIOUR CHANGE (2026-08-18): registration follows the replication config. This graph is
|
|
// most of the fleet — central and site-b are default-OFF — and it must still get a working
|
|
// local database (the artifact cache and the alarm buffer both depend on it) while paying
|
|
// for none of the change-capture machinery it has no peer to send to.
|
|
var sp = BuildDriverGraph();
|
|
|
|
var db = sp.GetService<ILocalDb>();
|
|
db.ShouldNotBeNull();
|
|
sp.GetService<IDeploymentArtifactCache>().ShouldNotBeNull();
|
|
|
|
db.ReplicatedTables.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverGraph_ArtifactCacheResolvesToTheLocalDbBackedImplementation()
|
|
{
|
|
var sp = BuildDriverGraph();
|
|
|
|
sp.GetService<IDeploymentArtifactCache>()
|
|
.ShouldBeOfType<LocalDbDeploymentArtifactCache>();
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverGraph_AlarmStateStoreResolvesToTheLocalDbBackedImplementation()
|
|
{
|
|
// Phase 4: scripted-alarm condition state is served from the replicated LocalDb store on a
|
|
// driver node. A dropped registration would silently fall the actor back to the EF store (or,
|
|
// DB-less, skip the alarm host) — this pins the LocalDb store as the resolved implementation.
|
|
var sp = BuildDriverGraph();
|
|
|
|
sp.GetService<IAlarmStateStore>()
|
|
.ShouldBeOfType<LocalDbAlarmConditionStateStore>();
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer()
|
|
{
|
|
// The default-OFF posture pin: registering the replication engine must not connect it.
|
|
var sp = BuildDriverGraph();
|
|
|
|
var status = sp.GetService<ISyncStatus>();
|
|
status.ShouldNotBeNull();
|
|
status.Connected.ShouldBeFalse();
|
|
status.PeerNodeId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverGraph_LocalDbReplicationHealthCheckIsRegistered()
|
|
{
|
|
var sp = BuildDriverGraph();
|
|
|
|
var registrations = sp.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
|
|
.Value.Registrations
|
|
.Select(r => r.Name)
|
|
.ToArray();
|
|
|
|
registrations.ShouldContain("localdb-replication");
|
|
}
|
|
|
|
[Fact]
|
|
public void AdminOnlyGraph_HasNoLocalDb_AndDoesNotDemandLocalDbPath()
|
|
{
|
|
// Boot must not require LocalDb:Path on a node that never registers LocalDb. Building the
|
|
// container without a configured path and resolving the shared services proves nothing in
|
|
// the always-on registrations reaches for ILocalDb or its options.
|
|
var sp = BuildAdminOnlyGraph();
|
|
|
|
sp.GetService<ILocalDb>().ShouldBeNull();
|
|
sp.GetService<IDeploymentArtifactCache>().ShouldBeNull();
|
|
// Phase 4: an admin-only node never registers the LocalDb alarm store either.
|
|
sp.GetService<IAlarmStateStore>().ShouldBeNull();
|
|
|
|
// The unconditionally-registered health check must still resolve and construct — it is meant
|
|
// to no-op to Healthy when LocalDb is absent, not to fail because ISyncStatus is missing.
|
|
var registrations = sp.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
|
|
.Value.Registrations
|
|
.Where(r => r.Name == "localdb-replication")
|
|
.ToArray();
|
|
var localDbCheck = registrations.ShouldHaveSingleItem();
|
|
Should.NotThrow(() => localDbCheck.Factory(sp));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var app in _apps)
|
|
((IDisposable)app).Dispose();
|
|
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
foreach (var basePath in _dbPaths)
|
|
{
|
|
foreach (var path in new[] { basePath, $"{basePath}-wal", $"{basePath}-shm" })
|
|
{
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
}
|