Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbCdcRegistrationTests.cs
T
Joseph Doherty 7ebdcd370a perf(host): install CDC capture only when replication is configured
SiteLocalDbSetup.OnReady registered all ten replicated tables
unconditionally, so a deliberately unreplicated site node (site-b and
site-c on the rig) carried the full 30-trigger CDC set forever. Every
write to those tables paid two extra INSERTs plus a json_object
serialization of the whole row, inside the caller's own transaction, and
appended to an oplog nothing ever drains. Arch-review finding #5 (High),
repo half; the library half — trigger cleanup API and O(1) backlog — is
WP3.3.

The ten RegisterReplicated calls are now behind a guard on whether the
node has LocalDb:Replication:PeerAddress OR LocalDb:Replication:ApiKey.
Either key counts, and the OR is load-bearing rather than defensive:
replication is one bidirectional stream that exactly one side dials, so
only the initiator sets PeerAddress. Verified against the rig — site-a
node-a has PeerAddress + ApiKey, site-a node-b (passive) has ApiKey
alone, site-b/site-c have no Replication section at all. Keying on
PeerAddress alone would have stripped capture from every passive node and
silently made each pair converge in one direction only.

The load-bearing ordering documented in the file is preserved: DDL still
precedes registration, and the legacy migrator still runs unconditionally
after it — an unreplicated node must still absorb its pre-Phase-1 files,
and it has no peer for those rows to be invisible to.

Known residual, documented in-file and in the topology guide: a database
file first created by an older build keeps its stale __localdb_* triggers.
The guard decides whether triggers are installed, not whether existing
ones are removed, and the library has no removal API until WP3.3. Moot on
the docker rig, where a schema-change redeploy recreates the volumes.
The inverse is also now documented: enabling replication on a site that
has run without it does not baseline existing rows, since CDC never
recorded them in __localdb_row_version and the snapshot resync streams
from that ledger.

Tests: new SiteLocalDbCdcRegistrationTests asserts trigger presence and
absence via sqlite_master across all four config shapes (none, ApiKey
only, PeerAddress + ApiKey, and the notification-table exclusion), plus
DDL-still-runs and migrator-still-runs on the unreplicated branch.
SiteLocalDbWiringTests and the integration site-pair harness now
configure an ApiKey — mirroring the rig's passive node — so their
registration and convergence assertions still describe a replicating
node. 483/483 Host.Tests pass; the 20 offline LocalDb convergence tests
still pass.
2026-08-14 19:59:53 -04:00

