Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbStoreAndForwardConvergenceTests.cs
T
Joseph Doherty 2bbe66311d test(localdb): port store-and-forward replication intents as CDC convergence specs
Task 10. Specifications first, deletion second: the bespoke ReplicationService
(explicit Add/Remove/Park/Requeue over Akka) dies at Task 14, so its behaviour
is restated here as outcomes the CDC replacement must still deliver. Written
in terms of ROWS, not operations — under CDC there is no Add or Park message to
observe, only a row that must end up right on both nodes.

Not ported: ReplicationOperations_AreDispatchedInIssueOrder. It asserts the
mechanism (inline fire-and-forget dispatch), and CDC capture is asynchronous
and batched by construction. Its portable content is the ordering OUTCOME —
add-then-remove must never converge to present — which is a test here, with
that reasoning recorded in the file so it does not read as an accidental drop.

DEVIATION: extracted the Phase 1 fixture into LocalDbSitePairHarness rather
than duplicating ~150 lines. Phase 1's tests now derive from it and still pass
unchanged. The harness registers the Phase 2 tables itself, since production
OnReady does not until Task 14; that method is marked for deletion at the
cutover, and the 8-table list is written literally so a cutover registering the
wrong set fails these tests instead of agreeing with itself.

Non-vacuity verified by unregistering sf_messages: 6 of 7 failed. The 7th —
the ordering test — PASSED, because an absent row is also what a pair that
replicates nothing looks like. Fixed with a control row that must converge in
the same window, so the absence is evidence rather than silence.

Also corrected two comments from Task 9 that claimed Task 14 makes
notification_lists/smtp_configurations replicated. It explicitly does not
register them, for the same reason the migrator skips them.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 03:50:48 -04:00

222 lines
10 KiB
C#

