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.
537 lines
22 KiB
C#
537 lines
22 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.LocalDb.Internal;
|
|
using ZB.MOM.WW.LocalDb.Registration;
|
|
|
|
namespace ZB.MOM.WW.LocalDb.Tests;
|
|
|
|
public sealed class RegistrationTests : 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 SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path });
|
|
|
|
private static async Task CreateOrders(SqliteLocalDb db) =>
|
|
await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)");
|
|
|
|
private Task<IReadOnlyList<string>> Triggers(SqliteLocalDb db) =>
|
|
db.QueryAsync(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='orders' ORDER BY name",
|
|
r => r.GetString(0));
|
|
|
|
[Fact]
|
|
public async Task Register_TableWithPk_InstallsThreeTriggers()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
db.RegisterReplicated("orders");
|
|
|
|
var triggers = await Triggers(db);
|
|
Assert.Equal(
|
|
new[] { "__localdb_orders_ad", "__localdb_orders_ai", "__localdb_orders_au" },
|
|
triggers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_NoPk_Throws()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE nopk (a TEXT, b TEXT)");
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("nopk"));
|
|
Assert.Contains("nopk", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_MissingTable_Throws()
|
|
{
|
|
using var db = Create();
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("ghost"));
|
|
Assert.Contains("ghost", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_BlobColumn_Throws()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE blobby (id INTEGER PRIMARY KEY, data BLOB)");
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("blobby"));
|
|
Assert.Contains("blobby", ex.Message);
|
|
Assert.Contains("data", ex.Message);
|
|
Assert.Contains("BLOB", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_IsIdempotent_RegeneratesTriggers()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
db.RegisterReplicated("orders");
|
|
db.RegisterReplicated("orders");
|
|
|
|
var triggers = await Triggers(db);
|
|
Assert.Equal(3, triggers.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_CompositePk_Works()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE kv (a INTEGER, b INTEGER, v TEXT, PRIMARY KEY (a, b))");
|
|
|
|
db.RegisterReplicated("kv");
|
|
await db.ExecuteAsync("INSERT INTO kv (a, b, v) VALUES (1, 2, 'x')");
|
|
|
|
var pk = await db.QueryAsync(
|
|
"SELECT pk_json FROM __localdb_oplog WHERE table_name='kv'",
|
|
r => r.GetString(0));
|
|
Assert.Single(pk);
|
|
Assert.Equal("{\"a\":1,\"b\":2}", pk[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Digest_ChangesWhenSchemaChanges()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
db.RegisterReplicated("orders");
|
|
var before = db.ReplicatedTables["orders"].Digest;
|
|
|
|
await db.ExecuteAsync("ALTER TABLE orders ADD COLUMN note TEXT");
|
|
db.RegisterReplicated("orders");
|
|
var after = db.ReplicatedTables["orders"].Digest;
|
|
|
|
Assert.NotEqual(before, after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplicatedTables_ExposesRegistry()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
db.RegisterReplicated("orders");
|
|
|
|
var table = db.ReplicatedTables["orders"];
|
|
Assert.Equal("orders", table.Name);
|
|
Assert.Equal(new[] { "id" }, table.PkColumns);
|
|
Assert.Equal(new[] { "id", "sku", "qty" }, table.Columns.Select(c => c.Name).ToArray());
|
|
}
|
|
|
|
// ---- Deregistration ------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Deregister_DropsTriggers_PrunesOplogAndLedger_LeavesDataAlone()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
db.RegisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
|
|
|
var removed = db.DeregisterReplicated("orders");
|
|
|
|
Assert.True(removed);
|
|
Assert.Empty(await Triggers(db));
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'"));
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'"));
|
|
Assert.False(db.ReplicatedTables.ContainsKey("orders"));
|
|
|
|
// The consumer's own data is never touched — this removes CAPTURE, not rows.
|
|
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM orders"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_StopsCapture()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
db.RegisterReplicated("orders");
|
|
db.DeregisterReplicated("orders");
|
|
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
await db.ExecuteAsync("UPDATE orders SET qty = 9 WHERE id = 1");
|
|
await db.ExecuteAsync("DELETE FROM orders WHERE id = 1");
|
|
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog"));
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_IsIdempotent_SecondCallReportsNothingToDo()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
db.RegisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
|
|
Assert.True(db.DeregisterReplicated("orders"));
|
|
Assert.False(db.DeregisterReplicated("orders"));
|
|
Assert.False(db.DeregisterReplicated("orders"));
|
|
Assert.Empty(await Triggers(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_NeverRegisteredTable_IsANoOp()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
Assert.False(db.DeregisterReplicated("orders"));
|
|
Assert.False(db.DeregisterReplicated("ghost"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_ClearsTriggersLeftByAnEarlierProcess()
|
|
{
|
|
// The primary use: a database file registered by an OLDER build, opened by one that has
|
|
// decided this node does not replicate. Nothing in memory says the table was ever
|
|
// registered — the evidence is entirely on disk.
|
|
using (var first = Create())
|
|
{
|
|
await CreateOrders(first);
|
|
first.RegisterReplicated("orders");
|
|
await first.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
}
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
using var second = Create();
|
|
Assert.Equal(3, (await Triggers(second)).Count);
|
|
Assert.False(second.ReplicatedTables.ContainsKey("orders"));
|
|
|
|
Assert.True(second.DeregisterReplicated("orders"));
|
|
|
|
Assert.Empty(await Triggers(second));
|
|
Assert.Equal(0, await CountAsync(second, "SELECT COUNT(*) FROM __localdb_oplog"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_LeavesOtherTablesCapturing()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
|
db.RegisterReplicated("orders");
|
|
db.RegisterReplicated("widgets");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W')");
|
|
|
|
db.DeregisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (2,'W2')");
|
|
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'"));
|
|
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='widgets'"));
|
|
Assert.True(db.ReplicatedTables.ContainsKey("widgets"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_ThenRegisterWithBaseline_RestoresTheLedger()
|
|
{
|
|
// Pruning the ledger is only safe because re-registration can re-baseline; this is that
|
|
// round trip.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
db.RegisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
|
|
|
db.DeregisterReplicated("orders");
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'"));
|
|
}
|
|
|
|
// ---- Baselining ----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Register_WithoutBaseline_LeavesPreexistingRowsUnledgered()
|
|
{
|
|
// The documented CDC contract, unchanged: opting in is what changes behaviour, not upgrading.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
|
|
db.RegisterReplicated("orders");
|
|
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
Assert.False(await NeedsSnapshot(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_SeedsAtHlcZero_AndFlagsSnapshot()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
var seeded = await db.QueryAsync(
|
|
"SELECT pk_json, hlc, node_id, is_tombstone FROM __localdb_row_version ORDER BY pk_json",
|
|
r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2), Tomb: r.GetInt64(3)));
|
|
|
|
Assert.Equal(2, seeded.Count);
|
|
Assert.Equal(["{\"id\":1}", "{\"id\":2}"], seeded.Select(s => s.Pk));
|
|
// HLC 0 is the floor: below every genuine stamp (physical ms << 16), so it can never beat one.
|
|
Assert.All(seeded, s => Assert.Equal(0, s.Hlc));
|
|
Assert.All(seeded, s => Assert.Equal(db.NodeId, s.Node));
|
|
Assert.All(seeded, s => Assert.Equal(0, s.Tomb));
|
|
|
|
// Without the flag the seeding would be inert: no gap exists at the handshake, so the peer
|
|
// would be told it needs no snapshot and the baselined rows would never travel.
|
|
Assert.True(await NeedsSnapshot(db));
|
|
// ...and it is flagged for THIS TABLE ONLY. Through 0.2.x this set the one database-wide
|
|
// bit, so baselining `orders` re-streamed every other registered table with it.
|
|
Assert.False(await NeedsFullSnapshot(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_FlagsOnlyTheBaselinedTable()
|
|
{
|
|
// The per-table flag's reason to exist: opting one table into replication late must not
|
|
// schedule a full re-stream of its unrelated neighbours.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W')");
|
|
|
|
db.RegisterReplicated("widgets");
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
Assert.True(await NeedsSnapshot(db, "orders"));
|
|
Assert.False(await NeedsSnapshot(db, "widgets"));
|
|
Assert.False(await NeedsFullSnapshot(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_DropsThePendingSnapshotFlag()
|
|
{
|
|
// A flag naming a table nothing streams any more can never clear itself — the streamer skips
|
|
// unregistered tables — so it would sit in the file and re-appear as unasked-for debt if the
|
|
// table were registered again.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
Assert.True(await NeedsSnapshot(db));
|
|
|
|
db.DeregisterReplicated("orders");
|
|
|
|
Assert.False(await NeedsSnapshot(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_IsIdempotent_AndDoesNotReflagSnapshot()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
// Stand in for the snapshot having been delivered and acknowledged.
|
|
await db.ExecuteAsync("DELETE FROM __localdb_snapshot_state");
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
Assert.Equal(1, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
// Nothing new was seeded, so no resync is owed: passing true every boot is free after the first.
|
|
Assert.False(await NeedsSnapshot(db));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_NeverOverwritesACapturedVersion()
|
|
{
|
|
// The LWW-safety core: a row with a real, captured version must keep it. A floor that
|
|
// clobbered genuine versions would make every baselined node look newer than its peer.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
db.RegisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
var captured = (await db.QueryAsync(
|
|
"SELECT hlc FROM __localdb_row_version WHERE pk_json='{\"id\":1}'", r => r.GetInt64(0)))[0];
|
|
Assert.True(captured > 0);
|
|
|
|
// A second, pre-existing row that capture never saw (inserted with triggers removed).
|
|
db.DeregisterReplicated("orders");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2,'B',2)");
|
|
await db.ExecuteAsync(
|
|
"INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone) " +
|
|
"VALUES ('orders', '{\"id\":1}', @h, 'genuine', 0)", new { h = captured });
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
var versions = await db.QueryAsync(
|
|
"SELECT pk_json, hlc, node_id FROM __localdb_row_version ORDER BY pk_json",
|
|
r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2)));
|
|
Assert.Equal(2, versions.Count);
|
|
Assert.Equal(captured, versions[0].Hlc);
|
|
Assert.Equal("genuine", versions[0].Node);
|
|
Assert.Equal(0, versions[1].Hlc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_SeedsPkJsonIdenticalToWhatCaptureWrites()
|
|
{
|
|
// A seeded key that differs by so much as a space from the capture triggers' json_object
|
|
// output would be a DIFFERENT ledger row, and the baseline would silently never merge.
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE kv (a INTEGER, b TEXT, v TEXT, PRIMARY KEY (a, b))");
|
|
await db.ExecuteAsync("INSERT INTO kv (a, b, v) VALUES (1, 'x', 'seeded')");
|
|
|
|
db.RegisterReplicated("kv", baselineExistingRows: true);
|
|
await db.ExecuteAsync("UPDATE kv SET v = 'captured' WHERE a = 1 AND b = 'x'");
|
|
|
|
// One ledger row, not two: the capture upsert landed on the seeded key.
|
|
var rows = await db.QueryAsync(
|
|
"SELECT pk_json, hlc FROM __localdb_row_version", r => (Pk: r.GetString(0), Hlc: r.GetInt64(1)));
|
|
Assert.Single(rows);
|
|
Assert.Equal("{\"a\":1,\"b\":\"x\"}", rows[0].Pk);
|
|
Assert.True(rows[0].Hlc > 0, "the genuine capture must have replaced the floor");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_FloorLosesToEveryGenuineRemoteWrite()
|
|
{
|
|
// Proved through the real LWW path rather than by reasoning about the comparison: an
|
|
// absurdly small remote HLC of 1 still beats the baseline floor of 0.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'BASELINE',1)");
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
var result = await new Replication.Internal.LwwApplier(db).ApplyBatchAsync(
|
|
[new Replication.Internal.OplogEntryRecord(
|
|
1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"REMOTE\",\"qty\":9}", 1, "peer", false)],
|
|
default);
|
|
|
|
Assert.Equal(1, result.Applied);
|
|
var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0));
|
|
Assert.Equal("REMOTE", sku[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_TieBetweenTwoBaselinedNodes_ResolvesByNodeIdOrdinal()
|
|
{
|
|
// Two nodes that baselined the same key both hold HLC 0 — genuinely unordered history. The
|
|
// node-id tie-break is what keeps that convergent instead of oscillating.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'MINE',1)");
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
var applier = new Replication.Internal.LwwApplier(db);
|
|
var lower = await applier.ApplyBatchAsync(
|
|
[new Replication.Internal.OplogEntryRecord(
|
|
1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"LOWER\",\"qty\":9}", 0, "!" + db.NodeId, false)],
|
|
default);
|
|
Assert.Equal(1, lower.Discarded);
|
|
|
|
var higher = await applier.ApplyBatchAsync(
|
|
[new Replication.Internal.OplogEntryRecord(
|
|
2, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"HIGHER\",\"qty\":9}", 0, db.NodeId + "z", false)],
|
|
default);
|
|
Assert.Equal(1, higher.Applied);
|
|
|
|
var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0));
|
|
Assert.Equal("HIGHER", sku[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WithBaseline_EmptyTable_SeedsNothing_FlagsNothing()
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
db.RegisterReplicated("orders", baselineExistingRows: true);
|
|
|
|
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
Assert.False(await NeedsSnapshot(db));
|
|
}
|
|
|
|
// ---- Deregistration and the HLC crash anchor ------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Deregister_FlushesHlcAnchor_SoAnUngracefulReopenCannotRegress()
|
|
{
|
|
// The clock recovers as MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc), and
|
|
// deregistering the only registered table empties both ledgers. Without the in-transaction
|
|
// flush, an ungraceful exit right afterwards leaves hlc_high_water at 0 and the reopened
|
|
// clock restarts from wall-clock time — below stamps the peer already holds.
|
|
var crashed = Create();
|
|
try
|
|
{
|
|
await CreateOrders(crashed);
|
|
crashed.RegisterReplicated("orders");
|
|
await crashed.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
|
|
|
// A peer HLC an hour ahead, merged as the applier would merge it: this is what makes the
|
|
// clock outrun wall-clock time, and therefore what a wall-clock restart would regress.
|
|
var peerHlc = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() << 16;
|
|
crashed.Clock.Observe(peerHlc);
|
|
var preDereg = crashed.Clock.Current;
|
|
Assert.True(preDereg >= peerHlc);
|
|
|
|
crashed.DeregisterReplicated("orders");
|
|
|
|
Assert.Equal(preDereg, await CountAsync(crashed, "SELECT hlc_high_water FROM __localdb_meta WHERE id = 1"));
|
|
|
|
// The evidence the clock would otherwise have recovered from is gone.
|
|
Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_oplog"));
|
|
Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_row_version"));
|
|
Assert.Equal(0, await CountAsync(crashed, "SELECT last_seen_hlc FROM __localdb_peer_state WHERE id = 1"));
|
|
|
|
// Ungraceful reopen: a second instance over the same file while the first was NEVER
|
|
// disposed, so nothing but the deregistration itself can have written the anchor.
|
|
using var reopened = Create();
|
|
Assert.True(reopened.Clock.Current >= preDereg,
|
|
$"recovered clock {reopened.Clock.Current} regressed below the pre-deregistration stamp {preDereg}");
|
|
Assert.True(reopened.Clock.Next() > preDereg);
|
|
}
|
|
finally
|
|
{
|
|
crashed.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deregister_HlcFlush_DoesNotCountAsWorkDone()
|
|
{
|
|
// The flush is an UPDATE on every call, including the ones that clean up nothing; it must
|
|
// not turn the "was there anything to do?" answer into a permanent true.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
|
|
Assert.False(db.DeregisterReplicated("orders"));
|
|
Assert.False(db.DeregisterReplicated("ghost"));
|
|
}
|
|
|
|
private static async Task<long> CountAsync(SqliteLocalDb db, string sql) =>
|
|
(await db.QueryAsync(sql, r => r.GetInt64(0)))[0];
|
|
|
|
/// <summary>Whether <paramref name="table"/> individually owes the peer a snapshot (schema v3).</summary>
|
|
private static async Task<bool> NeedsSnapshot(SqliteLocalDb db, string table = "orders") =>
|
|
(await db.QueryAsync(
|
|
"SELECT COUNT(*) FROM __localdb_snapshot_state WHERE table_name = @t AND needs_snapshot <> 0",
|
|
r => r.GetInt64(0), new { t = table }))[0] != 0;
|
|
|
|
/// <summary>
|
|
/// The DATABASE-WIDE flag — "every registered table". Baselining must never set it: that is the
|
|
/// whole point of the per-table flags, and setting it would restore the 0.2.x behaviour silently.
|
|
/// </summary>
|
|
private static async Task<bool> NeedsFullSnapshot(SqliteLocalDb db) =>
|
|
(await db.QueryAsync(
|
|
"SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", r => r.GetInt64(0)))[0] != 0;
|
|
}
|