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:
Joseph Doherty
2026-07-17 23:50:42 -04:00
parent 130e9c918f
commit 8befebd476
5 changed files with 135 additions and 23 deletions
@@ -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);
}
@@ -553,10 +553,11 @@ public sealed class SyncSessionTests : IDisposable
await a.Store.SetNeedsSnapshotAsync(true, default);
var (duplex, toSession, fromSession) = ScriptedPeer();
var sent = new TaskCompletionSource();
a.Session.SnapshotSender = (send, token) =>
a.Session.SnapshotSender = async (send, token) =>
{
sent.TrySetResult();
return send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 7 } }, token);
await send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 7 } }, token);
return 7;
};
using var cts = new CancellationTokenSource(RunTimeout);