afa5be713a
Two driver nodes replicate the deployment-artifact cache over a real loopback h2c transport through the real fail-closed LocalDbSyncAuthInterceptor; both DBs init via the production LocalDbSetup.OnReady. Scenarios: byte-identical convergence with matching pointer HLC+origin, retention prune reaching B as tombstones, writes-while-transport-down surviving rejoin, and wrong-key non-convergence with a matching-key positive control. DoD positive control: commenting out both RegisterReplicated calls turns all four scenarios red (verified locally, restored before commit).
179 lines
8.4 KiB
C#
179 lines
8.4 KiB
C#
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 12) — two driver nodes replicating the deployment-artifact cache over a
|
|
/// real loopback h2c transport, through the real fail-closed interceptor.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the test the whole phase exists to pass: does a redundant driver pair actually share
|
|
/// the cached configuration a node boots from when central SQL is down? Every upstream piece —
|
|
/// the schema, the DI wiring, the interceptor, the chunked cache — can be individually green
|
|
/// while the pair still fails to converge.
|
|
/// </remarks>
|
|
[Collection("LocalDbPairConvergence")]
|
|
public sealed class LocalDbPairConvergenceTests
|
|
{
|
|
private const string Cluster = "cluster-1";
|
|
|
|
/// <summary>
|
|
/// A deterministic artifact large enough to force multiple 128 KiB chunks (≈ 3 here), so the
|
|
/// tests exercise the chunk-split/reassemble path rather than a single-row shortcut.
|
|
/// </summary>
|
|
private static byte[] MultiChunkArtifact(int seed, int size = 300 * 1024)
|
|
{
|
|
var bytes = new byte[size];
|
|
new Random(seed).NextBytes(bytes);
|
|
return bytes;
|
|
}
|
|
|
|
private static async Task<byte[]?> ReadArtifactAsync(IDeploymentArtifactCache cache, string cluster)
|
|
{
|
|
var cached = await cache.GetCurrentAsync(cluster, TestContext.Current.CancellationToken);
|
|
return cached?.Artifact;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArtifactStoredOnA_ConvergesToB_ByteIdentical()
|
|
{
|
|
await using var harness = new LocalDbPairHarness();
|
|
await harness.StartAsync();
|
|
|
|
var deploymentId = Guid.NewGuid().ToString("N");
|
|
var artifact = MultiChunkArtifact(seed: 1);
|
|
await harness.CacheA.StoreAsync(Cluster, deploymentId, "rev-1", artifact,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
// B's cache reassembles byte-for-byte what A stored — proving the chunk rows, chunk_count,
|
|
// and pointer all replicated intact through the real transport + interceptor.
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () =>
|
|
{
|
|
var onB = await ReadArtifactAsync(harness.CacheB, Cluster);
|
|
return onB is not null && onB.AsSpan().SequenceEqual(artifact);
|
|
},
|
|
"the artifact stored on node A to be byte-identical on node B");
|
|
|
|
// The pointer row on B carries A's origin HLC + node id, not a locally re-derived stamp: the
|
|
// row_version dumps for the pointer table are identical on both nodes. This is what
|
|
// distinguishes "B replicated A's row" from "both nodes happened to write equal content".
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () =>
|
|
await LocalDbPairHarness.DumpRowVersionAsync(harness.A, DeploymentCacheSchema.PointerTable)
|
|
== await LocalDbPairHarness.DumpRowVersionAsync(harness.B, DeploymentCacheSchema.PointerTable),
|
|
"both nodes' pointer row_version (hlc + origin node id) to match");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetentionPruneOnA_TombstonesReachB()
|
|
{
|
|
await using var harness = new LocalDbPairHarness();
|
|
await harness.StartAsync();
|
|
|
|
// Three deployments for one cluster. The cache retains the newest two, so the first is pruned
|
|
// on A — and that prune must replicate to B as tombstones, not linger as live chunks.
|
|
var dep1 = Guid.NewGuid().ToString("N");
|
|
var dep2 = Guid.NewGuid().ToString("N");
|
|
var dep3 = Guid.NewGuid().ToString("N");
|
|
|
|
await harness.CacheA.StoreAsync(Cluster, dep1, "rev-1", MultiChunkArtifact(1),
|
|
TestContext.Current.CancellationToken);
|
|
await harness.CacheA.StoreAsync(Cluster, dep2, "rev-2", MultiChunkArtifact(2),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
// dep1's chunks are still present until the third store prunes them — confirm they reached B
|
|
// first, so the later disappearance is a real replicated prune rather than never-arrived.
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await LocalDbPairHarness.ChunkCountAsync(harness.B, dep1) > 0,
|
|
"dep1's chunks to reach node B before the prune");
|
|
|
|
await harness.CacheA.StoreAsync(Cluster, dep3, "rev-3", MultiChunkArtifact(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
// A pruned dep1 locally; B must follow via replicated deletes: dep1 gone, tombstones present,
|
|
// and the surviving newest (dep3) intact.
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () =>
|
|
await LocalDbPairHarness.ChunkCountAsync(harness.B, dep1) == 0
|
|
&& await LocalDbPairHarness.TombstoneCountAsync(harness.B, DeploymentCacheSchema.ArtifactsTable) > 0
|
|
&& await LocalDbPairHarness.ChunkCountAsync(harness.B, dep3) > 0,
|
|
"node B to prune dep1 (with tombstones) while keeping dep3");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WritesWhileTransportDown_SurviveRejoin()
|
|
{
|
|
await using var harness = new LocalDbPairHarness();
|
|
await harness.StartAsync();
|
|
|
|
var dep1 = Guid.NewGuid().ToString("N");
|
|
var first = MultiChunkArtifact(seed: 10);
|
|
await harness.CacheA.StoreAsync(Cluster, dep1, "rev-1", first,
|
|
TestContext.Current.CancellationToken);
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await ReadArtifactAsync(harness.CacheB, Cluster) is { } b && b.AsSpan().SequenceEqual(first),
|
|
"the first artifact to converge before the outage");
|
|
|
|
// B goes offline; A keeps deploying. B's database survives the host teardown (pre-constructed
|
|
// instance), so the rejoin is genuine rather than a fresh node.
|
|
await harness.StopPassiveAsync();
|
|
|
|
var dep2 = Guid.NewGuid().ToString("N");
|
|
var second = MultiChunkArtifact(seed: 11);
|
|
await harness.CacheA.StoreAsync(Cluster, dep2, "rev-2", second,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
await harness.RestartPairAsync();
|
|
|
|
// Everything written during the outage catches up: B now serves the newer deployment.
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () =>
|
|
{
|
|
var cached = await harness.CacheB.GetCurrentAsync(Cluster, TestContext.Current.CancellationToken);
|
|
return cached is not null
|
|
&& cached.DeploymentId == dep2
|
|
&& cached.Artifact.AsSpan().SequenceEqual(second);
|
|
},
|
|
"node B to catch up on the deployment written while it was offline");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongApiKey_NeverConverges_WithMatchingKeyPositiveControl()
|
|
{
|
|
// Negative half: A and B hold different keys, so B's interceptor fail-closes on A's stream.
|
|
await using (var mismatched = new LocalDbPairHarness(
|
|
apiKeyA: LocalDbPairHarness.DefaultApiKey, apiKeyB: "a-different-key-entirely"))
|
|
{
|
|
await mismatched.StartAsync();
|
|
|
|
var deploymentId = Guid.NewGuid().ToString("N");
|
|
await mismatched.CacheA.StoreAsync(Cluster, deploymentId, "rev-1", MultiChunkArtifact(20),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var converged = await LocalDbPairHarness.HeldWithinAsync(
|
|
async () => await ReadArtifactAsync(mismatched.CacheB, Cluster) is not null,
|
|
TimeSpan.FromSeconds(5));
|
|
|
|
Assert.False(converged,
|
|
"a key mismatch must stop the pair converging — the interceptor is fail-closed");
|
|
}
|
|
|
|
// Positive control: the SAME scenario with matching keys DOES converge. Without this, the
|
|
// negative assertion above would pass even if convergence were broken for an unrelated reason.
|
|
await using var matched = new LocalDbPairHarness();
|
|
await matched.StartAsync();
|
|
|
|
var controlId = Guid.NewGuid().ToString("N");
|
|
var controlArtifact = MultiChunkArtifact(seed: 21);
|
|
await matched.CacheA.StoreAsync(Cluster, controlId, "rev-1", controlArtifact,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
await LocalDbPairHarness.WaitUntilAsync(
|
|
async () => await ReadArtifactAsync(matched.CacheB, Cluster) is { } b && b.AsSpan().SequenceEqual(controlArtifact),
|
|
"the matching-key control pair to converge");
|
|
}
|
|
}
|