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:
@@ -51,7 +51,8 @@ public sealed class LocalDbSchemaTests : IDisposable
|
||||
foreach (var expected in new[]
|
||||
{
|
||||
"__localdb_meta", "__localdb_oplog", "__localdb_peer_state",
|
||||
"__localdb_row_version", "__localdb_applying", "__localdb_dead_letter"
|
||||
"__localdb_row_version", "__localdb_applying", "__localdb_dead_letter",
|
||||
"__localdb_snapshot_state",
|
||||
})
|
||||
Assert.Contains(expected, tables);
|
||||
|
||||
@@ -85,6 +86,44 @@ public sealed class LocalDbSchemaTests : IDisposable
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_UpgradesV2File_AddsPerTableSnapshotState()
|
||||
{
|
||||
using var conn = Open();
|
||||
|
||||
// A file exactly as schema v2 left it: everything except __localdb_snapshot_state, stamped 2.
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
Exec(conn, "DROP TABLE __localdb_snapshot_state;" +
|
||||
"UPDATE __localdb_meta SET schema_version = 2 WHERE id = 1;");
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(1, ScalarLong(conn,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__localdb_snapshot_state'"));
|
||||
// Empty is the right v3 state for a file that never baselined anything: no table owes a
|
||||
// snapshot, and the database-wide flag it was carrying keeps whatever value it had.
|
||||
Assert.Equal(0, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_snapshot_state"));
|
||||
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_Upgrade_PreservesTheDatabaseWideSnapshotFlag()
|
||||
{
|
||||
// A v2 file mid-resync (cap prune flagged a full snapshot, the peer was down) must still owe
|
||||
// that snapshot after the upgrade. v3 narrows where NEW debt is recorded; it does not
|
||||
// reinterpret debt already on disk.
|
||||
using var conn = Open();
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
Exec(conn, "DROP TABLE __localdb_snapshot_state;" +
|
||||
"UPDATE __localdb_meta SET schema_version = 2 WHERE id = 1;" +
|
||||
"UPDATE __localdb_peer_state SET needs_snapshot = 1 WHERE id = 1;");
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(1, ScalarLong(conn, "SELECT needs_snapshot FROM __localdb_peer_state WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_Upgrade_PreservesNodeIdentityAndOplogRows()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user