7ebdcd370a
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.
267 lines
11 KiB
C#
267 lines
11 KiB
C#
using System.Net;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Hosting.Server;
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Serializes the site-pair convergence tests against each other: each one stands up a real
|
|
/// Kestrel listener plus two SQLite files, and running them concurrently under CI
|
|
/// contention is a flakiness risk.
|
|
/// </summary>
|
|
[CollectionDefinition("LocalDbSitePairConvergence")]
|
|
public sealed class LocalDbSitePairConvergenceCollection;
|
|
|
|
/// <summary>
|
|
/// Two ScadaBridge site nodes replicating the consolidated site database over a REAL
|
|
/// loopback gRPC transport, through the REAL fail-closed auth interceptor.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Extracted from the Phase 1 convergence tests once Phase 2 needed the same pair for the
|
|
/// store-and-forward buffer and the configuration tables. Everything here is fixture; the
|
|
/// derived classes hold only their own data helpers and scenarios.
|
|
/// </para>
|
|
/// <para>
|
|
/// It uses <see cref="SiteLocalDbSetup.OnReady"/>, not a hand-written schema, so the tables,
|
|
/// their primary keys, and the registration ORDER under test are the ones the host actually
|
|
/// runs. A separate schema here would prove only that the test agrees with itself.
|
|
/// </para>
|
|
/// <para>
|
|
/// Offline: no docker, no external services. Loopback Kestrel with h2c.
|
|
/// </para>
|
|
/// </remarks>
|
|
public abstract class LocalDbSitePairHarness : IAsyncLifetime
|
|
{
|
|
private const string SharedApiKey = "site-pair-convergence-key";
|
|
|
|
/// <summary>How long a scenario waits for the pair to agree before failing.</summary>
|
|
protected static readonly TimeSpan ConvergeTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
private readonly string _pathA = Path.Combine(Path.GetTempPath(), $"sitepairA-{Guid.NewGuid():N}.db");
|
|
private readonly string _pathB = Path.Combine(Path.GetTempPath(), $"sitepairB-{Guid.NewGuid():N}.db");
|
|
|
|
// The databases are owned by the fixture, in their own providers, and registered into the
|
|
// hosts as pre-constructed instances. MS.DI does not dispose instances it did not create,
|
|
// so tearing a host down (the offline-peer scenario) leaves the databases intact and
|
|
// writable — which is exactly what lets node A accumulate writes while B is down.
|
|
private ServiceProvider _dbProviderA = null!;
|
|
private ServiceProvider _dbProviderB = null!;
|
|
|
|
private IHost? _serverHost; // node B — passive
|
|
private IHost? _initiatorHost; // node A — dials the peer
|
|
|
|
static LocalDbSitePairHarness() =>
|
|
// Grpc.Net.Client dials the loopback server over HTTP/2 cleartext.
|
|
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
|
|
|
/// <summary>Node A — the initiator, which dials the peer.</summary>
|
|
protected ILocalDb A => _dbProviderA.GetRequiredService<ILocalDb>();
|
|
|
|
/// <summary>Node B — the passive node, which listens.</summary>
|
|
protected ILocalDb B => _dbProviderB.GetRequiredService<ILocalDb>();
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_dbProviderA = BuildDatabaseProvider(_pathA, "node-a");
|
|
_dbProviderB = BuildDatabaseProvider(_pathB, "node-b");
|
|
|
|
// Force construction (and therefore OnReady) before anything replicates.
|
|
_ = A;
|
|
_ = B;
|
|
|
|
await StartPassiveAsync();
|
|
await StartInitiatorAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await StopHostAsync(_initiatorHost);
|
|
await StopHostAsync(_serverHost);
|
|
await _dbProviderA.DisposeAsync();
|
|
await _dbProviderB.DisposeAsync();
|
|
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
foreach (var path in new[] { _pathA, _pathB })
|
|
{
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
{
|
|
try { File.Delete(path + suffix); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- fixture internals ------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A provider owning one consolidated site database, initialized through the host's own
|
|
/// <see cref="SiteLocalDbSetup.OnReady"/> — same schema, same registration order.
|
|
/// </summary>
|
|
private static ServiceProvider BuildDatabaseProvider(string path, string nodeName)
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["LocalDb:Path"] = path,
|
|
["ScadaBridge:Node:NodeName"] = nodeName,
|
|
// OnReady installs the CDC capture triggers only on a node that has
|
|
// replication configured, so the key has to be here and not only in
|
|
// ReplicationConfig below — without it these nodes would run the sync
|
|
// engine over an oplog nothing ever writes to, and every convergence
|
|
// scenario would time out with two intact but unrelated databases.
|
|
["LocalDb:Replication:ApiKey"] = SharedApiKey,
|
|
// Point the legacy migrators at paths that do not exist, so they no-op rather
|
|
// than picking up stray files from the test working directory. The two Phase 2
|
|
// defaults matter most: unlike the Phase 1 pair they resolve inside ./data/,
|
|
// and a migration would also RENAME whatever it found.
|
|
["ScadaBridge:OperationTracking:ConnectionString"] =
|
|
$"Data Source={Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.db")}",
|
|
["ScadaBridge:SiteEventLog:DatabasePath"] =
|
|
Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.db"),
|
|
["ScadaBridge:StoreAndForward:SqliteDbPath"] =
|
|
Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.db"),
|
|
["ScadaBridge:Database:SiteDbPath"] =
|
|
Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.db"),
|
|
})
|
|
.Build();
|
|
|
|
return new ServiceCollection()
|
|
.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config))
|
|
.BuildServiceProvider();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The eight tables the Phase 2 cutover registers. Listed literally rather than derived
|
|
/// from production code, so a registration that drifts fails these tests instead of
|
|
/// agreeing with itself.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>notification_lists</c> and <c>smtp_configurations</c> are absent by design. They
|
|
/// are permanently empty (no site writer since 2026-07-10, the migrator skips them, the
|
|
/// active-node purge keeps them empty), so registering them would open a standing
|
|
/// replication channel whose only historical payload was plaintext SMTP passwords.
|
|
/// </remarks>
|
|
protected static readonly string[] Phase2ReplicatedTables =
|
|
[
|
|
"sf_messages",
|
|
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
|
|
"external_systems", "database_connections", "data_connection_definitions",
|
|
"native_alarm_state",
|
|
];
|
|
|
|
private static IConfiguration ReplicationConfig(string? peerAddress)
|
|
{
|
|
var values = new Dictionary<string, string?>
|
|
{
|
|
// Tight flush + bounded reconnect backoff so convergence is observable well
|
|
// inside the poll deadline. The 60 s production default would let the doubling
|
|
// backoff overrun it after a peer outage.
|
|
["LocalDb:Replication:FlushInterval"] = "00:00:00.050",
|
|
["LocalDb:Replication:ReconnectBackoffMax"] = "00:00:02",
|
|
// Both nodes share one key — the interceptor is fail-closed, so a mismatch here
|
|
// turns every scenario below red (verified by deliberately breaking it).
|
|
["LocalDb:Replication:ApiKey"] = SharedApiKey,
|
|
};
|
|
|
|
if (peerAddress is not null)
|
|
values["LocalDb:Replication:PeerAddress"] = peerAddress;
|
|
|
|
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
}
|
|
|
|
/// <summary>Starts node B, the passive listener.</summary>
|
|
protected async Task StartPassiveAsync()
|
|
{
|
|
var config = ReplicationConfig(peerAddress: null);
|
|
|
|
_serverHost = await new HostBuilder()
|
|
.ConfigureWebHost(web =>
|
|
{
|
|
web.UseKestrel(o =>
|
|
o.Listen(IPAddress.Loopback, 0, listen => listen.Protocols = HttpProtocols.Http2));
|
|
web.ConfigureServices(services =>
|
|
{
|
|
services.AddLogging();
|
|
services.AddRouting();
|
|
// The REAL interceptor, not a stand-in. If it rejected legitimate peer
|
|
// traffic, every scenario below would fail — which is the point.
|
|
services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());
|
|
services.AddSingleton(B);
|
|
services.AddZbLocalDbReplication(config);
|
|
});
|
|
web.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
app.UseEndpoints(e => e.MapZbLocalDbSync());
|
|
});
|
|
})
|
|
.StartAsync();
|
|
}
|
|
|
|
/// <summary>Starts node A, which dials the passive node.</summary>
|
|
protected async Task StartInitiatorAsync()
|
|
{
|
|
var config = ReplicationConfig(PassiveAddress());
|
|
|
|
_initiatorHost = await new HostBuilder()
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.AddLogging();
|
|
services.AddSingleton(A);
|
|
services.AddZbLocalDbReplication(config);
|
|
})
|
|
.StartAsync();
|
|
}
|
|
|
|
/// <summary>Takes node B's listener down, leaving its database intact and writable.</summary>
|
|
protected async Task StopPassiveAsync()
|
|
{
|
|
await StopHostAsync(_serverHost);
|
|
_serverHost = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Brings node B back on a NEW loopback port and re-dials from A. The initiator's channel
|
|
/// factory re-reads the peer address on each reconnect, so this is a genuine rejoin.
|
|
/// </summary>
|
|
protected async Task RestartPairAsync()
|
|
{
|
|
await StartPassiveAsync();
|
|
await StopHostAsync(_initiatorHost);
|
|
await StartInitiatorAsync();
|
|
}
|
|
|
|
private string PassiveAddress()
|
|
=> _serverHost!.Services.GetRequiredService<IServer>()
|
|
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
|
|
|
private static async Task StopHostAsync(IHost? host)
|
|
{
|
|
if (host is null) return;
|
|
try { await host.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ }
|
|
host.Dispose();
|
|
}
|
|
|
|
/// <summary>Polls <paramref name="condition"/> until true or the deadline passes.</summary>
|
|
protected static async Task WaitUntilAsync(Func<Task<bool>> condition, string because)
|
|
{
|
|
var deadline = DateTime.UtcNow + ConvergeTimeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (await condition()) return;
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
Assert.Fail($"Timed out after {ConvergeTimeout.TotalSeconds:0}s waiting for: {because}");
|
|
}
|
|
}
|