cea3930b94
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.
233 lines
8.6 KiB
C#
233 lines
8.6 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.LocalDb.Internal;
|
|
|
|
namespace ZB.MOM.WW.LocalDb.Tests;
|
|
|
|
public sealed class LocalDbSchemaTests : IDisposable
|
|
{
|
|
private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
|
|
|
public void Dispose()
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
if (File.Exists(_path))
|
|
File.Delete(_path);
|
|
}
|
|
|
|
private SqliteConnection Open()
|
|
{
|
|
var conn = new SqliteConnection($"Data Source={_path}");
|
|
conn.Open();
|
|
return conn;
|
|
}
|
|
|
|
private static long ScalarLong(SqliteConnection conn, string sql)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
return Convert.ToInt64(cmd.ExecuteScalar());
|
|
}
|
|
|
|
private static string ScalarString(SqliteConnection conn, string sql)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
return (string)cmd.ExecuteScalar()!;
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureCreated_CreatesAllObjects()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table'";
|
|
var tables = new List<string>();
|
|
using (var reader = cmd.ExecuteReader())
|
|
while (reader.Read())
|
|
tables.Add(reader.GetString(0));
|
|
|
|
foreach (var expected in new[]
|
|
{
|
|
"__localdb_meta", "__localdb_oplog", "__localdb_peer_state",
|
|
"__localdb_row_version", "__localdb_applying", "__localdb_dead_letter",
|
|
"__localdb_snapshot_state",
|
|
})
|
|
Assert.Contains(expected, tables);
|
|
|
|
// __localdb_oplog_hlc is deliberately NOT created: schema v2 removed it. It indexed
|
|
// (table_name, pk_json, hlc), which no query has ever used — every oplog access path is by
|
|
// seq or a full hlc-predicate delete — while costing a b-tree insert inside every captured
|
|
// consumer write.
|
|
Assert.Equal(0, ScalarLong(conn,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
|
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
|
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureCreated_UpgradesLegacyFile_DropsOplogHlcIndex()
|
|
{
|
|
using var conn = Open();
|
|
|
|
// A file exactly as schema v1 left it: the tables, the index, and a v1 stamp.
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
Exec(conn, "CREATE INDEX __localdb_oplog_hlc ON __localdb_oplog(table_name, pk_json, hlc);" +
|
|
"UPDATE __localdb_meta SET schema_version = 1 WHERE id = 1;");
|
|
Assert.Equal(1, ScalarLong(conn,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
|
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
Assert.Equal(0, ScalarLong(conn,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
|
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
|
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()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
var nodeId = ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1");
|
|
Exec(conn,
|
|
"CREATE INDEX __localdb_oplog_hlc ON __localdb_oplog(table_name, pk_json, hlc);" +
|
|
"UPDATE __localdb_meta SET schema_version = 1 WHERE id = 1;" +
|
|
"INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone) " +
|
|
"VALUES ('orders', '{\"id\":1}', '{\"id\":1}', 42, 'n', 0);" +
|
|
"UPDATE __localdb_peer_state SET last_acked_seq = 7 WHERE id = 1;");
|
|
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
// The upgrade is DDL only — identity, replication watermarks and captured data survive it.
|
|
Assert.Equal(nodeId, ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1"));
|
|
Assert.Equal(1, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_oplog"));
|
|
Assert.Equal(42, ScalarLong(conn, "SELECT hlc FROM __localdb_oplog"));
|
|
Assert.Equal(7, ScalarLong(conn, "SELECT last_acked_seq FROM __localdb_peer_state WHERE id=1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureCreated_AlreadyCurrent_LeavesVersionAlone()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
|
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
|
}
|
|
|
|
private static void Exec(SqliteConnection conn, string sql)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureCreated_IsIdempotent()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
Assert.Equal(1, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_meta"));
|
|
Assert.Equal(1, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_applying"));
|
|
Assert.Equal(1, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_peer_state"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Meta_NodeId_MintedOnceAndStable()
|
|
{
|
|
string first;
|
|
using (var conn = Open())
|
|
{
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
first = ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1");
|
|
}
|
|
|
|
using (var conn = Open())
|
|
{
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
var second = ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1");
|
|
Assert.Equal(first, second);
|
|
}
|
|
|
|
Assert.True(Guid.TryParse(first, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Applying_SeededWithZeroRow()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
Assert.Equal(0, ScalarLong(conn, "SELECT applying FROM __localdb_applying WHERE id=1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadMeta_ReturnsNodeIdAndHighWater()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
var (nodeId, highWater) = LocalDbSchema.ReadMeta(conn);
|
|
|
|
Assert.True(Guid.TryParse(nodeId, out _));
|
|
Assert.Equal(0, highWater);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlushHlcHighWater_RoundTrips()
|
|
{
|
|
using var conn = Open();
|
|
LocalDbSchema.EnsureCreated(conn);
|
|
|
|
LocalDbSchema.FlushHlcHighWater(conn, 123_456_789L);
|
|
|
|
var (_, highWater) = LocalDbSchema.ReadMeta(conn);
|
|
Assert.Equal(123_456_789L, highWater);
|
|
}
|
|
}
|