Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
T
Joseph Doherty 3e62f1617b fix(localdb): registration follows replication config — baseline on enable, deregister self-heal on disable (pair-symmetric)
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.
2026-08-18 07:25:01 -04:00

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&lt;Program&gt;</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);
}
}
}
}