using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// Phase 2 convergence: the store-and-forward buffer across a real site pair.
/// </summary>
/// <remarks>
/// <para>
/// These are <b>specifications, written before the deletion they justify</b>. The bespoke
/// <c>ReplicationService</c> (an explicit Add/Remove/Park/Requeue operation stream over Akka)
/// is deleted at Task 14 and replaced by LocalDb's trigger-based change capture. Each test
/// here is one behaviour the old mechanism provided, restated as an outcome the replacement
/// must still deliver — so the cutover has something to be judged against other than "it
/// compiles".
/// </para>
/// <para>
/// They are written in terms of <i>rows</i>, not operations, which is the whole point:
/// under CDC there is no Add or Park message to observe, only a row that must end up in the
/// right state on both nodes.
/// </para>
/// <para>
/// <b>One intent is deliberately not ported:</b>
/// <c>ReplicationServiceTests.ReplicationOperations_AreDispatchedInIssueOrder</c> — 200
/// interleaved operations dispatched synchronously, observed in strict issue order. That
/// asserts the <i>mechanism</i> (inline fire-and-forget dispatch), not an outcome, and CDC
/// capture is asynchronous and batched by construction, so no honest port exists. Its
/// portable content is the ordering <i>outcome</i>: an add followed by a remove must never
/// converge to present. That is
/// <see cref="MessageAddedThenRemoved_NeverConvergesToPresent"/>. This paragraph exists so a
/// future reader does not conclude the test was dropped by accident.
/// </para>
/// </remarks>
[Collection("LocalDbSitePairConvergence")]
public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarness
{
// ---- data helpers -----------------------------------------------------------------
/// <summary>Buffers a message, as <c>StoreAndForwardStorage.EnqueueAsync</c> does.</summary>
private static Task EnqueueAsync(ILocalDb db, string id, string target = "ERP.GetOrder")
=> db.ExecuteAsync(
"""
INSERT INTO sf_messages (
id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, status)
VALUES (@id, 0, @target, '{"order":1}', 0, 50, 30000, @now, @pending)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
retry_count = excluded.retry_count;
""",
new
{
id,
target,
now = DateTime.UtcNow.ToString("o"),
pending = (int)StoreAndForwardMessageStatus.Pending,
});
/// <summary>Deletes a delivered message, as the successful-retry path does.</summary>
private static Task DeleteAsync(ILocalDb db, string id)
=> db.ExecuteAsync("DELETE FROM sf_messages WHERE id = @id;", new { id });
private static Task SetStatusAsync(ILocalDb db, string id, StoreAndForwardMessageStatus status, int retryCount)
=> db.ExecuteAsync(
"UPDATE sf_messages SET status = @status, retry_count = @retryCount WHERE id = @id;",
new { id, status = (int)status, retryCount });
private static async Task<(int Status, int RetryCount)?> ReadAsync(ILocalDb db, string id)
{
var rows = await db.QueryAsync(
"SELECT status, retry_count FROM sf_messages WHERE id = @id",
static r => (r.GetInt32(0), r.GetInt32(1)), new { id });
return rows.Count == 0 ? null : rows[0];
}
private static async Task<bool> ExistsAsync(ILocalDb db, string id)
=> await ReadAsync(db, id) is not null;
private static async Task<bool> HasStatusAsync(
ILocalDb db, string id, StoreAndForwardMessageStatus status)
=> await ReadAsync(db, id) is { } row && row.Status == (int)status;
// ---- scenarios --------------------------------------------------------------------
[Fact]
public async Task BufferedMessage_MaterialisesOnThePeer()
{
// Was: BufferingAMessage_ReplicatesAnAddOperation.
await EnqueueAsync(A, "msg-add");
await WaitUntilAsync(
() => ExistsAsync(B, "msg-add"),
"a message buffered on node A to appear on node B");
}
[Fact]
public async Task DeliveredMessage_DisappearsFromThePeer()
{
// Was: SuccessfulRetry_ReplicatesARemoveOperation. Row deletes propagate as
// tombstones under CDC — the peer must not keep re-delivering a message that node A
// already succeeded on.
await EnqueueAsync(A, "msg-remove");
await WaitUntilAsync(() => ExistsAsync(B, "msg-remove"), "the message to arrive on B first");
await DeleteAsync(A, "msg-remove");
await WaitUntilAsync(
async () => !await ExistsAsync(B, "msg-remove"),
"the delivered message to be gone from node B");
}
[Fact]
public async Task ParkedMessage_ShowsAsParkedOnThePeer()
{
// Was: ParkedMessage_ReplicatesAParkOperation.
await EnqueueAsync(A, "msg-park");
await WaitUntilAsync(() => ExistsAsync(B, "msg-park"), "the message to arrive on B first");
await SetStatusAsync(A, "msg-park", StoreAndForwardMessageStatus.Parked, retryCount: 50);
await WaitUntilAsync(
() => HasStatusAsync(B, "msg-park", StoreAndForwardMessageStatus.Parked),
"the parked status to reach node B");
}
[Fact]
public async Task RequeuedMessage_ResetsStatusAndRetryCountOnThePeer()
{
// Was: RetryingAParkedMessage_ReplicatesARequeueOperation +
// ApplyReplicatedOperation_Requeue_MovesStandbyRowBackToPending. RetryCount matters
// as much as status: a peer that took Pending but kept retry_count at max would park
// the message again on its first attempt after a failover.
await EnqueueAsync(A, "msg-requeue");
await SetStatusAsync(A, "msg-requeue", StoreAndForwardMessageStatus.Parked, retryCount: 50);
await WaitUntilAsync(
() => HasStatusAsync(B, "msg-requeue", StoreAndForwardMessageStatus.Parked),
"the message to be parked on B before it is requeued");
await SetStatusAsync(A, "msg-requeue", StoreAndForwardMessageStatus.Pending, retryCount: 0);
await WaitUntilAsync(
async () => await ReadAsync(B, "msg-requeue")
is { Status: (int)StoreAndForwardMessageStatus.Pending, RetryCount: 0 },
"node B to show the requeued message as Pending with retry_count 0");
}
[Fact]
public async Task MessageAddedThenRemoved_NeverConvergesToPresent()
{
// The portable intent of ReplicationOperations_AreDispatchedInIssueOrder (see the
// class remarks). Ordering only ever mattered because a remove overtaken by its own
// add would resurrect a delivered message and send it twice. Under LWW that
// reordering is not preventable by dispatch discipline — it is prevented because the
// tombstone carries the later HLC and therefore wins regardless of arrival order.
//
// Asserting the endpoint rather than the sequence is what makes this portable: the
// old test would fail on any async transport even when the outcome was correct.
await EnqueueAsync(A, "msg-ordering");
await DeleteAsync(A, "msg-ordering");
// A control row written in the same window. Without it this test is VACUOUS: an
// absent row is also what a pair that replicates nothing at all looks like, so it
// would pass with capture switched off entirely (observed — it was the only one of
// these seven that survived unregistering sf_messages). The control converging is
// what makes the absence of msg-ordering evidence rather than silence.
await EnqueueAsync(A, "msg-ordering-control");
await WaitUntilAsync(
() => ExistsAsync(B, "msg-ordering-control"),
"the control message to reach node B, proving the pair was replicating");
await WaitUntilAsync(
async () => !await ExistsAsync(B, "msg-ordering") && !await ExistsAsync(A, "msg-ordering"),
"the add-then-remove pair to settle as absent on both nodes");
// Absence has to persist, not just occur once: a late-arriving add would flip the row
// back and the poll above could have observed a gap it never actually converged to.
await Task.Delay(TimeSpan.FromSeconds(2));
Assert.False(await ExistsAsync(B, "msg-ordering"));
Assert.False(await ExistsAsync(A, "msg-ordering"));
Assert.True(await ExistsAsync(B, "msg-ordering-control"));
}
[Fact]
public async Task SameMessage_BufferedTwice_ConvergesToTheNewerState()
{
// Was: ApplyReplicatedAdd_Twice_IsIdempotent_NewestWins. Under CDC this is not a
// property the application has to implement — LWW on the primary key gives it — but
// it is still a property the pair must HAVE, so it is asserted rather than assumed.
await EnqueueAsync(A, "msg-twice");
await WaitUntilAsync(() => ExistsAsync(B, "msg-twice"), "the first write to reach B");
await SetStatusAsync(A, "msg-twice", StoreAndForwardMessageStatus.InFlight, retryCount: 3);
await WaitUntilAsync(
async () => await ReadAsync(B, "msg-twice")
is { Status: (int)StoreAndForwardMessageStatus.InFlight, RetryCount: 3 },
"the newer state to win on node B");
}
[Fact]
public async Task ParkArrivingWithoutItsAdd_StillMaterialisesTheRow()
{
// Was: ApplyReplicatedPark_WhenAddWasLost_MaterializesTheParkedRow. The bespoke
// replicator needed explicit upsert semantics so a lost Add did not leave a Park with
// nothing to update. The CDC equivalent is a peer that was offline for the add and
// only ever sees the row in its parked state — it must still end up with the row,
// not skip it as an update to something it never had.
await StopPassiveAsync();
await EnqueueAsync(A, "msg-park-no-add");
await SetStatusAsync(A, "msg-park-no-add", StoreAndForwardMessageStatus.Parked, retryCount: 50);
await RestartPairAsync();
await WaitUntilAsync(
() => HasStatusAsync(B, "msg-park-no-add", StoreAndForwardMessageStatus.Parked),
"node B to materialise a row it only ever saw in its parked state");
}
}