Skip to content

[tx] Updating the same indexed record twice in one transaction permanently corrupts the index #4935

Closed

Description

@lvca

Severity: CRITICAL - reproduced on current main.

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.

Activity

  1. added
    auditFindings from the 2026-07 engine deep audit
    concurrencyThreading / concurrency / MVCC
    on Jul 3, 2026
  2. added this to the 26.8.1 milestone on Jul 4, 2026
  3. added 5 commits that reference this issue on Jul 4, 2026
  4. lvca commented on Jul 5, 2026

    @lvca
    MemberAuthor

    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 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.

  5. added a commit that references this issue on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    auditFindings from the 2026-07 engine deep auditbugconcurrencyThreading / concurrency / MVCC

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions