fix(localdb): applier review fixes — null-row_json guard, clock recovery via last_seen_hlc, fidelity + guard tests
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -10,12 +10,15 @@ public sealed class LwwApplierTests : IDisposable
|
||||
private const string Pk1 = "{\"id\":1}";
|
||||
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
private readonly string _path2 = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(_path))
|
||||
File.Delete(_path);
|
||||
if (File.Exists(_path2))
|
||||
File.Delete(_path2);
|
||||
}
|
||||
|
||||
private async Task<SqliteLocalDb> NewOrdersDb()
|
||||
@@ -305,4 +308,117 @@ public sealed class LwwApplierTests : IDisposable
|
||||
Assert.NotNull(peer[0].Utc);
|
||||
Assert.EndsWith("Z", peer[0].Utc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_NullRowJsonNonTombstone_DeadLettered_NoPhantomRow()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'LOCAL', 5)");
|
||||
|
||||
var result = await new LwwApplier(db).ApplyBatchAsync(
|
||||
[Entry(60, "{\"id\":2}", null, 9_000_000_000_000_000, "remote-node", tombstone: false)], default);
|
||||
|
||||
Assert.Equal(1, result.DeadLettered);
|
||||
Assert.Equal(0, result.Applied);
|
||||
|
||||
var dead = await db.QueryAsync(
|
||||
"SELECT pk_json, error FROM __localdb_dead_letter",
|
||||
x => (Pk: x.GetString(0), Err: x.GetString(1)));
|
||||
Assert.Single(dead);
|
||||
Assert.Equal("{\"id\":2}", dead[0].Pk);
|
||||
Assert.Contains("null row_json", dead[0].Err);
|
||||
|
||||
// No phantom auto-rowid row: the table still holds exactly the one pre-existing row.
|
||||
var rows = await db.QueryAsync("SELECT COUNT(*) FROM orders", x => x.GetInt64(0));
|
||||
Assert.Equal(1, rows[0]);
|
||||
Assert.Null(await ReadOrder(db, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_RoundTrip_PreservesRealAndBigIntegerExactly()
|
||||
{
|
||||
const double real17 = 1.2345678901234567; // 17 significant digits
|
||||
const long bigInt = 9_007_199_254_740_993; // 2^53 + 1: not representable as a double
|
||||
|
||||
using var source = new SqliteLocalDb(new LocalDbOptions { Path = _path });
|
||||
await source.ExecuteAsync("CREATE TABLE meas (id INTEGER PRIMARY KEY, r REAL, big INTEGER)");
|
||||
source.RegisterReplicated("meas");
|
||||
await source.ExecuteAsync(
|
||||
"INSERT INTO meas (id, r, big) VALUES (1, @r, @big)", new { r = real17, big = bigInt });
|
||||
|
||||
var captured = await source.QueryAsync(
|
||||
"SELECT seq, table_name, pk_json, row_json, hlc, node_id, is_tombstone FROM __localdb_oplog",
|
||||
r => new OplogEntryRecord(
|
||||
r.GetInt64(0), r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3),
|
||||
r.GetInt64(4), r.GetString(5), r.GetInt64(6) != 0));
|
||||
Assert.Single(captured);
|
||||
|
||||
using var target = new SqliteLocalDb(new LocalDbOptions { Path = _path2 });
|
||||
await target.ExecuteAsync("CREATE TABLE meas (id INTEGER PRIMARY KEY, r REAL, big INTEGER)");
|
||||
target.RegisterReplicated("meas");
|
||||
|
||||
var result = await new LwwApplier(target).ApplyBatchAsync(captured, default);
|
||||
Assert.Equal(1, result.Applied);
|
||||
|
||||
var applied = await target.QueryAsync(
|
||||
"SELECT r, big FROM meas WHERE id = 1", x => (R: x.GetDouble(0), Big: x.GetInt64(1)));
|
||||
Assert.Equal(BitConverter.DoubleToInt64Bits(real17), BitConverter.DoubleToInt64Bits(applied[0].R));
|
||||
Assert.Equal(bigInt, applied[0].Big);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_GuardAlwaysLoweredAfterBatch()
|
||||
{
|
||||
// The abort/rollback path needs no separate coverage: applying = 1 is set INSIDE the
|
||||
// transaction, so any rollback restores it to 0 by SQLite transaction semantics.
|
||||
using var db = await NewOrdersDb();
|
||||
|
||||
await new LwwApplier(db).ApplyBatchAsync(
|
||||
[Entry(1, Pk1, "{\"id\":1,\"sku\":\"R\",\"qty\":1}", 5_000_000, "remote-node")], default);
|
||||
|
||||
var guard = await db.QueryAsync(
|
||||
"SELECT applying FROM __localdb_applying WHERE id = 1", x => x.GetInt64(0));
|
||||
Assert.Equal(0, guard[0]);
|
||||
|
||||
// A subsequent consumer write must capture normally — proof the guard is really down.
|
||||
var before = await OplogCount(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'LOCAL', 2)");
|
||||
Assert.Equal(before + 1, await OplogCount(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_SameTable_TombstoneAndUpsert_OneBatch()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'LOCAL', 5)");
|
||||
var localHlc = await RowVersionHlc(db, Pk1);
|
||||
|
||||
var result = await new LwwApplier(db).ApplyBatchAsync(
|
||||
[
|
||||
Entry(70, Pk1, null, localHlc + 1000, "remote-node", tombstone: true),
|
||||
Entry(71, "{\"id\":2}", "{\"id\":2,\"sku\":\"NEW\",\"qty\":7}", localHlc + 1001, "remote-node"),
|
||||
], default);
|
||||
|
||||
Assert.Equal(2, result.Applied);
|
||||
Assert.Null(await ReadOrder(db, 1));
|
||||
Assert.Equal(("NEW", (long?)7), await ReadOrder(db, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ReplaceExistingRow_DoesNotFireCaptureTriggers()
|
||||
{
|
||||
// INSERT OR REPLACE over an existing row also exercises the delete side of REPLACE;
|
||||
// neither half may reach the capture triggers.
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'LOCAL', 5)");
|
||||
var localHlc = await RowVersionHlc(db, Pk1);
|
||||
var before = await OplogCount(db);
|
||||
|
||||
var result = await new LwwApplier(db).ApplyBatchAsync(
|
||||
[Entry(80, Pk1, "{\"id\":1,\"sku\":\"REPL\",\"qty\":6}", localHlc + 1000, "remote-node")], default);
|
||||
|
||||
Assert.Equal(1, result.Applied);
|
||||
Assert.Equal(("REPL", (long?)6), await ReadOrder(db, 1));
|
||||
Assert.Equal(before, await OplogCount(db));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user