[LocalDb] INSERT OR REPLACE on apply deletes a different row via a secondary UNIQUE (permanent divergence, latent) #38
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the scadaproj#15 shared-library review (
code-reviews/LocalDb/findings.md, LocalDb-003), verified at91fb2b4. Update the finding's Resolution row when fixed.ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/LwwApplier.cs:340Description
BuildUpsertemitsINSERT OR REPLACE INTO <table> (...). When the incoming row collides with alocal row on a non-PK
UNIQUEconstraint, REPLACE deletes that other row, which has a differentprimary key. The deletion:
recursive_triggersis off anyway);__localdb_row_versionrow;LWW only ever compares versions of the key it was handed, so nothing detects the change.
RegisterReplicated(SqliteLocalDb.cs:170) checks PKs, BLOBs and cascading FKs, but notUNIQUEindexes.
Failure scenario
Latent today: the ScadaBridge site tables registered for replication declare no secondary
UNIQUEconstraint (checked 2026-11-09). Reproduced in a scratch test:
t(id INTEGER PRIMARY KEY, code TEXT UNIQUE)and register it on both nodes.(1,'X')on A and(2,'X')on B, for example the same tag orname created on each node during a partition.
A applies
(2,'X'), and REPLACE drops row 1. B applies(1,'X'), and REPLACE drops row 2. Finalstate: A =
{2}, B ={1}. Each row is applied (applied=1, dl=0) and A's ledger still says{"id":1}is live. The pair is permanently diverged. Any later snapshot only logs "ledger/tabledivergence" and skips the row (
SnapshotStreamer.cs:114).Recommendation
Reject, or at least warn on, registration of tables that have non-PK
UNIQUEindexes orconstraints (
pragma_index_listwhereorigin IN ('u')and the index is not the PK). Alternatively,apply with an explicit
INSERT … ON CONFLICT(<pk cols>) DO UPDATE SET …. A unique conflict onanother column then aborts and becomes a visible dead-letter instead of a silent delete. Document
that secondary unique keys must be globally unique across both nodes.
Resolution
Unresolved.
Fixed on main
15a6c7c(LocalDb-003 → Resolved): rows apply withINSERT … ON CONFLICT(<pk>) DO UPDATE; a clash on another UNIQUE column becomes a visible dead-letter instead of a silent delete (the pair still differs until someone resolves it — documented). Bonus: a remote update of a parent with ON DELETE RESTRICT children now applies (was dead-lettered); also de-flaked WipedPeer_SnapshotLiftsItsWatermark. Only effective once both nodes run 0.3.6. Evidence: LocalDb 236/236; Auth 344 passed / 78 skipped (live SQL/LDAP gated) after rebase; code-reviews --check consistent.