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