fix(localdb): snapshot delivers FK-linked rows in any order (scadaproj#37)
The snapshot streamed tables by name and rows by pk_json text, so a child (or a self-reference whose parent's key sorts later) reached the receiver first, failed its immediate FK check and was dead-lettered - permanently, since the snapshot's watermark and ack covered it (LocalDb-002). Sender: stream referenced tables first (Kahn order, name tie-break). Receiver: SnapshotApplier defers rows that fail only SQLITE_CONSTRAINT_FOREIGNKEY, retries them as rows land, and dead-letters true orphans at SnapshotComplete. Delta path unchanged; wire unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -6,7 +6,15 @@ using ZB.MOM.WW.LocalDb.Registration;
|
||||
namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
|
||||
/// <summary>Outcome of applying one inbound batch. <see cref="AppliedThruSeq"/> is the batch's highest seq regardless of per-entry disposition, so the watermark advances even over discarded/dead-lettered entries.</summary>
|
||||
internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discarded, int DeadLettered);
|
||||
/// <remarks>
|
||||
/// <see cref="Deferred"/> is only ever non-empty when the caller asked for foreign-key violations to
|
||||
/// be deferred (the snapshot path): those entries were rolled back, NOT dead-lettered, and the caller
|
||||
/// owns retrying them.
|
||||
/// </remarks>
|
||||
internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discarded, int DeadLettered)
|
||||
{
|
||||
public IReadOnlyList<OplogEntryRecord> Deferred { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a peer's oplog batch under last-writer-wins in one transaction: the applying guard is
|
||||
@@ -35,7 +43,19 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
/// </summary>
|
||||
internal int LastBatchStatementCount { get; private set; }
|
||||
|
||||
public async Task<ApplyResult> ApplyBatchAsync(IReadOnlyList<OplogEntryRecord> entries, CancellationToken ct)
|
||||
/// <param name="entries">The batch, in apply order.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <param name="deferForeignKeyViolations">
|
||||
/// Snapshot path only (LocalDb-002). A snapshot can deliver a child row before its parent — a
|
||||
/// table that sorts earlier, a self-reference whose parent's key sorts later, or any order an
|
||||
/// older sender chose — and the delta path's rule (an SQLite error is per-entry poison) would
|
||||
/// dead-letter it permanently. With this set, an entry that fails ONLY its foreign-key check is
|
||||
/// rolled back and returned in <see cref="ApplyResult.Deferred"/> for the caller to retry once
|
||||
/// more of the snapshot has landed. Off for deltas: they arrive in commit order, so a parent
|
||||
/// always precedes its child there.
|
||||
/// </param>
|
||||
public async Task<ApplyResult> ApplyBatchAsync(
|
||||
IReadOnlyList<OplogEntryRecord> entries, CancellationToken ct, bool deferForeignKeyViolations = false)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
return new ApplyResult(0, 0, 0, 0);
|
||||
@@ -60,6 +80,7 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
var applied = 0;
|
||||
var discarded = 0;
|
||||
var deadLettered = 0;
|
||||
List<OplogEntryRecord>? deferred = null;
|
||||
|
||||
await using var tx = await db.BeginPreparedTransactionAsync(ct);
|
||||
using var sql = new PreparedStatements(tx.Connection, tx.Sqlite);
|
||||
@@ -120,10 +141,12 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
// statement, not the transaction, so the batch continues after dead-lettering.
|
||||
// Any other exception propagates with the savepoint still open, and disposing the
|
||||
// outer transaction unwinds the whole batch (fail-closed).
|
||||
var defer = deferForeignKeyViolations && IsForeignKeyViolation(ex);
|
||||
try
|
||||
{
|
||||
await sql.RollbackEntryAsync(ct);
|
||||
await sql.DeadLetterAsync(entry, ex.Message, utcNow, ct);
|
||||
if (!defer)
|
||||
await sql.DeadLetterAsync(entry, ex.Message, utcNow, ct);
|
||||
}
|
||||
catch (Exception recoveryFailure) when (recoveryFailure is not OperationCanceledException)
|
||||
{
|
||||
@@ -141,7 +164,10 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
ex, recoveryFailure);
|
||||
}
|
||||
|
||||
deadLettered++;
|
||||
if (defer)
|
||||
(deferred ??= []).Add(entry);
|
||||
else
|
||||
deadLettered++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +179,12 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
db.Clock.Observe(maxHlc);
|
||||
return new ApplyResult(maxSeq, applied, discarded, deadLettered);
|
||||
return new ApplyResult(maxSeq, applied, discarded, deadLettered) { Deferred = deferred ?? [] };
|
||||
}
|
||||
|
||||
/// <summary>SQLITE_CONSTRAINT_FOREIGNKEY (787): the statement failed its immediate FK check.</summary>
|
||||
private static bool IsForeignKeyViolation(SqliteException ex) => ex.SqliteExtendedErrorCode == 787;
|
||||
|
||||
/// <summary>
|
||||
/// The statement set one apply-batch runs on: each distinct statement is built and prepared on
|
||||
/// first use, then rebound and re-executed per row. Statements whose SQL depends on the table
|
||||
|
||||
@@ -76,9 +76,8 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
await send(new SyncMessage { SnapshotBegin = begin }, ct);
|
||||
|
||||
var totalRows = 0L;
|
||||
foreach (var table in db.ReplicatedTables.Values
|
||||
.Where(t => scope.Includes(t.Name))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
foreach (var table in await ParentsFirstAsync(
|
||||
db.ReplicatedTables.Values.Where(t => scope.Includes(t.Name)).ToList(), ct))
|
||||
{
|
||||
var sql = BuildPageSql(table);
|
||||
// Keyset cursor (pk_json > last seen), not LIMIT/OFFSET: concurrent inserts/prunes
|
||||
@@ -162,6 +161,40 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
return asOfSeq;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders the tables so every table is streamed after the tables it references by foreign key
|
||||
/// (LocalDb-002): name order put <c>a_child</c> before <c>z_parent</c>, and a receiver with
|
||||
/// immediate FK checks rejected the child. Kahn's algorithm with name order as the tie-break, so
|
||||
/// unrelated tables keep their old relative order; self-references are ignored (the receiver
|
||||
/// defers those rows), and a reference cycle falls back to name order for its members. Purely a
|
||||
/// sender-side choice — the wire is unchanged and any order is still correct at a 0.3.6+
|
||||
/// receiver, which defers FK-failed snapshot rows instead of dead-lettering them.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ReplicatedTable>> ParentsFirstAsync(List<ReplicatedTable> tables, CancellationToken ct)
|
||||
{
|
||||
var byName = tables.ToDictionary(t => t.Name, StringComparer.Ordinal);
|
||||
var parents = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var referenced = await db.QueryAsync(
|
||||
"SELECT DISTINCT \"table\" FROM pragma_foreign_key_list(@t)", r => r.GetString(0), new { t = table.Name }, ct);
|
||||
parents[table.Name] = referenced
|
||||
.Where(p => byName.ContainsKey(p) && !string.Equals(p, table.Name, StringComparison.Ordinal))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var ordered = new List<ReplicatedTable>(tables.Count);
|
||||
var remaining = new SortedSet<string>(byName.Keys, StringComparer.Ordinal);
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var next = remaining.FirstOrDefault(n => !parents[n].Overlaps(remaining))
|
||||
?? remaining.Min!; // a cycle: break it in name order
|
||||
ordered.Add(byName[next]);
|
||||
remaining.Remove(next);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialized bytes one row contributes to its batch message: the row's own encoding plus the
|
||||
/// repeated field's tag and length prefix, as protobuf itself computes them.
|
||||
@@ -221,6 +254,12 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
private long _maxHlc;
|
||||
private bool _partial;
|
||||
|
||||
// Snapshot rows whose foreign-key parent had not arrived yet (LocalDb-002). Held for the life of
|
||||
// one snapshot receive: retried as more rows land, and dead-lettered at SnapshotComplete only if
|
||||
// their parent never came. A session that ends mid-snapshot drops them, which is safe — the
|
||||
// sender's debt clears only on the SnapshotAck, so the whole snapshot is re-streamed.
|
||||
private List<OplogEntryRecord> _pending = [];
|
||||
|
||||
// db is part of the collaborator's canonical shape (mirrors the streamer) even though the merge
|
||||
// reaches the database through the applier and store; kept for construction symmetry in the factory.
|
||||
public SnapshotApplier(SqliteLocalDb db, LwwApplier applier, OplogStore store, ILogger logger)
|
||||
@@ -236,6 +275,7 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
_asOfSeq = begin.AsOfSeq;
|
||||
_rows = 0;
|
||||
_maxHlc = 0;
|
||||
_pending = [];
|
||||
// A non-empty table list scopes the snapshot; empty (which is all a pre-0.3.0 sender can
|
||||
// emit) means every registered table. Read here rather than on complete because
|
||||
// SnapshotComplete does not repeat it — the receive loop guarantees a Begin precedes both.
|
||||
@@ -258,8 +298,31 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
entries.Add(new OplogEntryRecord(
|
||||
0, batch.TableName, row.PkJson, row.HasRowJson ? row.RowJson : null, row.Hlc, row.NodeId, row.IsTombstone));
|
||||
}
|
||||
await _applier.ApplyBatchAsync(entries, ct);
|
||||
var result = await _applier.ApplyBatchAsync(entries, ct, deferForeignKeyViolations: true);
|
||||
_rows += batch.Rows.Count;
|
||||
_pending.AddRange(result.Deferred);
|
||||
|
||||
// Something landed, so a deferred child may now have its parent.
|
||||
if (result.Applied > 0 && _pending.Count > 0)
|
||||
await RetryPendingAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the rows deferred on a foreign-key violation until a pass makes no progress. A pass
|
||||
/// that applies anything may unblock another deferred row (a chain of self-references, a parent
|
||||
/// tombstone waiting for its child's), hence the loop; it ends because every pass that continues
|
||||
/// shrinks the list.
|
||||
/// </summary>
|
||||
private async Task RetryPendingAsync(CancellationToken ct)
|
||||
{
|
||||
while (_pending.Count > 0)
|
||||
{
|
||||
var result = await _applier.ApplyBatchAsync(_pending, ct, deferForeignKeyViolations: true);
|
||||
var progressed = result.Deferred.Count < _pending.Count;
|
||||
_pending = result.Deferred.ToList();
|
||||
if (!progressed)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct)
|
||||
@@ -272,6 +335,25 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
// has stamped. The sender makes the mirror-image concession: it does not advance its own
|
||||
// sent-thru watermark past a partial snapshot, so those deltas are still pumped normally
|
||||
// and merely re-deliver rows the snapshot already carried, which LWW absorbs.
|
||||
// Rows still deferred have no parent anywhere in the snapshot: true orphans. One last pass
|
||||
// with deferral off applies anything that became valid and dead-letters the rest, so they
|
||||
// are visible in __localdb_dead_letter instead of silently missing.
|
||||
if (_pending.Count > 0)
|
||||
{
|
||||
await RetryPendingAsync(ct);
|
||||
if (_pending.Count > 0)
|
||||
{
|
||||
var final = await _applier.ApplyBatchAsync(_pending, ct);
|
||||
if (final.DeadLettered > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Snapshot receive dead-lettered {Count} row(s) whose foreign-key parent never arrived.",
|
||||
final.DeadLettered);
|
||||
}
|
||||
_pending = [];
|
||||
}
|
||||
}
|
||||
|
||||
var seq = _partial ? 0 : complete.AsOfSeq;
|
||||
// SetLastAppliedRemoteSeqAsync is MAX-guarded, so a delta applied mid-snapshot with a seq
|
||||
// above the snapshot's as-of (or an HLC above what the snapshot carried) is never regressed.
|
||||
|
||||
Reference in New Issue
Block a user