feat(localdb): snapshot resync (LWW-merging, tombstone-carrying, delta resume)
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LocalDb.Contracts;
|
||||
using ZB.MOM.WW.LocalDb.Internal;
|
||||
using ZB.MOM.WW.LocalDb.Registration;
|
||||
|
||||
namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an inbound full-snapshot the receive loop routes to it: SnapshotBegin opens a receive,
|
||||
/// each SnapshotBatch merges through the SAME last-writer-wins path as deltas (never truncating —
|
||||
/// the receiver may hold newer local writes), and SnapshotComplete advances the durable watermark
|
||||
/// so incremental deltas resume above it.
|
||||
/// </summary>
|
||||
internal interface ISnapshotApplier
|
||||
{
|
||||
Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct);
|
||||
Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct);
|
||||
Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a full snapshot of every registered table to a peer that has fallen behind the pruned
|
||||
/// oplog horizon. Reads the authoritative per-row version from <c>__localdb_row_version</c> joined
|
||||
/// to each live table, pages it into <see cref="SnapshotBatch"/> messages through the gated data
|
||||
/// lane, and brackets them with SnapshotBegin/SnapshotComplete carrying the max oplog seq as-of.
|
||||
/// </summary>
|
||||
internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, ReplicationOptions options, ILogger logger)
|
||||
{
|
||||
public async Task SendAsync(Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
|
||||
{
|
||||
var asOfSeq = await store.GetMaxSeqAsync(ct);
|
||||
await send(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = asOfSeq } }, ct);
|
||||
|
||||
var totalRows = 0L;
|
||||
foreach (var table in db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var sql = BuildPageSql(table);
|
||||
var offset = 0;
|
||||
while (true)
|
||||
{
|
||||
var rows = await db.QueryAsync(
|
||||
sql,
|
||||
// Column 4 is NULL for tombstones; a proto optional string cannot be assigned null,
|
||||
// so carry it as a nullable and only set row_json when present.
|
||||
static r => (
|
||||
PkJson: r.GetString(0),
|
||||
Hlc: r.GetInt64(1),
|
||||
NodeId: r.GetString(2),
|
||||
IsTombstone: r.GetInt64(3) != 0,
|
||||
RowJson: r.IsDBNull(4) ? null : r.GetString(4)),
|
||||
new { t = table.Name, page = options.MaxBatchSize, off = offset }, ct);
|
||||
if (rows.Count == 0)
|
||||
break;
|
||||
|
||||
var batch = new SnapshotBatch { TableName = table.Name };
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var snapshotRow = new SnapshotRow
|
||||
{
|
||||
PkJson = row.PkJson,
|
||||
Hlc = row.Hlc,
|
||||
NodeId = row.NodeId,
|
||||
IsTombstone = row.IsTombstone,
|
||||
};
|
||||
if (row.RowJson is not null)
|
||||
snapshotRow.RowJson = row.RowJson;
|
||||
batch.Rows.Add(snapshotRow);
|
||||
}
|
||||
await send(new SyncMessage { SnapshotBatch = batch }, ct);
|
||||
totalRows += rows.Count;
|
||||
|
||||
offset += rows.Count;
|
||||
if (rows.Count < options.MaxBatchSize)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = asOfSeq } }, ct);
|
||||
// The sender owed this snapshot (its own needs_snapshot); clearing it here — after the whole
|
||||
// snapshot is on the wire — stops it re-streaming on the next reconnect.
|
||||
await store.SetNeedsSnapshotAsync(false, ct);
|
||||
logger.LogInformation("Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows).", asOfSeq, totalRows);
|
||||
}
|
||||
|
||||
// Pages one table's rows by joining the row-version ledger to the live table on the PK columns
|
||||
// extracted from pk_json. Rows present in the live table but absent from __localdb_row_version
|
||||
// (data written BEFORE the table was registered for replication) are intentionally NOT snapshotted:
|
||||
// they were never captured, consistent with CDC semantics — a full re-registration is required to
|
||||
// baseline them.
|
||||
private static string BuildPageSql(ReplicatedTable table)
|
||||
{
|
||||
var name = Ident(table.Name);
|
||||
var join = string.Join(" AND ", table.PkColumns.Select(p => $"t.{Ident(p)} = json_extract(rv.pk_json, {JsonPath(p)})"));
|
||||
var jsonObject = "json_object(" +
|
||||
string.Join(", ", table.Columns.Select(c => $"{StringLiteral(c.Name)}, t.{Ident(c.Name)}")) + ")";
|
||||
return
|
||||
"SELECT rv.pk_json, rv.hlc, rv.node_id, rv.is_tombstone, " +
|
||||
$"CASE WHEN rv.is_tombstone = 1 THEN NULL ELSE {jsonObject} END " +
|
||||
$"FROM __localdb_row_version rv LEFT JOIN {name} t ON {join} " +
|
||||
"WHERE rv.table_name = @t ORDER BY rv.pk_json LIMIT @page OFFSET @off";
|
||||
}
|
||||
|
||||
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
private static string StringLiteral(string value) => "'" + value.Replace("'", "''") + "'";
|
||||
|
||||
// A single-quoted JSON path with a quoted key member so a column name with reserved characters
|
||||
// still resolves: $."col".
|
||||
private static string JsonPath(string column) => "'$.\"" + column.Replace("\"", "\\\"") + "\"'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The inbound-snapshot consumer wired into <see cref="SyncSession"/>. Each batch merges through the
|
||||
/// shared <see cref="LwwApplier"/> (seq 0 — snapshot rows carry no oplog seq — so the applier's
|
||||
/// MAX-guarded watermark update is a no-op, and per-row LWW protects any newer local write). On
|
||||
/// complete, the remote watermark is set to the snapshot's as-of seq so deltas resume above it.
|
||||
/// </summary>
|
||||
internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
{
|
||||
private readonly LwwApplier _applier;
|
||||
private readonly OplogStore _store;
|
||||
private readonly ILogger _logger;
|
||||
private long _asOfSeq;
|
||||
private long _rows;
|
||||
private long _maxHlc;
|
||||
|
||||
// db is part of the collaborator's canonical shape (mirrors the streamer) even though the merge
|
||||
// reaches the database through the applier and store; kept for construction symmetry in the factory.
|
||||
public SnapshotApplier(SqliteLocalDb db, LwwApplier applier, OplogStore store, ILogger logger)
|
||||
{
|
||||
_ = db;
|
||||
_applier = applier;
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct)
|
||||
{
|
||||
_asOfSeq = begin.AsOfSeq;
|
||||
_rows = 0;
|
||||
_maxHlc = 0;
|
||||
_logger.LogInformation("Snapshot receive begun (as_of_seq {AsOfSeq}).", _asOfSeq);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct)
|
||||
{
|
||||
if (batch.Rows.Count == 0)
|
||||
return;
|
||||
|
||||
var entries = new List<OplogEntryRecord>(batch.Rows.Count);
|
||||
foreach (var row in batch.Rows)
|
||||
{
|
||||
if (row.Hlc > _maxHlc) _maxHlc = row.Hlc;
|
||||
entries.Add(new OplogEntryRecord(
|
||||
0, batch.TableName, row.PkJson, row.HasRowJson ? row.RowJson : null, row.Hlc, row.NodeId, row.IsTombstone));
|
||||
}
|
||||
await _applier.ApplyBatchAsync(entries, ct);
|
||||
_rows += batch.Rows.Count;
|
||||
}
|
||||
|
||||
public async Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct)
|
||||
{
|
||||
// Preserve last_seen_hlc against a concurrently-applied delta that observed a higher HLC:
|
||||
// SetLastAppliedRemoteSeqAsync overwrites the column, so pass the max of what we saw and
|
||||
// what is already recorded.
|
||||
var lastSeenHlc = Math.Max(_maxHlc, (await _store.GetPeerStateAsync(ct)).LastSeenHlc);
|
||||
await _store.SetLastAppliedRemoteSeqAsync(complete.AsOfSeq, lastSeenHlc, ct);
|
||||
_logger.LogInformation("Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows).", complete.AsOfSeq, _rows);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -25,6 +25,11 @@ internal static class SyncSessionFactory
|
||||
var store = new OplogStore(db, options);
|
||||
var applier = new LwwApplier(db);
|
||||
var logger = loggerFactory.CreateLogger<SyncSession>();
|
||||
return new SyncSession(db, store, applier, options, logger);
|
||||
var session = new SyncSession(db, store, applier, options, logger);
|
||||
|
||||
var streamer = new SnapshotStreamer(db, store, options, loggerFactory.CreateLogger<SnapshotStreamer>());
|
||||
session.SnapshotSender = streamer.SendAsync;
|
||||
session.SnapshotApplier = new SnapshotApplier(db, applier, store, loggerFactory.CreateLogger<SnapshotApplier>());
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user