test(localdb): convergence polish — provable LWW race arbitration, collection serialization, dead-letter diagnostics
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
+43
-8
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.LocalDb.Tests.Convergence;
|
||||
/// concurrent writes, an update/delete race, offline accumulation, restart-mid-stream, and a pruned-horizon
|
||||
/// snapshot resync — each driven through the normal <c>ILocalDb</c> API while both sync sessions run.
|
||||
/// </summary>
|
||||
[Collection("Convergence")]
|
||||
public sealed class BidirectionalConvergenceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -38,26 +39,52 @@ public sealed class BidirectionalConvergenceTests
|
||||
Assert.Equal(51, await ConvergenceFixture.CountOrdersAsync(fx.B));
|
||||
}
|
||||
|
||||
// Update/delete race, serialized so the winner is PROVABLY HLC-arbitrated (same technique as
|
||||
// ConcurrentWrites' shared key): each contender writes only after observing the other's
|
||||
// replicated stamp, so its HLC is strictly higher and the expected LWW winner is deterministic —
|
||||
// not just "both sides agree" (which would also pass if both agreed for a wrong reason).
|
||||
[Fact]
|
||||
public async Task UpdateDeleteRace_Converges()
|
||||
public async Task UpdateDeleteRace_DeleteHasHigherHlc_DeleteWinsBothSides()
|
||||
{
|
||||
await using var fx = new ConvergenceFixture();
|
||||
|
||||
// Seed the contested key on both nodes and let it converge first.
|
||||
await ConvergenceFixture.UpsertAsync(fx.A, 7, "SEED", 1);
|
||||
await fx.StartAsync();
|
||||
await fx.AssertConvergedAsync();
|
||||
|
||||
// Race: A updates the same pk while B deletes it, inside one window. Whichever HLC wins, both
|
||||
// nodes must end identical (either the updated row on both, or absent on both).
|
||||
// A updates; converge (B's clock has observed A's stamp); then B deletes -> provably higher HLC.
|
||||
await ConvergenceFixture.UpsertAsync(fx.A, 7, "A_UPDATED", 99);
|
||||
await fx.AssertConvergedAsync();
|
||||
await ConvergenceFixture.DeleteAsync(fx.B, 7);
|
||||
|
||||
await fx.AssertConvergedAsync();
|
||||
|
||||
var a = await ConvergenceFixture.CountOrdersAsync(fx.A);
|
||||
var b = await ConvergenceFixture.CountOrdersAsync(fx.B);
|
||||
Assert.Equal(a, b); // identical outcome; AssertConverged already proved row-level identity
|
||||
// The DELETE won on BOTH sides: row absent, row_version tombstoned.
|
||||
Assert.Equal(0, await ConvergenceFixture.CountOrdersAsync(fx.A));
|
||||
Assert.Equal(0, await ConvergenceFixture.CountOrdersAsync(fx.B));
|
||||
Assert.True(await IsTombstoned(fx.A, 7), "A row_version for id=7 should be a tombstone");
|
||||
Assert.True(await IsTombstoned(fx.B, 7), "B row_version for id=7 should be a tombstone");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDeleteRace_UpdateHasHigherHlc_UpdateWinsBothSides()
|
||||
{
|
||||
await using var fx = new ConvergenceFixture();
|
||||
await ConvergenceFixture.UpsertAsync(fx.A, 7, "SEED", 1);
|
||||
await fx.StartAsync();
|
||||
await fx.AssertConvergedAsync();
|
||||
|
||||
// Mirrored: B deletes; converge (A's clock has observed the tombstone's stamp); then A
|
||||
// re-writes the pk -> provably higher HLC -> the update resurrects the row on BOTH sides.
|
||||
await ConvergenceFixture.DeleteAsync(fx.B, 7);
|
||||
await fx.AssertConvergedAsync();
|
||||
await ConvergenceFixture.UpsertAsync(fx.A, 7, "A_RESURRECTED", 99);
|
||||
|
||||
await fx.AssertConvergedAsync();
|
||||
|
||||
Assert.Equal((7L, "A_RESURRECTED", (long?)99), await ReadRow(fx.A, 7));
|
||||
Assert.Equal((7L, "A_RESURRECTED", (long?)99), await ReadRow(fx.B, 7));
|
||||
Assert.False(await IsTombstoned(fx.A, 7), "A row_version for id=7 should be live again");
|
||||
Assert.False(await IsTombstoned(fx.B, 7), "B row_version for id=7 should be live again");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -181,6 +208,14 @@ public sealed class BidirectionalConvergenceTests
|
||||
Assert.Equal((7L, "SECOND", (long?)2), await ReadRow(fx.B, 7));
|
||||
}
|
||||
|
||||
private static async Task<bool> IsTombstoned(ILocalDb db, long id)
|
||||
{
|
||||
var rows = await db.QueryAsync(
|
||||
"SELECT is_tombstone FROM __localdb_row_version WHERE table_name = 'orders' AND pk_json = @pk",
|
||||
static r => r.GetInt64(0), new { pk = $"{{\"id\":{id}}}" });
|
||||
return Assert.Single(rows) != 0;
|
||||
}
|
||||
|
||||
private static async Task<(long Id, string? Sku, long? Qty)> ReadRow(ILocalDb db, long id)
|
||||
{
|
||||
var rows = await db.QueryAsync(
|
||||
|
||||
Reference in New Issue
Block a user