Files
scadaproj/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/RegistrationTests.cs
T
Joseph Doherty 122940c0e4 fix(localdb): refuse cascading-FK parents at registration; make an entry's row+ledger writes atomic (0.3.3, ScadaBridge arch-review P4-7)
Two latent apply-path hazards from the ScadaBridge adversarial architecture
review. Neither has a live trigger in any current consumer — ScadaBridge's ten
replicated tables carry no foreign keys at all (verified against the running rig's
site-localdb.db) and OtOpcUa's LocalDb tables carry none either — but both are the
kind that surface as silent divergence rather than as an error, so they are closed
before a consumer's schema grows into them.

Wire-compatible: no proto change, no lib_schema_version bump, no on-disk schema
change. No consumer code change.

- RegisterReplicated now refuses a table that is the PARENT of a foreign key with
  a row-mutating ON DELETE action (CASCADE / SET NULL / SET DEFAULT), naming the
  offending child column and action. The apply path writes remote rows with
  INSERT OR REPLACE, whose PK-conflict resolution DELETES the existing row first;
  connections run PRAGMA foreign_keys=ON, so that implicit delete fires the child's
  ON DELETE action while the capture guard is raised. The child rows would change
  on the applying node only, produce no oplog entry, and diverge the pair with
  nothing able to detect it — LWW compares only rows it was told about. RESTRICT /
  NO ACTION stay allowed: they abort the statement instead of mutating rows behind
  capture's back, which the apply path already handles as per-entry poison. The
  check inspects every foreign key in the database and runs at registration;
  SQLite cannot add a foreign key to an existing table, so a child table created
  later is caught at the next startup's registration, not at the DDL. Documented
  on ILocalDb.RegisterReplicated and in the README's limitations list.

- An entry's row write and its ledger write are now atomic with each other. They
  are two statements and SQLite aborts only the failing one, so a statement-level
  fault between them — the case the per-entry poison catch exists for — committed
  a half-applied entry: a tombstone whose table row was gone while
  __localdb_row_version still called it live (SnapshotStreamer can only warn and
  skip that, so the peer never learns the row died), or an upsert whose new row
  carried the OLD version and therefore lost the next comparison it should have
  won. The pair now runs inside a per-entry SAVEPOINT, rolled back (and released,
  so nothing nests) before dead-lettering, leaving both writes or neither while
  the rest of the batch proceeds and commits.

Every new test was run against the unfixed code first and fails there with exactly
the divergence shape described: the tombstone case reads back null instead of the
live row, the upsert case reads back the remote row against an unchanged ledger,
and the registration cases throw nothing.

Suite: 213/213 green, build 0 warnings / 0 errors.
2026-08-20 08:40:14 -04:00

623 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_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;
}