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.
This commit is contained in:
@@ -12,7 +12,8 @@ internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discard
|
||||
/// Applies a peer's oplog batch under last-writer-wins in one transaction: the applying guard is
|
||||
/// raised so capture triggers skip these writes, per-entry LWW resolves against the local
|
||||
/// row-version (hlc, then node_id ordinal tie-break), poison rows are dead-lettered while the
|
||||
/// batch continues, and the remote watermark advances in the SAME transaction as the data so a
|
||||
/// batch continues — each entry's row write and ledger write sharing a savepoint so a dead-lettered
|
||||
/// entry leaves neither — and the remote watermark advances in the SAME transaction as the data so a
|
||||
/// crash-replay of the batch is a no-op (LWW's strict-greater compare loses on equal versions).
|
||||
/// This never writes <c>__localdb_oplog</c>: the 2-node topology does not re-forward applied deltas.
|
||||
/// </summary>
|
||||
@@ -88,15 +89,25 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
continue;
|
||||
}
|
||||
|
||||
var table = db.ReplicatedTables[entry.TableName];
|
||||
|
||||
// An entry is TWO statements — the row change and its ledger row — and SQLite aborts
|
||||
// only the failing statement, so a fault between them used to commit a half-applied
|
||||
// entry: a tombstone whose table row is gone while __localdb_row_version still calls it
|
||||
// live (which SnapshotStreamer can only warn about and skip, so the peer never learns
|
||||
// the row died), or an upsert whose new row carries the OLD version and therefore loses
|
||||
// the next LWW comparison it should win. A per-entry savepoint makes the pair
|
||||
// all-or-nothing without widening the poison catch to the whole batch.
|
||||
await sql.EnterEntryAsync(ct);
|
||||
try
|
||||
{
|
||||
var table = db.ReplicatedTables[entry.TableName];
|
||||
if (entry.IsTombstone)
|
||||
await sql.DeleteAsync(table, entry.PkJson, ct);
|
||||
else
|
||||
await sql.UpsertAsync(table, entry.RowJson!, ct);
|
||||
|
||||
await sql.WriteRowVersionAsync(entry, utcNow, ct);
|
||||
await sql.ReleaseEntryAsync(ct);
|
||||
applied++;
|
||||
}
|
||||
catch (SqliteException ex)
|
||||
@@ -104,7 +115,9 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
// Deliberately narrow: only statement-level SQLite errors (e.g. json_extract on
|
||||
// malformed row_json) are treated as per-entry poison — SQLite aborts just the
|
||||
// statement, not the transaction, so the batch continues after dead-lettering.
|
||||
// Any other exception propagates and rolls back the whole batch (fail-closed).
|
||||
// Any other exception propagates with the savepoint still open, and disposing the
|
||||
// outer transaction unwinds the whole batch (fail-closed).
|
||||
await sql.RollbackEntryAsync(ct);
|
||||
await sql.DeadLetterAsync(entry, ex.Message, utcNow, ct);
|
||||
deadLettered++;
|
||||
}
|
||||
@@ -138,7 +151,13 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
private readonly Dictionary<string, SqliteCommand> _deletes = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, SqliteCommand> _upserts = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Name of the per-entry savepoint. Fixed: only one is ever open at a time.</summary>
|
||||
private const string EntrySavepoint = "zb_lww_entry";
|
||||
|
||||
private SqliteCommand? _applying;
|
||||
private SqliteCommand? _enterEntry;
|
||||
private SqliteCommand? _releaseEntry;
|
||||
private SqliteCommand? _rollbackEntry;
|
||||
private SqliteCommand? _lookup;
|
||||
private SqliteCommand? _rowVersion;
|
||||
private SqliteCommand? _deadLetter;
|
||||
@@ -155,6 +174,32 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Opens the per-entry savepoint inside the batch transaction.</summary>
|
||||
public Task EnterEntryAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _enterEntry ??= Create($"SAVEPOINT {EntrySavepoint}");
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Commits the entry's work into the batch transaction (pops the savepoint).</summary>
|
||||
public Task ReleaseEntryAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _releaseEntry ??= Create($"RELEASE {EntrySavepoint}");
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undoes everything the entry did and pops the savepoint, leaving the batch transaction
|
||||
/// open and usable. Two statements, because ROLLBACK TO rewinds to the savepoint but leaves
|
||||
/// it ON the stack — without the RELEASE, every dead-lettered entry would nest another one.
|
||||
/// </summary>
|
||||
public async Task RollbackEntryAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _rollbackEntry ??= Create($"ROLLBACK TO {EntrySavepoint}");
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await ReleaseEntryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(long Hlc, string NodeId)?> ReadLocalVersionAsync(
|
||||
string tableName, string pkJson, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -38,8 +38,20 @@ public interface ILocalDb
|
||||
/// schema digest, and installs the three AFTER triggers that capture every change into the
|
||||
/// oplog + row-version tables within the same transaction as the consumer's write. Idempotent
|
||||
/// (re-registering regenerates the triggers). Throws <see cref="LocalDbRegistrationException"/>
|
||||
/// if the name is unsafe, the table is missing, or it has no primary key.
|
||||
/// if the name is unsafe, the table is missing, it has no primary key, or it is the parent of a
|
||||
/// foreign key whose <c>ON DELETE</c> action mutates the child (<c>CASCADE</c> / <c>SET NULL</c> /
|
||||
/// <c>SET DEFAULT</c>) — see the remarks for why that last one cannot be replicated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Replicated tables may not be the parent of a cascading foreign key.</b> The apply path
|
||||
/// writes remote rows with <c>INSERT OR REPLACE</c>; REPLACE deletes the conflicting row first,
|
||||
/// and with <c>PRAGMA foreign_keys=ON</c> that delete fires the child's <c>ON DELETE</c> action
|
||||
/// while change capture is suppressed. The child rows would be mutated on the applying node only,
|
||||
/// never captured and never shipped, diverging the pair with nothing able to detect it. The check
|
||||
/// runs at registration and inspects the whole database's foreign keys; SQLite cannot add a
|
||||
/// foreign key to an existing table, so a later-created child table is caught on the next
|
||||
/// registration (i.e. the next startup), not at the moment of the DDL.
|
||||
/// </remarks>
|
||||
ReplicatedTable RegisterReplicated(string tableName);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -199,6 +199,8 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
throw new LocalDbRegistrationException(
|
||||
$"Cannot register table '{tableName}': replication requires an explicit primary key.");
|
||||
|
||||
EnsureNoInboundActionOnDelete(tableName);
|
||||
|
||||
var allColumns = columns.Select(c => (c.Name, c.Type)).ToList();
|
||||
var digest = ReplicatedTable.ComputeDigest(tableName, pkColumns, allColumns);
|
||||
var table = new ReplicatedTable(tableName, pkColumns, allColumns, digest);
|
||||
@@ -293,6 +295,66 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses to register a table that is the PARENT of a foreign key whose <c>ON DELETE</c> action
|
||||
/// mutates the child (<c>CASCADE</c>, <c>SET NULL</c>, <c>SET DEFAULT</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is a replication hazard, not a schema opinion.</b> The apply path writes a remote
|
||||
/// row with <c>INSERT OR REPLACE</c>, and REPLACE resolves a primary-key conflict by DELETING the
|
||||
/// existing row before inserting the new one. Connections run <c>PRAGMA foreign_keys=ON</c>, and
|
||||
/// that implicit delete fires foreign-key actions (verified behaviour, not inference). It runs
|
||||
/// with the capture guard raised, so the resulting child-row deletes/nulls are invisible to the
|
||||
/// oplog: they happen on this node and are never shipped. The peer, applying the same parent row,
|
||||
/// keeps its children — and the pair has silently diverged with nothing to detect it, since LWW
|
||||
/// only ever compares rows it was told about.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Checked at registration only.</b> SQLite cannot add a foreign key to an existing table, so
|
||||
/// a new offender can only arrive as a child table created (or a parent table rebuilt) after this
|
||||
/// call. Consumers register their tables at startup, after schema creation/migration, which is
|
||||
/// where the check belongs; a schema change that adds a cascading child to an already-registered
|
||||
/// parent is caught on the next boot, not at the moment of the DDL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>RESTRICT</c> / <c>NO ACTION</c> are deliberately allowed: they abort the offending statement
|
||||
/// with a constraint error rather than mutating rows behind capture's back, which the apply path
|
||||
/// already handles as per-entry poison.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void EnsureNoInboundActionOnDelete(string tableName)
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
using (var cmd = _master.CreateCommand())
|
||||
{
|
||||
// pragma_foreign_key_list is the table-valued form of PRAGMA foreign_key_list, so the
|
||||
// whole database is one query: fk."table" is the PARENT this child points at.
|
||||
cmd.CommandText =
|
||||
"SELECT m.name, fk.\"from\", fk.\"on_delete\" " +
|
||||
"FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) fk " +
|
||||
"WHERE m.type = 'table' AND fk.\"table\" = $t COLLATE NOCASE " +
|
||||
"AND UPPER(fk.\"on_delete\") IN ('CASCADE', 'SET NULL', 'SET DEFAULT') " +
|
||||
"ORDER BY m.name, fk.\"from\"";
|
||||
cmd.Parameters.AddWithValue("$t", tableName);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
offenders.Add(
|
||||
$"{reader.GetString(0)}.{reader.GetString(1)} ON DELETE {reader.GetString(2).ToUpperInvariant()}");
|
||||
}
|
||||
|
||||
if (offenders.Count == 0)
|
||||
return;
|
||||
|
||||
throw new LocalDbRegistrationException(
|
||||
$"Cannot register table '{tableName}': it is the parent of a foreign key with a row-mutating " +
|
||||
$"ON DELETE action ({string.Join(", ", offenders)}). Replication applies remote rows with " +
|
||||
"INSERT OR REPLACE, whose implicit delete fires that action with change capture suppressed — " +
|
||||
"the child rows would be changed on this node only and never replicated, diverging the pair " +
|
||||
"undetectably. Use ON DELETE NO ACTION/RESTRICT and delete children explicitly, or leave this " +
|
||||
"table unreplicated.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <c>__localdb_row_version</c> at the baseline floor for every row of
|
||||
/// <paramref name="table"/> that has no ledger entry, and flags a snapshot resync if it seeded
|
||||
|
||||
Reference in New Issue
Block a user