2bbe66311d
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
222 lines
10 KiB
C#
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");
|
|
}
|
|
}
|