feat(localdb): snapshot resync (LWW-merging, tombstone-carrying, delta resume)

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-17 23:35:50 -04:00
parent e92cdc6d3a
commit 130e9c918f
5 changed files with 628 additions and 27 deletions
@@ -546,10 +546,11 @@ public sealed class SyncSessionTests : IDisposable
[Fact]
public async Task PeerNeedsSnapshot_InvokesSnapshotHooks()
{
// (a) peer's HandshakeAck.snapshot_required = true -> our SnapshotSender fires, and its
// gated send delegate routes messages through the writer onto the wire.
// (a) THIS node owes the peer a snapshot (its own needs_snapshot) -> our SnapshotSender fires,
// and its gated send delegate routes messages through the writer onto the wire.
{
var a = await NewSideAsync();
await a.Store.SetNeedsSnapshotAsync(true, default);
var (duplex, toSession, fromSession) = ScriptedPeer();
var sent = new TaskCompletionSource();
a.Session.SnapshotSender = (send, token) =>
@@ -564,7 +565,7 @@ public sealed class SyncSessionTests : IDisposable
await NextAsync(fromSession, cts.Token); // session Handshake
await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token);
await NextAsync(fromSession, cts.Token); // session HandshakeAck
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = true } }, cts.Token);
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, cts.Token);
await sent.Task.WaitAsync(RunTimeout);
var complete = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token);
@@ -574,16 +575,12 @@ public sealed class SyncSessionTests : IDisposable
await SwallowAsync(run);
}
// (b) inbound SnapshotBegin -> our SnapshotReceiver fires with the triggering message.
// (b) inbound SnapshotBegin -> our ISnapshotApplier.OnBeginAsync fires with the triggering message.
{
var a = await NewSideAsync();
var (duplex, toSession, fromSession) = ScriptedPeer();
var received = new TaskCompletionSource<long>();
a.Session.SnapshotReceiver = (begin, _, _) =>
{
received.TrySetResult(begin.AsOfSeq);
return Task.CompletedTask;
};
var applier = new CapturingApplier();
a.Session.SnapshotApplier = applier;
using var cts = new CancellationTokenSource(RunTimeout);
var run = a.Session.RunAsync(duplex, cts.Token);
@@ -591,14 +588,16 @@ public sealed class SyncSessionTests : IDisposable
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 42 } }, cts.Token);
Assert.Equal(42, await received.Task.WaitAsync(RunTimeout));
Assert.Equal(42, await applier.BeganAsOf.Task.WaitAsync(RunTimeout));
cts.Cancel();
await SwallowAsync(run);
}
// (c) snapshot required but no sender hook -> NotSupportedException.
// (c) this node owes a snapshot but no sender hook -> NotSupportedException.
{
var a = await NewSideAsync();
await a.Store.SetNeedsSnapshotAsync(true, default);
a.Session.SnapshotSender = null;
var (duplex, toSession, fromSession) = ScriptedPeer();
using var cts = new CancellationTokenSource(RunTimeout);
@@ -607,14 +606,15 @@ public sealed class SyncSessionTests : IDisposable
await NextAsync(fromSession, cts.Token);
await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token);
await NextAsync(fromSession, cts.Token);
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = true } }, cts.Token);
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, cts.Token);
await Assert.ThrowsAsync<NotSupportedException>(() => run);
}
// (d) inbound SnapshotBegin but no receiver hook -> NotSupportedException.
// (d) inbound SnapshotBegin but no applier hook -> NotSupportedException.
{
var a = await NewSideAsync();
a.Session.SnapshotApplier = null;
var (duplex, toSession, fromSession) = ScriptedPeer();
using var cts = new CancellationTokenSource(RunTimeout);
@@ -627,6 +627,20 @@ public sealed class SyncSessionTests : IDisposable
}
}
private sealed class CapturingApplier : ISnapshotApplier
{
public TaskCompletionSource<long> BeganAsOf { get; } = new();
public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct)
{
BeganAsOf.TrySetResult(begin.AsOfSeq);
return Task.CompletedTask;
}
public Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct) => Task.CompletedTask;
public Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct) => Task.CompletedTask;
}
[Fact]
public async Task SecondHandshake_MidStream_IsProtocolError()
{