feat(localdb): per-table needs_snapshot (0.3.0, on-disk schema v3)
Baselining one table used to set a single per-DATABASE needs_snapshot bit, and a snapshot streams every registered table in full — so opting one table into replication late re-sent all of them, in both directions at once on a pair enabling replication late. On a database holding a high-volume table (an event log at its retention cap) that is the largest transfer this library performs. 0.2.1 documented the cost and deferred the fix as needing an on-disk change. Schema v3 adds __localdb_snapshot_state (table_name PK, needs_snapshot), created by the shared DDL with IF NOT EXISTS so an older file gains it on open; the v2->v3 transition is a version stamp only. RegisterReplicated(..., baselineExistingRows: true) now flags that table alone. The database-wide flag survives unchanged for the paths that genuinely reach every table — a cap prune or an impossible peer ack loses delta continuity for the oplog as a whole. DeregisterReplicated drops a table's pending flag with its ledger rows, since nothing would ever stream (and so clear) it afterwards. Wire compatibility is additive and negotiated by CAPABILITY, not version: lib_schema_version stays 1 because ValidateHandshake compares it fail-closed for equality, so bumping it would stop 0.1.x/0.2.x pairs syncing outright. Handshake gains supports_partial_snapshot (field 5) and SnapshotBegin gains a tables scope (field 2) where EMPTY means all tables — precisely what a pre-0.3.0 sender emits and what a pre-0.3.0 receiver assumes. A peer that does not advertise the capability is served a full snapshot: a scoped one would make it advance last_applied_remote_seq to the snapshot's as-of and silently discard every delta below it for the tables left out. For the same reason a partial snapshot moves no watermark on either side. The receiver keeps last_applied_remote_seq (carrying only the HLC across, which is MAX-guarded), and the sender keeps _sentThruSeq, so the deltas the snapshot overlaps are still pumped and merged by LWW. Crash safety: snapshot debt now clears on the receiver's new SnapshotAck rather than on enqueue, so an ungraceful exit part-way through re-streams the same scope on reconnect. The ack rides the unbounded CONTROL lane like DeltaAck, so it composes with 0.2.1's bounded 64-message inbox and its out-of-handshake snapshot. Against a pre-0.3.0 peer the 0.2.x clear-on-enqueue behaviour is kept — there is no ack to wait for — with the handshake's horizon gap check as the net. Tests 199/199 (was 188): single-table baseline streams only that table, mixed- version interop (peer without the capability gets a full snapshot), debt standing until SnapshotAck, partial snapshot not advancing the receiver's watermark, no SnapshotAck emitted to an old peer, end-to-end over two real databases, plus v2->v3 upgrade and per-table flag isolation. README, ILocalDb remarks (which named this as the follow-up) and the umbrella CLAUDE.md index updated.
This commit is contained in:
@@ -233,10 +233,61 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
db.ExecuteAsync(
|
||||
"UPDATE __localdb_peer_state SET last_applied_remote_seq = 0, last_seen_hlc = 0 WHERE id = 1", null, ct);
|
||||
|
||||
/// <summary>Sets or clears the snapshot-resync-required flag.</summary>
|
||||
/// <summary>
|
||||
/// Sets or clears the DATABASE-WIDE snapshot-resync flag — the one that means "every registered
|
||||
/// table". Set by the oplog-horizon paths (cap prune, impossible peer ack), which invalidate
|
||||
/// delta continuity for all tables at once. Per-table flags live in
|
||||
/// <c>__localdb_snapshot_state</c>; see <see cref="GetTablesNeedingSnapshotAsync"/>.
|
||||
/// </summary>
|
||||
public Task SetNeedsSnapshotAsync(bool value, CancellationToken ct = default) =>
|
||||
db.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = @v WHERE id = 1", new { v = value ? 1 : 0 }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// The tables individually flagged as owing the peer a snapshot (schema v3), in ordinal name
|
||||
/// order. Independent of the database-wide flag: baselining one table flags only that table.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<string>> GetTablesNeedingSnapshotAsync(CancellationToken ct = default) =>
|
||||
db.QueryAsync(
|
||||
"SELECT table_name FROM __localdb_snapshot_state WHERE needs_snapshot <> 0 ORDER BY table_name",
|
||||
static r => r.GetString(0), null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the snapshot debt a completed snapshot discharged: the named tables when
|
||||
/// <paramref name="tables"/> is non-empty, otherwise <b>everything</b> — the database-wide flag
|
||||
/// and every per-table row, because a full snapshot subsumes each of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Rows are deleted rather than zeroed: absence is the resting state, and keeping a row per
|
||||
/// table that has ever been baselined would accumulate bookkeeping that means nothing. Runs in
|
||||
/// one transaction so a full snapshot's two clears cannot half-apply and leave the pair
|
||||
/// re-snapshotting on every reconnect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scoped clear deliberately does NOT touch the database-wide flag. If the cap enforcer
|
||||
/// flagged a whole-database resync while this snapshot was in flight, that debt is real and
|
||||
/// unrelated — the snapshot only carried some of the tables — so it must survive. The full
|
||||
/// clear may take it down because a full snapshot carries every row state as of its as-of seq,
|
||||
/// which is exactly what the pruned deltas would have delivered.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ClearSnapshotDebtAsync(IReadOnlyList<string> tables, CancellationToken ct = default)
|
||||
{
|
||||
await using var tx = await db.BeginTransactionAsync(ct);
|
||||
if (tables.Count == 0)
|
||||
{
|
||||
await tx.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = 0 WHERE id = 1", null, ct);
|
||||
await tx.ExecuteAsync("DELETE FROM __localdb_snapshot_state", null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var table in tables)
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM __localdb_snapshot_state WHERE table_name = @t", new { t = table }, ct);
|
||||
}
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the highest remote seq applied locally plus the peer HLC observed with it; also stamps
|
||||
/// <c>last_sync_utc</c>. Both watermarks are MAX-guarded (monotonic): a snapshot completing with a
|
||||
|
||||
@@ -19,21 +19,57 @@ internal interface ISnapshotApplier
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// Which tables a snapshot covers. An EMPTY <see cref="Tables"/> is the wire's own encoding of
|
||||
/// "all registered tables" (see <c>SnapshotBegin.tables</c>) and is what every pre-0.3.0 peer both
|
||||
/// sends and expects, so it is deliberately the same value here rather than a separate flag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The distinction is not cosmetic: a FULL snapshot carries every row state as of its
|
||||
/// <c>as_of_seq</c>, so the receiver may jump its watermark there and the sender may skip the
|
||||
/// deltas it covered. A PARTIAL one carries only the listed tables, so neither side may move a
|
||||
/// watermark that also speaks for the tables it left out.
|
||||
/// </remarks>
|
||||
internal sealed record SnapshotScope(IReadOnlyList<string> Tables)
|
||||
{
|
||||
/// <summary>Every registered table — the only scope a pre-0.3.0 peer can be sent.</summary>
|
||||
public static readonly SnapshotScope Full = new([]);
|
||||
|
||||
public bool IsPartial => Tables.Count > 0;
|
||||
|
||||
public bool Includes(string tableName) =>
|
||||
Tables.Count == 0 || Tables.Contains(tableName, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a snapshot of the tables in the requested <see cref="SnapshotScope"/> — every registered
|
||||
/// table for a peer that has fallen behind the pruned oplog horizon, or just the baselined ones for
|
||||
/// a scoped resync. 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>
|
||||
/// <remarks>
|
||||
/// Clearing the snapshot debt is deliberately NOT done here: whether the flags may be cleared at all
|
||||
/// depends on the peer's capability (a 0.3.0 peer acknowledges the snapshot, a pre-0.3.0 one cannot),
|
||||
/// which is session state. <see cref="SyncSession"/> owns that decision; this type only streams.
|
||||
/// </remarks>
|
||||
internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, ReplicationOptions options, ILogger logger)
|
||||
{
|
||||
/// <summary>Streams the full snapshot; returns the as-of oplog seq it covers, so the session can skip already-covered tail deltas.</summary>
|
||||
public async Task<long> SendAsync(Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Streams the snapshot for <paramref name="scope"/>; returns the as-of oplog seq it covers, so
|
||||
/// the session can skip already-covered tail deltas (a FULL snapshot only — a partial one speaks
|
||||
/// for nothing outside its tables).
|
||||
/// </summary>
|
||||
public async Task<long> SendAsync(SnapshotScope scope, Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
|
||||
{
|
||||
var asOfSeq = await store.GetMaxSeqAsync(ct);
|
||||
await send(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = asOfSeq } }, ct);
|
||||
var begin = new SnapshotBegin { AsOfSeq = asOfSeq };
|
||||
begin.Tables.AddRange(scope.Tables);
|
||||
await send(new SyncMessage { SnapshotBegin = begin }, ct);
|
||||
|
||||
var totalRows = 0L;
|
||||
foreach (var table in db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
foreach (var table in db.ReplicatedTables.Values
|
||||
.Where(t => scope.Includes(t.Name))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var sql = BuildPageSql(table);
|
||||
// Keyset cursor (pk_json > last seen), not LIMIT/OFFSET: concurrent inserts/prunes
|
||||
@@ -111,16 +147,9 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
}
|
||||
|
||||
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. Narrow window: the
|
||||
// messages are enqueued on the data lane, not yet transport-delivered, so a crash/drop after
|
||||
// this clear could lose the snapshot with the flag already down. The reconnect handshake's
|
||||
// gap check (peer watermark below the pruned horizon) re-detects that in every case where the
|
||||
// oplog is non-empty; the sole undetectable corner is an EMPTY oplog (nothing to gap-check),
|
||||
// where the peer stays behind until new local writes recreate a horizon. Accepted: closing it
|
||||
// would need transport-level delivery acks for snapshot frames.
|
||||
await store.SetNeedsSnapshotAsync(false, ct);
|
||||
logger.LogInformation("Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows).", asOfSeq, totalRows);
|
||||
logger.LogInformation(
|
||||
"Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows, scope {Scope}).",
|
||||
asOfSeq, totalRows, scope.IsPartial ? string.Join(",", scope.Tables) : "all");
|
||||
return asOfSeq;
|
||||
}
|
||||
|
||||
@@ -181,6 +210,7 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
private long _asOfSeq;
|
||||
private long _rows;
|
||||
private long _maxHlc;
|
||||
private bool _partial;
|
||||
|
||||
// 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.
|
||||
@@ -197,7 +227,13 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
_asOfSeq = begin.AsOfSeq;
|
||||
_rows = 0;
|
||||
_maxHlc = 0;
|
||||
_logger.LogInformation("Snapshot receive begun (as_of_seq {AsOfSeq}).", _asOfSeq);
|
||||
// A non-empty table list scopes the snapshot; empty (which is all a pre-0.3.0 sender can
|
||||
// emit) means every registered table. Read here rather than on complete because
|
||||
// SnapshotComplete does not repeat it — the receive loop guarantees a Begin precedes both.
|
||||
_partial = begin.Tables.Count > 0;
|
||||
_logger.LogInformation(
|
||||
"Snapshot receive begun (as_of_seq {AsOfSeq}, scope {Scope}).",
|
||||
_asOfSeq, _partial ? string.Join(",", begin.Tables) : "all");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -219,9 +255,20 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
|
||||
public async Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct)
|
||||
{
|
||||
// A PARTIAL snapshot may not move the inbound seq watermark. That watermark is one number
|
||||
// for the whole peer oplog, and jumping it to as_of_seq would declare every table applied
|
||||
// through that point — including the ones this snapshot deliberately left out, whose deltas
|
||||
// below as_of_seq the sender would then never resend. Only the HLC is carried across (seq 0
|
||||
// is a no-op against the MAX guard), so the clock still cannot regress below what the peer
|
||||
// has stamped. The sender makes the mirror-image concession: it does not advance its own
|
||||
// sent-thru watermark past a partial snapshot, so those deltas are still pumped normally
|
||||
// and merely re-deliver rows the snapshot already carried, which LWW absorbs.
|
||||
var seq = _partial ? 0 : complete.AsOfSeq;
|
||||
// SetLastAppliedRemoteSeqAsync is MAX-guarded, so a delta applied mid-snapshot with a seq
|
||||
// above the snapshot's as-of (or an HLC above what the snapshot carried) is never regressed.
|
||||
await _store.SetLastAppliedRemoteSeqAsync(complete.AsOfSeq, _maxHlc, ct);
|
||||
_logger.LogInformation("Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows).", complete.AsOfSeq, _rows);
|
||||
await _store.SetLastAppliedRemoteSeqAsync(seq, _maxHlc, ct);
|
||||
_logger.LogInformation(
|
||||
"Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows, partial {Partial}).",
|
||||
complete.AsOfSeq, _rows, _partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,17 @@ internal sealed class SyncSession
|
||||
// single receive-loop task, so no synchronization is needed.
|
||||
private bool _snapshotReceiving;
|
||||
|
||||
// Whether the peer speaks the 0.3.0 snapshot extensions: scoped snapshots
|
||||
// (SnapshotBegin.tables) and the SnapshotAck that answers a completed one. Written once during
|
||||
// the handshake — which completes before either loop starts — then read-only.
|
||||
private bool _peerSupportsPartialSnapshot;
|
||||
|
||||
// The scope of the snapshot this node is streaming, held until the peer's SnapshotAck discharges
|
||||
// it; null on every session that owes none. Assigned on the pump task strictly BEFORE the
|
||||
// snapshot's first message is enqueued and read on the receive loop when the ack lands, so the
|
||||
// ordering is causal — volatile only to make the cross-thread publication explicit.
|
||||
private SnapshotScope? _sentSnapshotScope;
|
||||
|
||||
public SyncSession(
|
||||
SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger,
|
||||
Func<DateTimeOffset>? utcNow = null, LocalDbMetrics? metrics = null, SyncStatus? status = null)
|
||||
@@ -63,11 +74,12 @@ internal sealed class SyncSession
|
||||
|
||||
/// <summary>
|
||||
/// 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); returns the as-of oplog seq the snapshot covers so the
|
||||
/// pump can skip already-covered tail deltas. Wired to <see cref="SnapshotStreamer.SendAsync"/>.
|
||||
/// the peer's) before deltas resume. Receives the negotiated <see cref="SnapshotScope"/> and a
|
||||
/// gated send delegate that enqueues on the bounded data lane (never the raw transport); returns
|
||||
/// the as-of oplog seq the snapshot covers so the pump can skip already-covered tail deltas.
|
||||
/// Wired to <see cref="SnapshotStreamer.SendAsync"/>.
|
||||
/// </summary>
|
||||
public Func<Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task<long>>? SnapshotSender { get; set; }
|
||||
public Func<SnapshotScope, Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task<long>>? SnapshotSender { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an inbound snapshot inline on the receive loop: SnapshotBegin opens the receive,
|
||||
@@ -99,7 +111,7 @@ internal sealed class SyncSession
|
||||
var writer = WriterLoopAsync(duplex, control.Reader, data.Reader, linked.Token);
|
||||
try
|
||||
{
|
||||
var snapshotRequired = await HandshakeAsync(duplex, control.Writer, linked.Token);
|
||||
var snapshotScope = await HandshakeAsync(duplex, control.Writer, linked.Token);
|
||||
|
||||
// The receive loop starts BEFORE any snapshot this node owes is streamed. That ordering
|
||||
// is load-bearing, not incidental: a snapshot sent from inside the handshake left the
|
||||
@@ -111,7 +123,7 @@ internal sealed class SyncSession
|
||||
// ordinary flow control. Interleaving is already safe by design: snapshot rows and
|
||||
// deltas both merge through LWW, and the snapshot's watermark write is MAX-guarded.
|
||||
var receive = ReceiveLoopAsync(duplex, control.Writer, linked.Token);
|
||||
var pump = SnapshotThenPumpLoopAsync(snapshotRequired, GatedDataSend, data.Writer, linked.Token);
|
||||
var pump = SnapshotThenPumpLoopAsync(snapshotScope, GatedDataSend, data.Writer, linked.Token);
|
||||
var loops = new[] { pump, receive, writer };
|
||||
|
||||
// The first COMPLETED task is the primary outcome. Cancel the siblings, observe every
|
||||
@@ -135,12 +147,12 @@ internal sealed class SyncSession
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the two-phase fail-closed handshake and returns whether THIS node owes the peer a full
|
||||
/// snapshot (its own computation from its own state — the peer's answer drives the peer's send
|
||||
/// direction, symmetrically). Sending that snapshot is deliberately NOT part of the handshake;
|
||||
/// see <see cref="RunAsync"/>.
|
||||
/// Runs the two-phase fail-closed handshake and returns the snapshot THIS node owes the peer —
|
||||
/// null for none, otherwise the <see cref="SnapshotScope"/> to stream (its own computation from
|
||||
/// its own state; the peer's answer drives the peer's send direction, symmetrically). Sending
|
||||
/// that snapshot is deliberately NOT part of the handshake; see <see cref="RunAsync"/>.
|
||||
/// </summary>
|
||||
private async Task<bool> HandshakeAsync(
|
||||
private async Task<SnapshotScope?> HandshakeAsync(
|
||||
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane, CancellationToken ct)
|
||||
{
|
||||
var peerState = await _store.GetPeerStateAsync(ct);
|
||||
@@ -158,6 +170,11 @@ internal sealed class SyncSession
|
||||
|
||||
ValidateHandshake(localHandshake, peerHandshake);
|
||||
|
||||
// Read before anything can act on it. A pre-0.3.0 peer leaves the field at its proto3
|
||||
// default, so "false" covers both "explicitly unsupported" and "older build" without a
|
||||
// version check — which is the point of advertising a capability rather than a version.
|
||||
_peerSupportsPartialSnapshot = peerHandshake.SupportsPartialSnapshot;
|
||||
|
||||
// A different node id on the far end means the peer was replaced, not merely restarted — a
|
||||
// rebuilt host, a restored-from-nothing volume. Its oplog numbers from 1 again, so the
|
||||
// watermark we hold (in the OLD peer's seq space) would silently swallow its first N
|
||||
@@ -191,16 +208,16 @@ internal sealed class SyncSession
|
||||
_sentThruSeq = peerAppliedThruSeq;
|
||||
Interlocked.Exchange(ref _peerAckedSeq, peerAppliedThruSeq);
|
||||
|
||||
var snapshotRequired = await ComputeSnapshotRequiredAsync(peerHandshake, peerState, ct);
|
||||
var snapshotScope = await ComputeSnapshotScopeAsync(peerHandshake, peerState, ct);
|
||||
await controlLane.WriteAsync(
|
||||
new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = _db.NodeId, SnapshotRequired = snapshotRequired } }, ct);
|
||||
new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = _db.NodeId, SnapshotRequired = snapshotScope is not null } }, ct);
|
||||
|
||||
var peerAckMsg = await ReceiveOneAsync(duplex, ct);
|
||||
if (peerAckMsg.MsgCase != SyncMessage.MsgOneofCase.HandshakeAck)
|
||||
throw new InvalidOperationException(
|
||||
$"Replication protocol error: expected a HandshakeAck, got {peerAckMsg.MsgCase}.");
|
||||
|
||||
return snapshotRequired;
|
||||
return snapshotScope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -216,25 +233,55 @@ internal sealed class SyncSession
|
||||
/// than racing the transport.
|
||||
/// </remarks>
|
||||
private async Task SnapshotThenPumpLoopAsync(
|
||||
bool snapshotRequired, Func<SyncMessage, CancellationToken, Task> dataSend,
|
||||
SnapshotScope? snapshotScope, Func<SyncMessage, CancellationToken, Task> dataSend,
|
||||
ChannelWriter<SyncMessage> dataLane, CancellationToken ct)
|
||||
{
|
||||
if (snapshotRequired)
|
||||
if (snapshotScope is not null)
|
||||
{
|
||||
if (SnapshotSender is null)
|
||||
throw new NotSupportedException("snapshot required but no snapshot sender configured");
|
||||
var snapshotAsOfSeq = await SnapshotSender(dataSend, ct);
|
||||
// Published BEFORE the first frame is enqueued, so the peer's SnapshotAck can never
|
||||
// arrive on the receive loop while this is still null.
|
||||
Volatile.Write(ref _sentSnapshotScope, snapshotScope);
|
||||
var snapshotAsOfSeq = await SnapshotSender(snapshotScope, dataSend, ct);
|
||||
_metrics?.RecordSnapshotSent();
|
||||
_status?.SetLastSyncUtc(_utcNow());
|
||||
// The snapshot already carries every row state up to as-of; skipping the pump past it
|
||||
// avoids re-sending tail deltas the peer would only discard via LWW.
|
||||
if (snapshotAsOfSeq > _sentThruSeq)
|
||||
// A FULL snapshot carries every row state up to as-of, so skipping the pump past it
|
||||
// avoids re-sending tail deltas the peer would only discard via LWW. A PARTIAL one
|
||||
// speaks only for its own tables, so the pump must still deliver every delta below
|
||||
// as-of — the peer has not moved its watermark either (see SnapshotApplier.OnComplete).
|
||||
if (!snapshotScope.IsPartial && snapshotAsOfSeq > _sentThruSeq)
|
||||
_sentThruSeq = snapshotAsOfSeq;
|
||||
|
||||
// A peer that cannot acknowledge the snapshot leaves only the 0.2.x option: clear once
|
||||
// the frames are on the data lane. The window that opens (enqueued, not yet delivered —
|
||||
// a crash there loses the snapshot with the debt already discharged) is why 0.3.0 peers
|
||||
// wait for the ack instead. The reconnect handshake's gap check re-detects it whenever
|
||||
// the oplog is non-empty; an EMPTY oplog remains the undetectable corner.
|
||||
if (!_peerSupportsPartialSnapshot)
|
||||
await ClearSentSnapshotDebtAsync(ct);
|
||||
}
|
||||
|
||||
await PumpLoopAsync(dataLane, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discharges the snapshot debt this session's outbound snapshot covered, exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taking the scope out of the field before clearing makes this idempotent, which matters
|
||||
/// because two paths reach it — the pre-0.3.0 clear-on-enqueue above and the peer's SnapshotAck
|
||||
/// — and a peer that acknowledges anyway (a newer build than its handshake claimed) must not
|
||||
/// clear a second time on top of flags a later baseline may already have re-raised.
|
||||
/// </remarks>
|
||||
private async Task ClearSentSnapshotDebtAsync(CancellationToken ct)
|
||||
{
|
||||
var scope = Interlocked.Exchange(ref _sentSnapshotScope, null);
|
||||
if (scope is null)
|
||||
return;
|
||||
await _store.ClearSnapshotDebtAsync(scope.Tables, ct);
|
||||
}
|
||||
|
||||
// The ONLY caller of duplex.Send. Control (handshake + acks) always preempts data so the peer's
|
||||
// inbound processing is never starved of its acks behind a backlog of DeltaBatches.
|
||||
private static async Task WriterLoopAsync(
|
||||
@@ -384,6 +431,20 @@ internal sealed class SyncSession
|
||||
_snapshotReceiving = false;
|
||||
_metrics?.RecordSnapshotReceived();
|
||||
_status?.SetLastSyncUtc(_utcNow());
|
||||
// Acknowledge only to a peer that asked for it. Sent AFTER the applier's
|
||||
// durable watermark write, so the ack means "I hold these rows", which is
|
||||
// what lets the sender drop its per-table flags. On the unbounded control
|
||||
// lane, like DeltaAck: an ack must never queue behind a data backlog.
|
||||
if (_peerSupportsPartialSnapshot)
|
||||
await controlLane.WriteAsync(
|
||||
new SyncMessage { SnapshotAck = new SnapshotAck { AsOfSeq = msg.SnapshotComplete.AsOfSeq } }, ct);
|
||||
break;
|
||||
case SyncMessage.MsgOneofCase.SnapshotAck:
|
||||
// The peer has durably applied the snapshot THIS node streamed, so the
|
||||
// tables it covered no longer owe one. Nothing to reconcile against the
|
||||
// ack's as-of seq: only one snapshot is ever sent per session, so the scope
|
||||
// held in _sentSnapshotScope is unambiguously the one being acknowledged.
|
||||
await ClearSentSnapshotDebtAsync(ct);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
@@ -565,6 +626,9 @@ internal sealed class SyncSession
|
||||
NodeId = _db.NodeId,
|
||||
LibSchemaVersion = LibSchemaVersion,
|
||||
LastAppliedRemoteSeq = peerState.LastAppliedRemoteSeq,
|
||||
// Unconditional: this build always understands a scoped snapshot and always answers a
|
||||
// completed one. It is a statement about the code, not about configuration or state.
|
||||
SupportsPartialSnapshot = true,
|
||||
};
|
||||
foreach (var table in _db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
handshake.Tables.Add(new TableDigest { TableName = table.Name, Digest = table.Digest });
|
||||
@@ -595,11 +659,63 @@ internal sealed class SyncSession
|
||||
"Replication handshake rejected (fail-closed): " + string.Join("; ", parts) + ".");
|
||||
}
|
||||
|
||||
private async Task<bool> ComputeSnapshotRequiredAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Decides what snapshot this node owes the peer: null for none, <see cref="SnapshotScope.Full"/>
|
||||
/// for every registered table, or a scoped snapshot naming just the tables that need one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two sources of snapshot debt are genuinely different in reach, which is why they are
|
||||
/// stored separately. An <b>oplog-horizon</b> gap — the peer's watermark below the oldest delta
|
||||
/// still streamable, which is what a cap prune or an impossible peer ack leaves behind — means
|
||||
/// deltas have been lost for the oplog as a whole, so nothing short of every table is sound. A
|
||||
/// <b>baseline</b> means one table's pre-existing rows were never captured, and says nothing at
|
||||
/// all about the others.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scope that happens to name every registered table is returned as Full rather than as a
|
||||
/// list: it covers exactly what a full snapshot covers, so it should also get a full snapshot's
|
||||
/// watermark advance on both sides, and it needs no promotion for a pre-0.3.0 peer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Flags naming tables that are not registered are ignored (they can only stream nothing).
|
||||
/// <c>DeregisterReplicated</c> drops them with the table's ledger rows, so the surviving way to
|
||||
/// hold one is to drop the SQL table without deregistering — which the handshake's fail-closed
|
||||
/// digest comparison rejects for its own reasons.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<SnapshotScope?> ComputeSnapshotScopeAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
{
|
||||
if (peerState.NeedsSnapshot)
|
||||
return true;
|
||||
if (peerState.NeedsSnapshot || await HasHorizonGapAsync(peerHandshake, peerState, ct))
|
||||
return SnapshotScope.Full;
|
||||
|
||||
var flagged = await _store.GetTablesNeedingSnapshotAsync(ct);
|
||||
if (flagged.Count == 0)
|
||||
return null;
|
||||
|
||||
var scoped = flagged.Where(_db.ReplicatedTables.ContainsKey).ToList();
|
||||
if (scoped.Count == 0)
|
||||
return null;
|
||||
if (scoped.Count >= _db.ReplicatedTables.Count)
|
||||
return SnapshotScope.Full;
|
||||
|
||||
// A peer that would read SnapshotBegin.tables as "all tables" and then advance its watermark
|
||||
// past deltas this snapshot did not carry gets the full snapshot instead. Wider than asked
|
||||
// for, but it is the only shape the older protocol can express correctly — and it is what
|
||||
// 0.2.x did for every baseline.
|
||||
if (!_peerSupportsPartialSnapshot)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Peer {PeerNodeId} does not support scoped snapshots; promoting the snapshot of {Tables} to a full one.",
|
||||
PeerNodeId, string.Join(",", scoped));
|
||||
return SnapshotScope.Full;
|
||||
}
|
||||
|
||||
return new SnapshotScope(scoped);
|
||||
}
|
||||
|
||||
private async Task<bool> HasHorizonGapAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
{
|
||||
var minRows = await _db.QueryAsync(
|
||||
"SELECT COALESCE(MIN(seq), 0) FROM __localdb_oplog", static r => r.GetInt64(0), null, ct);
|
||||
var minSeq = minRows[0];
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
/// <summary>
|
||||
/// Shared construction of a <see cref="SyncSession"/> and its dependencies (OplogStore + LwwApplier)
|
||||
/// from a <see cref="SqliteLocalDb"/> and the bound options. Both the service and client adapters use
|
||||
/// this so they build an identical session; snapshot hooks are left null (Task 12 fills them in).
|
||||
/// this so they build an identical session.
|
||||
/// </summary>
|
||||
internal static class SyncSessionFactory
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user