fix(localdb): snapshot review fixes — MAX-guarded complete watermark, keyset pagination, phantom-row guard
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -167,6 +167,7 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
// Prune A's oplog below B's (empty) watermark and flag a resync: incremental deltas alone
|
||||
// can no longer carry rows 1..4 to B.
|
||||
Assert.True(await a.Store.EnforceCapsAsync());
|
||||
var asOfSeq = await a.Store.GetMaxSeqAsync();
|
||||
|
||||
var (da, db) = DuplexPair();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
@@ -175,6 +176,9 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
|
||||
await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 6, RunTimeout);
|
||||
Assert.Equal(await ReadOrders(a.Db), await ReadOrders(b.Db));
|
||||
// The snapshot covered everything through asOfSeq, so the pump starts above it (the surviving
|
||||
// tail oplog rows are not re-sent as deltas).
|
||||
Assert.True(a.Session.SentThruSeq >= asOfSeq, $"SentThruSeq {a.Session.SentThruSeq} < asOfSeq {asOfSeq}");
|
||||
|
||||
// Deltas resume after the snapshot: a post-snapshot write on A reaches B incrementally.
|
||||
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (7, 'POST', 7)");
|
||||
@@ -323,6 +327,59 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SnapshotComplete_WithoutBegin_IsProtocolError()
|
||||
{
|
||||
var a = await NewSideAsync();
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 9 } }, cts.Token);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SnapshotComplete_EmptyOplogAsOfZero_DoesNotRegress()
|
||||
{
|
||||
// A sender with an empty oplog streams as_of_seq 0; a receiver already at watermark 500 must
|
||||
// keep 500 (MAX-guarded complete), or it would re-request everything on the next handshake.
|
||||
var a = await NewSideAsync();
|
||||
await a.Store.SetLastAppliedRemoteSeqAsync(500, 0);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 0 } }, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
SnapshotBatch = new SnapshotBatch { TableName = "orders", Rows = { SnapRow(1, "SNAP", 6_000_000) } },
|
||||
}, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 0 } }, cts.Token);
|
||||
|
||||
// Barrier (seq 0 does not move the watermark): its ack proves Complete{0} was processed.
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
DeltaBatch = new DeltaBatch { Entries = { new OplogEntry
|
||||
{
|
||||
Seq = 0, TableName = "orders", PkJson = "{\"id\":2}",
|
||||
RowJson = "{\"id\":2,\"sku\":\"BARRIER\",\"qty\":1}", Hlc = 6_000_001, NodeId = "peer", IsTombstone = false,
|
||||
} } },
|
||||
}, cts.Token);
|
||||
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token);
|
||||
|
||||
Assert.Equal(500, await LastAppliedRemoteSeq(a.Db));
|
||||
Assert.Equal(2, (await ReadOrders(a.Db)).Count); // the snapshot row still merged
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeltaDuringSnapshot_AppliesSafely()
|
||||
{
|
||||
@@ -359,6 +416,19 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
var rows = await ReadOrders(a.Db);
|
||||
Assert.Equal([1L, 2L, 3L], rows.Select(r => r.Id));
|
||||
|
||||
// Barrier (seq 0 does not move the watermark): its ack proves Complete{200} was processed;
|
||||
// the MAX-guarded complete must not have regressed the delta's 205.
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
DeltaBatch = new DeltaBatch { Entries = { new OplogEntry
|
||||
{
|
||||
Seq = 0, TableName = "orders", PkJson = "{\"id\":4}",
|
||||
RowJson = "{\"id\":4,\"sku\":\"BARRIER\",\"qty\":1}", Hlc = 6_000_030, NodeId = "peer", IsTombstone = false,
|
||||
} } },
|
||||
}, cts.Token);
|
||||
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token);
|
||||
Assert.Equal(205, await LastAppliedRemoteSeq(a.Db));
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user