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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user