9663279594
Three review follow-ups on122940c. Still 0.3.3 — unpublished, so no further version bump. No behaviour change to any succeeding path. - The per-entry poison handler's recovery work (ROLLBACK TO + the dead-letter INSERT) was unguarded: a wedged connection, an I/O error, or a dead-letter INSERT that faults would propagate the SECOND exception and silently discard the FIRST — the one that says why the entry was poison at all. The pair is now wrapped; a recovery failure throws an AggregateException whose message names the entry (table + seq) and the recovery fault, and whose InnerException is the ORIGINAL entry fault, with the recovery fault carried alongside it at InnerExceptions[1] with its own stack. The outcome is unchanged and still fail-closed: nothing commits, and disposing the batch transaction unwinds it. OperationCanceledException is deliberately excluded from the wrap so a shutdown still surfaces as a cancellation rather than as a fault. No upstream caller catches SqliteException from the apply path (checked), so widening the escaping type is safe. Injected cheaply after all: a RAISE(ABORT) trigger on __localdb_dead_letter, stacked on the existing row-version poison trigger, produces exactly the two-fault case with no production test seam. The test asserts both causes are carried AND that the batch committed nothing — the entry that applied cleanly before the poison one is gone and the peer watermark never moved. - Register_SelfReferencingCascade_Throws pins the README's "or from itself" claim: a table that is its own cascading parent (parent_id REFERENCES nodes(id) ON DELETE CASCADE) is refused. The guard needed no special case — it reads every foreign key in the database — but the promise is now tested. - Comment on why EnterEntryAsync sits outside the try: a SAVEPOINT that failed opened nothing, so a handler ROLLBACK TO would name a savepoint that does not exist and fault in turn. Both new tests were run against122940cfirst and fail there: the self-reference case throws nothing, and the stacked-fault case escapes as a bare SqliteException("dead-letter write failed") with the original cause gone. Suite: 215/215 green, build 0 warnings / 0 errors.
640 lines
26 KiB
C#
640 lines
26 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_CascadeDeleteParent_Throws()
|
|
{
|
|
// The apply path writes remote rows with INSERT OR REPLACE, whose implicit delete fires
|
|
// ON DELETE CASCADE with capture suppressed — the child rows would die on this node only.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " +
|
|
"order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)");
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("orders"));
|
|
Assert.Contains("orders", ex.Message);
|
|
Assert.Contains("order_lines.order_id", ex.Message);
|
|
Assert.Contains("ON DELETE CASCADE", ex.Message);
|
|
|
|
// Refused, not half-registered.
|
|
Assert.Empty(await Triggers(db));
|
|
Assert.False(db.ReplicatedTables.ContainsKey("orders"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_CascadeDeleteParent_IsWhatWouldActuallyDiverge()
|
|
{
|
|
// Proves the guard is guarding something real: with the parent NOT registered, applying a
|
|
// replace to it silently wipes child rows that produce no oplog entry.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " +
|
|
"order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)");
|
|
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 1)");
|
|
await db.ExecuteAsync("INSERT INTO order_lines (id, order_id, sku) VALUES (10, 1, 'L')");
|
|
|
|
await db.ExecuteAsync("INSERT OR REPLACE INTO orders (id, sku, qty) VALUES (1, 'B', 2)");
|
|
|
|
var lines = await db.QueryAsync("SELECT COUNT(*) FROM order_lines", r => r.GetInt64(0));
|
|
Assert.Equal(0, lines[0]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("SET NULL")]
|
|
[InlineData("SET DEFAULT")]
|
|
public async Task Register_RowMutatingOnDeleteParent_Throws(string action)
|
|
{
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " +
|
|
$"order_id INTEGER DEFAULT 0 REFERENCES orders(id) ON DELETE {action}, sku TEXT)");
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("orders"));
|
|
Assert.Contains($"ON DELETE {action}", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_NonMutatingOnDeleteParent_IsAllowed()
|
|
{
|
|
// RESTRICT/NO ACTION abort the statement instead of changing rows behind capture's back,
|
|
// which the apply path already handles as per-entry poison.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " +
|
|
"order_id INTEGER REFERENCES orders(id) ON DELETE RESTRICT, sku TEXT)");
|
|
|
|
db.RegisterReplicated("orders");
|
|
|
|
Assert.Equal(3, (await Triggers(db)).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_SelfReferencingCascade_Throws()
|
|
{
|
|
// A table can be its own cascading parent: replacing a row would delete that row's
|
|
// children in the SAME table, uncaptured. The guard reads every foreign key in the
|
|
// database, so it does not need a special case — but the README promises this, so it is
|
|
// pinned.
|
|
using var db = Create();
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE nodes (id INTEGER PRIMARY KEY, " +
|
|
"parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE, name TEXT)");
|
|
|
|
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("nodes"));
|
|
Assert.Contains("nodes.parent_id", ex.Message);
|
|
Assert.Contains("ON DELETE CASCADE", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_CascadeDeleteChild_IsAllowed()
|
|
{
|
|
// Only the PARENT side is hazardous: replacing a child row deletes no other table's rows.
|
|
using var db = Create();
|
|
await CreateOrders(db);
|
|
await db.ExecuteAsync(
|
|
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, " +
|
|
"order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, sku TEXT)");
|
|
|
|
var table = db.RegisterReplicated("order_lines");
|
|
|
|
Assert.Equal("order_lines", table.Name);
|
|
}
|
|
|
|
[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;
|
|
}
|