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
@@ -39,6 +39,10 @@ internal sealed class SyncSession
private string? _peerNodeId;
private bool _driftWarned;
// Set true between an inbound SnapshotBegin and its SnapshotComplete. Only ever touched on the
// single receive-loop task, so no synchronization is needed.
private bool _snapshotReceiving;
public SyncSession(
SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger,
Func<DateTimeOffset>? utcNow = null)
@@ -52,16 +56,18 @@ internal sealed class SyncSession
}
/// <summary>
/// Invoked when the peer's HandshakeAck requests a snapshot before deltas. Receives a gated
/// send delegate that enqueues on the bounded data lane (never the raw transport). Task 12 fills this in.
/// Invoked when THIS node owes the peer a snapshot (its own snapshot-required computation, not
/// the peer's) before deltas resume. Receives a gated send delegate that enqueues on the bounded
/// data lane (never the raw transport). Wired to <see cref="SnapshotStreamer.SendAsync"/>.
/// </summary>
public Func<Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task>? SnapshotSender { get; set; }
/// <summary>
/// Invoked on an inbound SnapshotBegin, with the triggering message and a gated data-lane send
/// delegate. Task 12 fills this in.
/// Consumes an inbound snapshot inline on the receive loop: SnapshotBegin opens the receive,
/// SnapshotBatch merges through LWW, SnapshotComplete advances the watermark. Wired to
/// <see cref="Internal.SnapshotApplier"/>.
/// </summary>
public Func<SnapshotBegin, Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task>? SnapshotReceiver { get; set; }
public ISnapshotApplier? SnapshotApplier { get; set; }
/// <summary>Highest seq the peer has acknowledged applying this session.</summary>
public long PeerAckedSeq => Interlocked.Read(ref _peerAckedSeq);
@@ -86,7 +92,7 @@ internal sealed class SyncSession
await HandshakeAsync(duplex, control.Writer, GatedDataSend, linked.Token);
var pump = PumpLoopAsync(data.Writer, linked.Token);
var receive = ReceiveLoopAsync(duplex, control.Writer, GatedDataSend, linked.Token);
var receive = ReceiveLoopAsync(duplex, control.Writer, linked.Token);
var loops = new[] { pump, receive, writer };
// The first COMPLETED task is the primary outcome. Cancel the siblings, observe every
@@ -142,7 +148,10 @@ internal sealed class SyncSession
throw new InvalidOperationException(
$"Replication protocol error: expected a HandshakeAck, got {peerAckMsg.MsgCase}.");
if (peerAckMsg.HandshakeAck.SnapshotRequired)
// We stream the snapshot when WE owe the peer one (computed from our pruned horizon /
// needs_snapshot), then it lands on the peer's receive loop. The peer's own flag drives ITS
// send direction symmetrically; both are safe to run because LWW merges either way.
if (snapshotRequired)
{
if (SnapshotSender is null)
throw new NotSupportedException("snapshot required but no snapshot sender configured");
@@ -213,8 +222,7 @@ internal sealed class SyncSession
}
private async Task ReceiveLoopAsync(
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane,
Func<SyncMessage, CancellationToken, Task> dataSend, CancellationToken ct)
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane, CancellationToken ct)
{
while (await duplex.Inbox.WaitToReadAsync(ct))
{
@@ -223,6 +231,7 @@ internal sealed class SyncSession
switch (msg.MsgCase)
{
case SyncMessage.MsgOneofCase.DeltaBatch:
// Deltas apply normally even mid-snapshot: LWW makes the interleave safe.
await HandleDeltaBatchAsync(controlLane, msg.DeltaBatch, ct);
break;
case SyncMessage.MsgOneofCase.DeltaAck:
@@ -231,9 +240,23 @@ internal sealed class SyncSession
await _store.RecordPeerAckAsync(acked, ct);
break;
case SyncMessage.MsgOneofCase.SnapshotBegin:
if (SnapshotReceiver is null)
throw new NotSupportedException("snapshot received but no snapshot receiver configured");
await SnapshotReceiver(msg.SnapshotBegin, dataSend, ct);
if (SnapshotApplier is null)
throw new NotSupportedException("snapshot received but no snapshot applier configured");
_snapshotReceiving = true;
await SnapshotApplier.OnBeginAsync(msg.SnapshotBegin, ct);
break;
case SyncMessage.MsgOneofCase.SnapshotBatch:
if (!_snapshotReceiving)
throw new InvalidOperationException(
"Replication protocol error: SnapshotBatch with no active snapshot (missing SnapshotBegin).");
await SnapshotApplier!.OnBatchAsync(msg.SnapshotBatch, ct);
break;
case SyncMessage.MsgOneofCase.SnapshotComplete:
if (!_snapshotReceiving)
throw new InvalidOperationException(
"Replication protocol error: SnapshotComplete with no active snapshot (missing SnapshotBegin).");
await SnapshotApplier!.OnCompleteAsync(msg.SnapshotComplete, ct);
_snapshotReceiving = false;
break;
default:
throw new InvalidOperationException(