Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
T
Joseph Doherty 846c8d6335 fix(alarms): move scripted-alarm comment trail to its own append-only table (R05:4)
Every Part 9 transition used to re-serialize the alarm's whole comment trail
into alarm_condition_state.comments_json and replicate it to the pair peer:
O(n) per transition, O(n^2) over an alarm's life (measured: one ack at 10k
comments shipped 1,080,553 oplog bytes; now ~1.6 KB, the same as at 10).

- New replicated table alarm_condition_comment, PK (scripted_alarm_id, seq),
  seq = the entry's ordinal in the trail. Ordinal (not content) keying makes
  the two pair nodes' copies of one logical entry (each stamped by its own
  clock, since both nodes apply every command) collapse to one row under LWW.
- LocalDbAlarmConditionStateStore.SaveAsync appends only entries past the
  stored MAX(seq) (INSERT OR IGNORE, append-only) and upserts a fixed-size
  condition row with comments_json = "[]", in one transaction. Remove drops
  the trail with the row. Load merges the table with any inline trail.
- comments_json column kept (always "[]") so the condition table's shape and
  replication digest are unchanged and old-shape rows still load exactly.
- AlarmConditionStateSchema.MigrateInlineComments: idempotent per-row move of
  inline trails, run by LocalDbSetup.OnReady after RegisterReplicated so the
  moved entries replicate. Comment table joins ReplicatedTables (now five).

Tests: constant transition payload at 10 vs 10k comments (+ instrument
positive control), old-shape back-compat load, migration idempotency /
partial re-run / lazy move, OnReady boot migration captured in the oplog,
and two loopback pair tests (A->B and B->A trail convergence; the shared
entry converges to one copy, with a distinct-alarm positive control).

Refs #748
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:36:33 -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_comment", "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);
}
}
}
}