228 lines
9.2 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP1.3 — CDC capture is installed only on a node that actually replicates.
/// </summary>
/// <remarks>
/// <para>
/// These assert on the TRIGGERS in <c>sqlite_master</c> rather than on
/// <c>ILocalDb.ReplicatedTables</c>, because the trigger set is what costs anything: the
/// registry entry is a dictionary lookup, while each trigger runs two extra INSERTs plus a
/// <c>json_object</c> serialization of the full row inside every write transaction on the
/// table. An unreplicated node paid that on every store-and-forward enqueue, every static
/// override, every event log row, forever, for an oplog with no reader.
/// </para>
/// <para>
/// The naming <c>__localdb_{table}_{ai|au|ad}</c> is the library's
/// (<c>TriggerSqlGenerator.TriggerName</c>), matched by prefix here so a fourth trigger kind
/// would be caught rather than quietly ignored.
/// </para>
/// </remarks>
public class SiteLocalDbCdcRegistrationTests : IDisposable
{
private readonly string _root;
private readonly List<ServiceProvider> _providers = [];
public SiteLocalDbCdcRegistrationTests()
{
_root = Path.Combine(Path.GetTempPath(), $"localdb-cdc-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
foreach (var provider in _providers)
{
try { provider.Dispose(); } catch { /* best effort */ }
}
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
GC.SuppressFinalize(this);
}
[Fact]
public void UnreplicatedNode_InstallsNoCaptureTriggers()
{
// No LocalDb:Replication section at all — site-b and site-c on the rig.
var db = BuildDatabase(Config());
Assert.Empty(CaptureTriggers(db));
}
[Fact]
public void UnreplicatedNode_StillCreatesEveryTable()
{
// Skipping registration must not skip the DDL: the node is fully functional
// standalone, it just has nobody to ship its changes to. A guard placed around the
// schema block instead of the registration block would fail here and nowhere else.
var db = BuildDatabase(Config());
var tables = TableNames(db);
foreach (var table in AllSiteTables)
Assert.Contains(table, tables);
}
[Fact]
public void InitiatorNode_InstallsCaptureTriggers()
{
// PeerAddress + ApiKey — site-a node-a, the half that dials.
var db = BuildDatabase(Config(
peerAddress: "http://peer:8083", apiKey: "cdc-test-key"));
AssertCaptureTriggersCoverTheReplicatedTables(db);
}
[Fact]
public void PassiveNode_WithApiKeyButNoPeerAddress_StillInstallsCaptureTriggers()
{
// The reason the predicate is an OR. Site-a node-b sets ApiKey and nothing else:
// one bidirectional stream, dialled by one side. Keying on PeerAddress alone would
// leave this node capturing nothing, so its own writes would never reach the
// initiator and the pair would converge in one direction only — silently.
var db = BuildDatabase(Config(apiKey: "cdc-test-key"));
AssertCaptureTriggersCoverTheReplicatedTables(db);
}
[Fact]
public void ReplicatedNode_InstallsNoCaptureTriggersOnTheCentralOnlyNotificationTables()
{
// The security property, restated at the trigger level: notification_lists and
// smtp_configurations exist but must never be captured, because the only payload
// they ever historically held was plaintext SMTP passwords.
var db = BuildDatabase(Config(apiKey: "cdc-test-key"));
var triggers = CaptureTriggers(db);
Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_notification_lists_", StringComparison.Ordinal));
Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal));
}
[Fact]
public void UnreplicatedNode_StillRunsTheLegacyMigrator()
{
// The migrator is deliberately outside the guard. It renames the legacy file to
// ".migrated" on success, which is the observable proof it ran without needing a
// capture trigger to look at.
var legacyPath = Path.Combine(_root, "legacy-store-and-forward.db");
SeedLegacyStoreAndForward(legacyPath);
_ = BuildDatabase(Config(storeAndForwardPath: legacyPath));
Assert.False(File.Exists(legacyPath));
Assert.True(File.Exists(legacyPath + ".migrated"));
}
// ---- helpers ----------------------------------------------------------------------
/// <summary>The ten replicated tables plus the two deliberately-unregistered ones.</summary>
private static readonly string[] AllSiteTables =
[
"OperationTracking", "site_events", "sf_messages", "deployed_configurations",
"static_attribute_overrides", "shared_scripts", "external_systems",
"database_connections", "data_connection_definitions", "native_alarm_state",
"notification_lists", "smtp_configurations",
];
private static readonly string[] ReplicatedTables =
[
"OperationTracking", "site_events", "sf_messages", "deployed_configurations",
"static_attribute_overrides", "shared_scripts", "external_systems",
"database_connections", "data_connection_definitions", "native_alarm_state",
];
private static void AssertCaptureTriggersCoverTheReplicatedTables(ILocalDb db)
{
var triggers = CaptureTriggers(db);
foreach (var table in ReplicatedTables)
{
// All three kinds, so a partial install is a failure rather than a pass.
foreach (var suffix in new[] { "ai", "au", "ad" })
Assert.Contains($"__localdb_{table}_{suffix}", triggers);
}
}
private ILocalDb BuildDatabase(IConfiguration config)
{
var provider = new ServiceCollection()
.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config))
.BuildServiceProvider();
_providers.Add(provider);
return provider.GetRequiredService<ILocalDb>();
}
private IConfiguration Config(
string? peerAddress = null, string? apiKey = null, string? storeAndForwardPath = null)
{
var values = new Dictionary<string, string?>
{
["LocalDb:Path"] = Path.Combine(_root, "consolidated.db"),
["ScadaBridge:Node:NodeName"] = "node-a",
// Every legacy path is pinned inside this test's own directory. Left unset they
// resolve CWD-relative (./data/…), and the migrator RENAMES whatever it finds —
// so an unlucky run could eat a real file from the test binary's output folder.
["ScadaBridge:StoreAndForward:SqliteDbPath"] =
storeAndForwardPath ?? Path.Combine(_root, "absent-store-and-forward.db"),
["ScadaBridge:Database:SiteDbPath"] = Path.Combine(_root, "absent-scadabridge.db"),
["ScadaBridge:SiteEventLog:DatabasePath"] = Path.Combine(_root, "absent-events.db"),
["ScadaBridge:OperationTracking:ConnectionString"] =
$"Data Source={Path.Combine(_root, "absent-tracking.db")}",
};
if (peerAddress is not null) values["LocalDb:Replication:PeerAddress"] = peerAddress;
if (apiKey is not null) values["LocalDb:Replication:ApiKey"] = apiKey;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private static void SeedLegacyStoreAndForward(string path)
{
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={path}");
connection.Open();
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
}
/// <summary>
/// Every LocalDb capture trigger in the file. Matched on the library's
/// <c>__localdb_</c> prefix in C# rather than with SQL <c>LIKE</c>, where the underscores
/// are single-character wildcards and the pattern would need escaping to mean itself.
/// </summary>
private static HashSet<string> CaptureTriggers(ILocalDb db)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'trigger'";
using var reader = cmd.ExecuteReader();
var names = new HashSet<string>(StringComparer.Ordinal);
while (reader.Read())
{
var name = reader.GetString(0);
if (name.StartsWith("__localdb_", StringComparison.Ordinal)) names.Add(name);
}
return names;
}
private static HashSet<string> TableNames(ILocalDb db)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
using var reader = cmd.ExecuteReader();
var names = new HashSet<string>(StringComparer.Ordinal);
while (reader.Read()) names.Add(reader.GetString(0));
return names;
}
}