Skip to content

fix(#4935): stop double-update in one tx from leaving phantom index e… - #4964

Merged
lvca merged 6 commits into
mainfrom
fix/4935-double-update-index-corruption
Jul 5, 2026
Merged

lvca merged 6 commits into
mainfrom
fix/4935-double-update-index-corruption

Conversation

@lvca

@lvca lvca commented Jul 4, 2026

Copy link
Copy Markdown
Member

…ntries

When the same indexed record was updated more than once inside a single transaction, the eager in-transaction index update diffed each save against the record's committed buffer. Because serialization is deferred until commit, that buffer never advances during the transaction, so every save used the original committed value as its "before" image and the intermediate ADD was never cancelled. On a unique index this left a phantom entry that resolved by lookup to the final record and blocked a later legitimate insert of the intermediate value with a spurious DuplicatedKeyException; on a non-unique index it left a stale entry for every intermediate value. Reachable from ordinary SQL, e.g. "UPDATE x SET k=1; UPDATE x SET k=2" in one transaction.

Track a per-RID snapshot (Document.detach()) of the last in-transaction indexed state on the TransactionContext and diff each subsequent save against it instead of the committed buffer. The first update of a record in the transaction still diffs against the committed original; later updates diff against the previous in-transaction value, so the intermediate REMOVE cancels the buffered ADD and the net buffered change is the correct original -> final. The snapshot map is cleared on transaction reset/rollback.

Regression test: DoubleUpdateInTransactionIndexTest covers double and triple updates on unique and non-unique indexes, verifying no phantom entries survive and the unique constraint is not poisoned.

What does this PR do?

A brief description of the change being made with this pull request.

Motivation

What inspired you to submit this pull request?

Related issues

A list of issues either fixed, containing architectural discussions, otherwise relevant
for this Pull Request.

Additional Notes

Anything else we should know when reviewing?

Checklist

  • I have run the build using mvn clean package command
  • My unit tests cover both failure and success scenarios

…ntries

When the same indexed record was updated more than once inside a single
transaction, the eager in-transaction index update diffed each save against the
record's committed buffer. Because serialization is deferred until commit, that
buffer never advances during the transaction, so every save used the original
committed value as its "before" image and the intermediate ADD was never
cancelled. On a unique index this left a phantom entry that resolved by lookup
to the final record and blocked a later legitimate insert of the intermediate
value with a spurious DuplicatedKeyException; on a non-unique index it left a
stale entry for every intermediate value. Reachable from ordinary SQL, e.g.
"UPDATE x SET k=1; UPDATE x SET k=2" in one transaction.

Track a per-RID snapshot (Document.detach()) of the last in-transaction indexed
state on the TransactionContext and diff each subsequent save against it instead
of the committed buffer. The first update of a record in the transaction still
diffs against the committed original; later updates diff against the previous
in-transaction value, so the intermediate REMOVE cancels the buffered ADD and
the net buffered change is the correct original -> final. The snapshot map is
cleared on transaction reset/rollback.

Regression test: DoubleUpdateInTransactionIndexTest covers double and triple
updates on unique and non-unique indexes, verifying no phantom entries survive
and the unique constraint is not poisoned.
@lvca lvca self-assigned this Jul 4, 2026
@lvca lvca added this to the 26.8.1 milestone Jul 4, 2026
@mergify

mergify Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue #4935 by tracking the last in-transaction indexed state of a record in TransactionContext and diffing subsequent updates against this snapshot instead of the committed buffer, preventing phantom index entries. Feedback recommends cleaning up this new updatedRecordsIndexSnapshot map in removeRecordFromCache and removeFile to avoid potential memory leaks or stale lookups.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +370 to +374
public void setLastIndexedSnapshot(final RID rid, final Document snapshot) {
if (updatedRecordsIndexSnapshot == null)
updatedRecordsIndexSnapshot = new HashMap<>();
updatedRecordsIndexSnapshot.put(rid, snapshot);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While tracking the last in-transaction indexed state in updatedRecordsIndexSnapshot is a great fix for #4935, we should ensure that this map is properly cleaned up when records or files are removed during the transaction. Otherwise, it could lead to memory leaks or stale snapshot lookups.

Please update the following methods in TransactionContext.java (which are outside the current diff hunks):

  1. In removeRecordFromCache(final RID rid):
    if (updatedRecordsIndexSnapshot != null)
      updatedRecordsIndexSnapshot.remove(rid);
  1. In removeFile(final int fileId):
    if (updatedRecordsIndexSnapshot != null)
      updatedRecordsIndexSnapshot.keySet().removeIf(rid -> rid.bucketId == fileId);

@codacy-production

codacy-production Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 38 complexity

Metric Results
Complexity 38

View in Codacy

🟢 Coverage 89.47% diff coverage · -7.23% coverage variation

Metric Results
Coverage variation ✅ -7.23% coverage variation
Diff coverage ✅ 89.47% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (f8282b0) 134232 99762 74.32%
Head commit (06768e4) 166114 (+31882) 111446 (+11684) 67.09% (-7.23%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#4964) 95 85 89.47%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review — PR #4964 (fix #4935: double-update leaves phantom index entries)

Solid fix with a clear root-cause writeup and a focused regression test. The core logic is correct: diffing the second+ update against a per-RID in-transaction snapshot (instead of the frozen committed buffer) makes the intermediate REMOVE cancel the buffered ADD, yielding the correct net original -> final change. I traced the buffered-change cancellation and the reset paths and it holds up.

Correctness — looks good ✅

  • DetachedDocument copies all properties eagerly into its own LinkedHashMap (recursively detaching embedded docs), so the snapshot is a true point-in-time copy that won't advance as the live MutableDocument is mutated again. Correct choice over holding a reference.
  • Snapshot map is cleared in reset(), and both rollback() (line 299) and the commit paths route through reset(), so no snapshot leaks into a subsequent transaction if the TransactionContext is reused. Good.
  • First update still diffs against getOriginalDocument(); only 2nd+ updates use the snapshot. Triple-update case is covered by the test and works because each save re-snapshots.

Performance / GC — worth a look ⚠️

This is the main thing I'd raise, given the repo's "lightweight on the GC" mantra:

  1. document.detach() runs on every indexed-record update, even the common single-update case. For a record updated only once in the tx, the detached copy is allocated, stored in the map, and never read. A bulk UPDATE ... SET indexedField=... over N rows now allocates N DetachedDocuments (each a full LinkedHashMap copy of all properties, not just indexed ones) and retains them until commit. That's a real memory/allocation regression for wide documents and large batches, where before nothing was retained per update.

  2. Cheaper snapshot: updateDocument only ever reads the indexed property values from originalRecord (via getPropertyValue for each index.getPropertyNames()). You don't need a full-document detach — storing just the union of indexed property values (a small Object[] or tiny map keyed by property name) would preserve correctness while dropping the copy of every non-indexed field. Given getInvolvedIndexes(document) is already computed here, the needed property set is known at the call site. Worth considering as a follow-up if not now.

Minor nits

  • Field alignment: updatedRecordsIndexSnapshot = null; breaks the column alignment of the surrounding field block in TransactionContext.java (the = null no longer lines up). Trivial, but the file is consistently aligned.
  • Redundant getTransaction(): updateRecord calls getTransaction() at line 1068 and again at 1079; could hoist to a single local. Cheap (thread-local), purely cosmetic.
  • Snapshot not evicted on in-tx delete: if a record is updated (snapshot stored) then deleted in the same tx, its snapshot lingers in the map until reset. No correctness impact (delete goes through deleteDocument), only a small amount of retained memory — noting for completeness.

Test coverage — good ✅

DoubleUpdateInTransactionIndexTest covers double + triple updates on both unique and non-unique indexes, asserts no phantom survives, that the final value resolves, and (nicely) that the unique constraint isn't poisoned by a later legitimate insert of the intermediate value. Suggestions to strengthen further:

  • A composite/multi-property index case (the fix is index-shape agnostic, but the diff path in updateDocument has more branches — map/list expansions — not exercised here).
  • A case where the first update leaves the key unchanged and only the second changes it, to lock in the snapshot-vs-original selection.

Overall: correct and well-tested fix for a nasty data-integrity bug. My only substantive ask is to weigh the per-update detach() allocation/retention against the GC-sensitivity guidance — trimming the snapshot to just the indexed properties would keep the fix while avoiding the memory overhead.

lvca added 2 commits July 4, 2026 12:10
…aching the whole document

Addresses the substantive point of the PR review: document.detach() ran on
every indexed-record update, copying every property (recursively detaching
embedded documents) and retaining the copy until commit, even in the common
single-update case where the snapshot is never read. A bulk UPDATE over N
wide rows allocated and retained N full-document copies.

updateDocument only ever reads the indexed properties of the original
record, so the snapshot now stores exactly those: the stripped key names
(nested paths resolved eagerly) plus, for dotted names, the root property
read directly by the LIST BY ITEM delta logic. Container values are
shallow-copied with embedded documents detached, so the snapshot can never
alias state the caller later mutates in place. Cost per update is now
independent of document width: in a 20k-record bulk update of 50-property
documents the snapshot retention drops from ~130 MB to ~2 MB (~99% less),
with no measurable time difference.

Also addresses the review nits: the per-RID snapshot is evicted when the
record is deleted in the same transaction, the duplicate getTransaction()
call is hoisted, and the field-block alignment in TransactionContext is
restored.

New regression tests per the review's suggestions: composite index
double-update, first-save-keeps-key/second-moves-it, LIST BY ITEM
double-update and in-place list mutation (asserted directly against the
index content, since a CONTAINS query answered by a full scan would hide a
stale index), and update-then-delete in the same transaction.
@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review: fix(#4935) double-update in one tx leaving phantom index entries

Thorough, well-targeted fix. The root cause analysis is correct: because record serialization is deferred to commit, getOriginalDocument() returns the frozen committed buffer on every save, so the second+ eager index diff used the wrong "before" image and never cancelled the intermediate ADD. Threading a per-RID snapshot of the previous in-transaction indexed state through TransactionContext and diffing against it is the right approach, and keeping the snapshot to only the indexed property values (rather than a full Document.detach()) is a nice touch.

Strengths

  • Correctness reasoning is sound and matches the code: first update diffs against the committed original, later updates diff against the previous in-tx snapshot, so the net buffered change is original -> final.
  • Snapshot lifecycle is complete: cleared on reset() (covers both commit and rollback paths) and eagerly dropped on addDeletedRecord() to release memory. Good attention to the update+delete case.
  • Aliasing handled: copyForSnapshot defensively copies containers so an in-place mutation of the live list/map between saves cannot retroactively hide a delta. The inPlaceListMutationBetweenSavesUpdatesIndex test is exactly the kind of subtle case that would otherwise regress silently, and asserting against the index cursor directly (not a CONTAINS query answerable by a full scan) is the correct way to test it.
  • Test coverage is excellent: unique, non-unique, triple update, composite, LIST BY ITEM, in-place mutation, first-update-doesn't-change-key, and update+delete. Both success and constraint-poisoning scenarios are covered.

Performance consideration (main point worth discussing)
buildIndexedStateSnapshot() now runs on every indexed record update in a transaction, including the very common single-update case where the snapshot is stored and never read. For a bulk transaction updating N distinct records once each, that is N new LinkedHashMap + DetachedDocument allocations (plus container copies) that did not exist before this change. The release note's "negligible for bulk updates" is fair with respect to document width (only indexed props are copied), but it is not zero per-update overhead versus the prior behavior. The cost looks inherent to the fix (you must capture update-1's values eagerly before the live doc is mutated in place), so this is a note rather than a blocker, but it would be worth benchmarking a large single-update batch to confirm the regression is acceptable.

Minor notes

  • copyForSnapshot handles List and Map but not arrays or Set. Indexed values are normally scalar/List/Map so this is low risk, but if a property value can ever surface as an array or Set it would be stored aliased. A one-line comment noting the assumption (or a fallback) would prevent a future surprise.
  • updatedRecordsIndexSnapshot retains one entry per distinct updated RID for the whole transaction (same order of magnitude as updatedRecords), so memory scales with the number of distinct updated records in a long transaction. Acceptable given updatedRecords already holds those records, but worth being aware of for very large transactions.
  • Consistency between the snapshot keys and updateDocument's reads is correct for the NONE, LIST_ITEM, MAP, and nested-dotted-path cases (snapshot stores both the stripped/eagerly-resolved key and the dotted root). Good that the eager nested resolution mirrors getPropertyValue's has()/get() lookup.

Note
I was unable to run mvn in this review environment to execute DoubleUpdateInTransactionIndexTest, so I could not independently confirm the tests pass; please ensure CI is green. The static analysis of the flow (updateRecord eager path -> snapshot store -> reset/delete cleanup) looks correct.

Overall this is a high-quality, well-documented fix with strong regression coverage. The main thing to validate before merge is the per-update allocation impact on large single-update batches.

@codecov

codecov Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.94737% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.32%. Comparing base (f8282b0) to head (06768e4).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...in/java/com/arcadedb/database/DocumentIndexer.java 70.58% 10 Missing and 10 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #4964      +/-   ##
============================================
- Coverage     65.33%   65.32%   -0.02%     
  Complexity      814      814              
============================================
  Files          1682     1682              
  Lines        134232   134311      +79     
  Branches      28713    28736      +23     
============================================
+ Hits          87705    87733      +28     
- Misses        34507    34544      +37     
- Partials      12020    12034      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

lvca added 2 commits July 4, 2026 14:12
Addresses the review's benchmark request and closes the biggest remaining
per-update cost. Measured against the pre-fix baseline (no snapshot at all)
on 50k single-update transactions of narrow 3-property documents:

- updates that CHANGE the indexed value: ~6% slower (median 277ms vs 262ms,
  ~0.3us per update). This is the eager capture the fix requires - update
  1's values must be copied before the live document is mutated again - and
  only applies when an index entry was actually rewritten.
- updates that DON'T change any indexed value (e.g. bulk-updating a
  non-indexed property on an indexed type): now at pre-fix parity. Since
  getInvolvedIndexes() selects indexes by type, not by changed properties,
  these previously paid the snapshot on every save. updateDocument now
  reports whether any index entry was actually removed/added, and the
  snapshot is refreshed only then: when nothing changed, the previous diff
  source (committed buffer or an earlier snapshot) still describes the
  indexed state, so correctness is unaffected.

Also from the review notes:
- copyForSnapshot now defensively copies Set (as LinkedHashSet, preserving
  equals semantics for the diff) and Object[] values instead of storing
  them aliased, with a comment on the expected indexed shapes.
- documented that the per-RID snapshot map scales with (and never beyond)
  updatedRecords, with entries dropped on in-tx delete and released on
  reset().

New regression test: key change -> non-indexed-only change (refresh
skipped, retained snapshot must stay accurate) -> key change again, which
must diff against the first save's state.
…tracted key values

Removes the ~6% bulk-update overhead measured against the pre-fix baseline.
The snapshot was built in a second pass (buildIndexedStateSnapshot) that
re-extracted every indexed property the diff had just read. updateDocument
now retains the per-index new key values it extracts for the diff and, only
when an index actually changed, assembles the refreshed snapshot from them
directly, returning it to the caller (null when nothing changed). For the
common scalar-index case the snapshot costs one map insert per property:
no second extraction, no container copies.

Expansion indexes keep one direct property read for the container value
(the list/map itself is needed, not the expanded elements) and for the
fixed properties of composite expansion indexes; unchanged standard indexes
reuse their already-extracted values, since their current values equal the
previous state by definition.

Measured on the same 50k single-update / narrow-document workload (12 JVM
samples per side): median 259ms vs 262ms pre-fix - parity within noise,
down from 277ms with the two-pass snapshot. Updates that change no indexed
value remain snapshot-free from the previous commit.

buildIndexedStateSnapshot is removed; the behavior is covered by the same
9 regression tests (double/triple updates, composite, LIST BY ITEM deltas,
in-place mutation, change/no-change/change sequence, update+delete).
@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review - PR #4964: fix(#4935) phantom index entries on double-update in one tx

Reviewed the diff and the final state of all touched files. Overall this is a high-quality, well-reasoned fix with excellent test coverage and careful attention to the subtle correctness and memory pitfalls. The root cause (diffing every in-tx save against a committed buffer that never advances because serialization is deferred) is correctly identified and cleanly addressed by threading a per-RID snapshot of the previous in-transaction indexed state.

What I liked

  • Correct root cause & minimal surface. The fix is localized to updateRecord + a lightweight snapshot on TransactionContext; the non-tx updateRecordNoLock path is correctly left untouched (it serializes immediately, so its buffer already advances per update).
  • Lightweight snapshot. Storing only the indexed property values (DetachedDocument(source, properties)) instead of a full document.detach() is a good performance call, and updateDocument reuses the key values it already extracted for the diff, so the common scalar-index case pays no second extraction.
  • Return-null-when-unchanged optimization. Updates touching only non-indexed properties skip the snapshot refresh entirely, and the retained snapshot stays valid - the keyChangeThenNoChangeThenKeyChange... test locks this in.
  • Aliasing defense. copyForSnapshot shallow-copying containers and detaching embedded documents is the right instinct, and inPlaceListMutationBetweenSavesUpdatesIndex is exactly the test that would catch a regression here. Nice.
  • Memory lifecycle. Snapshot map is nulled on reset(), entries dropped on addDeletedRecord, and the doc comment correctly bounds retention to updatedRecords (which the tx already holds). The deleteAfterUpdateInSameTransactionLeavesNoEntry test covers the delete-after-update case.
  • Test coverage is genuinely thorough: unique, non-unique, triple update, composite, LIST BY ITEM, in-place list mutation, no-change-in-the-middle, and first-save-doesn't-change-key. It also asserts on the index content directly (lookupInTagsIndex) rather than trusting a query that a full scan could answer - good rigor.

Suggestions (non-blocking)

  1. Clear the snapshot map at commit1stPhase too (minor, consistency + earlier memory release). updatedRecordsIndexSnapshot is only nulled in reset(). updatedRecords is additionally nulled at line ~696 (end of commit1stPhase, where updated records are consumed). By that phase no further updateRecord calls happen for the tx, so the snapshot could be released one phase earlier and kept lifecycle-synced with updatedRecords. Not a bug (reset always runs on the normal commit/rollback paths), just tidier and frees memory sooner for large batches.

  2. copyForSnapshot shallow-copies nested containers. Only top-level EmbeddedDocument elements are detached; a List<Map> or Map<..,Map> element is stored by reference. If such a nested container is mutated in place between two saves of the same tx, the snapshot could alias the live value and hide a delta. This is a low-probability edge (indexed values are normally scalars or flat lists/maps), and the added Set/Object[] branches already show awareness - worth at most a one-line note in the comment that the copy is intentionally one level deep.

  3. Marginal memory for large single-update bulk transactions. A snapshot is stored for every distinct record whose index actually changed, retained until commit - pure overhead for the common case where each record is updated exactly once. As the doc note says, it's bounded by updatedRecords (already retained) and holds only indexed values, so the marginal cost is small; flagging only so it's a conscious trade-off for very wide multi-index records in huge batches.

Correctness spot-checks I ran (all pass)

  • Unique-index trace orig -> mid -> final: buffered ops net to remove(orig), add(final); intermediate add(mid)/remove(mid) cancel. Correct.
  • First-save-no-index-change then second-save-moves-key: getLastIndexedSnapshot returns null, falls back to the committed buffer which still holds the original key. Correct.
  • Edge @out/@in indexes: snapshot stores under the literal key, so the later has()/get() on the DetachedDocument resolves it even though it's not an Edge. Consistent (and edge endpoints are immutable anyway).
  • Snapshot rebuild covers all involved indexes (including unchanged ones) from the modified record, so a later update diffing an index that didn't change this round still has a valid source. Correct.

Note: I could not run mvn compile / the new test suite in this review environment (command approvals blocked), so please confirm CI is green - particularly DoubleUpdateInTransactionIndexTest. Based on inspection the code is consistent: the updateDocument signature change (void -> Document) has only two internal callers, both handled correctly.

Nice work - the fix is correct and the test suite is a model for regression coverage.

…le point

From the review's non-blocking notes:

- updatedRecordsIndexSnapshot now shares updatedRecords' exact lifecycle:
  besides reset(), it is released at the end of the commit 1st phase (no
  further updateRecord can happen for the tx past that point, so large
  batches free the snapshots one phase earlier), on the rollback reload
  path, and in the test-only kill().

- documented that copyForSnapshot is intentionally ONE level deep: nested
  container elements are stored by reference because indexed values are
  scalars or flat lists/maps - a nested container cannot be an index key,
  and deep-copying would tax every snapshot for a shape the diff never
  compares.
@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review: fix(#4935) — double-update in one tx leaving phantom index entries

Thorough, well-reasoned fix. The root-cause analysis (the committed buffer stays frozen until commit because serialization is deferred, so every in-tx save diffs against the original) is correct, and the per-RID indexed-state snapshot is the right shape for the fix. The commit history shows a careful progression from a full detach() to snapshotting only the indexed property values, with benchmarks at each step. Documentation and test coverage are excellent.

Correctness — looks sound

I traced the key paths and they hold up:

  • Nested standard-index paths work because the snapshot stores the resolved scalar under the literal dotted key name, and getPropertyValue() tries the exact name first (record.has(name)), so the DetachedDocument snapshot short-circuits correctly without re-descending.
  • Composite / expansion mixes: for LIST_ITEM/MAP indexes indexNewKeyValues[indexPos] stays null, so the snapshot build falls back to getPropertyValue(modifiedRecord, …) for the fixed positions — correct.
  • null vs absent: LinkedHashMap keeps null values and DetachedDocument.has() is containsKey(), so a stored-null indexed property reads back consistently.
  • Lifecycle: the snapshot map shares updatedRecords' exact lifecycle (reset/rollback/kill/commit-1st-phase) plus eviction on in-tx delete — memory scales with updatedRecords, never beyond. Good.
  • The copyForSnapshot defensive copy is the crux of the inPlaceListMutationBetweenSavesUpdatesIndex test and is handled correctly (shallow copy + embedded detach).

Notes (all non-blocking)

  1. The partial DetachedDocument is an unusual, "half-populated" object. Built via the new (source, properties) constructor it reports propertiesAsMap() == empty and getPropertyNames() == only the indexed keys. That's fine inside the indexer diff path (which only calls get/has), but if this object ever escaped — into a user event, a serializer, replication, or any code iterating propertiesAsMap() — it would silently look like a document with almost no properties. It's currently confined (getLastIndexedSnapshot is internal), but consider a brief contract note on the constructor / method stating it must never leave the tx-internal diff path, to guard against future misuse.

  2. Unchanged expansion indexes still pay a full container copy. When index A changes but a LIST_ITEM/MAP index B does not, the snapshot build still does copyForSnapshot(modifiedRecord.get(root)) for B — O(list/map size) even though B was untouched. The class comment frames the snapshot cost as tied to changed indexes; for multi-update rows with a wide unchanged collection index alongside a changed scalar index, that framing is slightly optimistic. Acceptable, just worth an accurate word in the comment.

  3. Test coverage gap: MAP BY KEY/BY VALUE double-update. There's strong LIST BY ITEM coverage (delta across saves + in-place mutation), but no equivalent for the MAP expansion path, even though updateMapEntriesInIndex got the same changed plumbing and the snapshot build has a dedicated MAP branch. A double-update + in-place map-mutation test would close the symmetry. A nested-path (tags.id) standard/list double-update would also lock in the "eagerly resolved" behavior noted above.

  4. Minor: updatedRecordsIndexSnapshot is a plain HashMap (consistent with updatedRecords — tx is single-threaded, so thread-safety isn't a concern). No change needed; noting it for completeness.

Style / conventions

Matches the codebase: final on locals/params, imports (not FQNs), AssertJ assertThat(...).isFalse() style, Apache headers, no new dependencies. Release note added. No System.out, no Claude authorship.

Nice work — the fix is correct and the regression suite is genuinely convincing (asserting directly against index content for the BY ITEM cases rather than through a query that a full scan could mask is a good call). I couldn't run mvn test in this environment; please confirm DoubleUpdateInTransactionIndexTest and the surrounding index tests pass in CI before merge.

@lvca
lvca merged commit 940e1c7 into main Jul 5, 2026
22 of 26 checks passed
@lvca
lvca deleted the fix/4935-double-update-index-corruption branch July 5, 2026 00:39
robfrank pushed a commit that referenced this pull request Aug 14, 2026
#4964)

* fix(#4935): stop double-update in one tx from leaving phantom index entries

When the same indexed record was updated more than once inside a single
transaction, the eager in-transaction index update diffed each save against the
record's committed buffer. Because serialization is deferred until commit, that
buffer never advances during the transaction, so every save used the original
committed value as its "before" image and the intermediate ADD was never
cancelled. On a unique index this left a phantom entry that resolved by lookup
to the final record and blocked a later legitimate insert of the intermediate
value with a spurious DuplicatedKeyException; on a non-unique index it left a
stale entry for every intermediate value. Reachable from ordinary SQL, e.g.
"UPDATE x SET k=1; UPDATE x SET k=2" in one transaction.

Track a per-RID snapshot (Document.detach()) of the last in-transaction indexed
state on the TransactionContext and diff each subsequent save against it instead
of the committed buffer. The first update of a record in the transaction still
diffs against the committed original; later updates diff against the previous
in-transaction value, so the intermediate REMOVE cancels the buffered ADD and
the net buffered change is the correct original -> final. The snapshot map is
cleared on transaction reset/rollback.

Regression test: DoubleUpdateInTransactionIndexTest covers double and triple
updates on unique and non-unique indexes, verifying no phantom entries survive
and the unique constraint is not poisoned.

* perf(#4935): snapshot only the indexed property values instead of detaching the whole document

Addresses the substantive point of the PR review: document.detach() ran on
every indexed-record update, copying every property (recursively detaching
embedded documents) and retaining the copy until commit, even in the common
single-update case where the snapshot is never read. A bulk UPDATE over N
wide rows allocated and retained N full-document copies.

updateDocument only ever reads the indexed properties of the original
record, so the snapshot now stores exactly those: the stripped key names
(nested paths resolved eagerly) plus, for dotted names, the root property
read directly by the LIST BY ITEM delta logic. Container values are
shallow-copied with embedded documents detached, so the snapshot can never
alias state the caller later mutates in place. Cost per update is now
independent of document width: in a 20k-record bulk update of 50-property
documents the snapshot retention drops from ~130 MB to ~2 MB (~99% less),
with no measurable time difference.

Also addresses the review nits: the per-RID snapshot is evicted when the
record is deleted in the same transaction, the duplicate getTransaction()
call is hoisted, and the field-block alignment in TransactionContext is
restored.

New regression tests per the review's suggestions: composite index
double-update, first-save-keeps-key/second-moves-it, LIST BY ITEM
double-update and in-place list mutation (asserted directly against the
index content, since a CONTAINS query answered by a full scan would hide a
stale index), and update-then-delete in the same transaction.

* perf(#4935): skip the snapshot refresh when no index actually changed

Addresses the review's benchmark request and closes the biggest remaining
per-update cost. Measured against the pre-fix baseline (no snapshot at all)
on 50k single-update transactions of narrow 3-property documents:

- updates that CHANGE the indexed value: ~6% slower (median 277ms vs 262ms,
  ~0.3us per update). This is the eager capture the fix requires - update
  1's values must be copied before the live document is mutated again - and
  only applies when an index entry was actually rewritten.
- updates that DON'T change any indexed value (e.g. bulk-updating a
  non-indexed property on an indexed type): now at pre-fix parity. Since
  getInvolvedIndexes() selects indexes by type, not by changed properties,
  these previously paid the snapshot on every save. updateDocument now
  reports whether any index entry was actually removed/added, and the
  snapshot is refreshed only then: when nothing changed, the previous diff
  source (committed buffer or an earlier snapshot) still describes the
  indexed state, so correctness is unaffected.

Also from the review notes:
- copyForSnapshot now defensively copies Set (as LinkedHashSet, preserving
  equals semantics for the diff) and Object[] values instead of storing
  them aliased, with a comment on the expected indexed shapes.
- documented that the per-RID snapshot map scales with (and never beyond)
  updatedRecords, with entries dropped on in-tx delete and released on
  reset().

New regression test: key change -> non-indexed-only change (refresh
skipped, retained snapshot must stay accurate) -> key change again, which
must diff against the first save's state.

* perf(#4935): build the snapshot inside updateDocument, reusing the extracted key values

Removes the ~6% bulk-update overhead measured against the pre-fix baseline.
The snapshot was built in a second pass (buildIndexedStateSnapshot) that
re-extracted every indexed property the diff had just read. updateDocument
now retains the per-index new key values it extracts for the diff and, only
when an index actually changed, assembles the refreshed snapshot from them
directly, returning it to the caller (null when nothing changed). For the
common scalar-index case the snapshot costs one map insert per property:
no second extraction, no container copies.

Expansion indexes keep one direct property read for the container value
(the list/map itself is needed, not the expanded elements) and for the
fixed properties of composite expansion indexes; unchanged standard indexes
reuse their already-extracted values, since their current values equal the
previous state by definition.

Measured on the same 50k single-update / narrow-document workload (12 JVM
samples per side): median 259ms vs 262ms pre-fix - parity within noise,
down from 277ms with the two-pass snapshot. Updates that change no indexed
value remain snapshot-free from the previous commit.

buildIndexedStateSnapshot is removed; the behavior is covered by the same
9 regression tests (double/triple updates, composite, LIST BY ITEM deltas,
in-place mutation, change/no-change/change sequence, update+delete).

* perf(#4935): release the snapshot map at every updatedRecords lifecycle point

From the review's non-blocking notes:

- updatedRecordsIndexSnapshot now shares updatedRecords' exact lifecycle:
  besides reset(), it is released at the end of the commit 1st phase (no
  further updateRecord can happen for the tx past that point, so large
  batches free the snapshots one phase earlier), on the rollback reload
  path, and in the test-only kill().

- documented that copyForSnapshot is intentionally ONE level deep: nested
  container elements are stored by reference because indexed values are
  scalars or flat lists/maps - a nested container cannot be an index key,
  and deep-copying would tax every snapshot for a shape the diff never
  compares.

(cherry picked from commit 940e1c7)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant