You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[tx] Updating the same indexed record twice in one transaction permanently corrupts the index #4935
Location:LocalDatabase.java:1068-1078 (eager per-save index diffing in the tx-active branch), TransactionIndexContext.java:296-368 (addIndexKeyLock, no cross-key reconciliation), index/lsm/LSMTreeIndex.java:437,515.
In the tx-active branch of updateRecord(), every save() calls indexer.updateDocument(originalRecord, document) where originalRecord is always rebuilt from the records **committed** buffer (serialization is deferred, so the buffer never advances inside the tx). Save #1 buffers REMOVE(orig), ADD(mid); save #2 buffers REMOVE(orig), ADD(final). ADD(mid)is never cancelled and at commitputReplaypersists **both**midandfinal`.
Repro (fails on main): unique LSM index on email; commit orig; in one tx doc.set("email","mid"); doc.save(); doc.set("email","final"); doc.save(); commit. Then:
lookupByKey(email=mid) returns the record (whose email is final) - a phantom entry;
a later insert of another record with email=mid`` throws DuplicatedKeyException ... already assigned to #x at `TransactionIndexContext.checkUniqueIndexKeys:404` - the unique constraint blocks a legitimate value forever.
Reachable from ordinary SQL: UPDATE ... SET x=1; UPDATE ... SET x=2 in one tx, or any script/graph mutation that saves twice.
Fix: compute the index diff against the last-buffered indexed state per RID (track per-tx "current indexed keys" per record in TransactionIndexContext), or drop the eager save-time updateDocument and buffer during phase 1. Add the repro above as a regression test.
Filed from an internal engine deep-audit (2026-07). Traced to source; verify against current main before fixing and add a regression test.
The in-transaction index update now diffs the second and subsequent saves of the same record against a per-RID snapshot of the previous in-transaction indexed state, instead of the committed buffer (which stays frozen until commit because serialization is deferred). The intermediate ADD is therefore cancelled and no phantom entry survives.
Performance notes (all benchmarked against the pre-fix baseline on 50k-record bulk single-update transactions): the snapshot stores only the indexed property values, is built inside updateDocument reusing the key values already extracted for the diff (no second extraction), is refreshed only when an index entry actually changed (bulk updates of non-indexed properties pay zero snapshot cost), and shares updatedRecords' lifecycle (released at commit 1st phase / rollback / reset). Net cost: parity with pre-fix, within measurement noise.
Regression tests: double/triple updates on unique and non-unique indexes, composite keys, LIST BY ITEM deltas asserted directly against the index content, in-place container mutation, change/no-change/change sequences, and update-then-delete in the same transaction.
Activity
perf(#4935): snapshot only the indexed property values instead of det…
perf(#4935): skip the snapshot refresh when no index actually changed
perf(#4935): build the snapshot inside updateDocument, reusing the ex…
perf(#4935): release the snapshot map at every updatedRecords lifecyc…
fix(#4935): stop double-update in one tx from leaving phantom index e… (
lvca commentedon Jul 5, 2026
Fixed in #4964 (merged).
The in-transaction index update now diffs the second and subsequent saves of the same record against a per-RID snapshot of the previous in-transaction indexed state, instead of the committed buffer (which stays frozen until commit because serialization is deferred). The intermediate ADD is therefore cancelled and no phantom entry survives.
Performance notes (all benchmarked against the pre-fix baseline on 50k-record bulk single-update transactions): the snapshot stores only the indexed property values, is built inside
updateDocumentreusing the key values already extracted for the diff (no second extraction), is refreshed only when an index entry actually changed (bulk updates of non-indexed properties pay zero snapshot cost), and sharesupdatedRecords' lifecycle (released at commit 1st phase / rollback / reset). Net cost: parity with pre-fix, within measurement noise.Regression tests: double/triple updates on unique and non-unique indexes, composite keys, LIST BY ITEM deltas asserted directly against the index content, in-place container mutation, change/no-change/change sequences, and update-then-delete in the same transaction.
fix(#4935): stop double-update in one tx from leaving phantom index e… (