feat(localdb): snapshot resync (LWW-merging, tombstone-carrying, delta resume)
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user