Harden GigaMap consistency against exceptions from user code (indexers, update logic) - #758
Merged
Conversation
…s, update logic) Fixes microstream-one/internal#94 (add()/set() have no index rollback on indexer exception - entityId reuse binds foreign index entries to a different entity, persisted); the reporter's reproducer is adopted as issues/AddIndexerExceptionIdReuseReproTest and passes. GigaMap runs user code (Indexer.index, key equality, update/apply logic, custom constraints) in the middle of mutations. On several paths internal state (storage slots, size counters, dirty flags, sibling indices) was already mutated when that user code ran, so a thrown RuntimeException left the map inconsistent: stale or aliased index entries after a failed add, orphaned entries after a failed remove, storage/index divergence after a failed set, and half-mutated entities with stale indices after a failed update/apply - all of which a subsequent store() persisted. Failure semantics now enforced (documented on the GigaMap interface): - add: rolls back like addAll (which gained an exception-tolerant rollback). If the cleanup itself fails again, the secondary failure is attached as a suppressed exception and the affected ids are "burned" (never reused) so stale index entries can never alias a future entity. - removeById/remove: the removal always completes; remaining indices and index groups are cleaned best-effort per-index/per-group and the first exception is rethrown afterwards. Residue in a broken index points at an empty id, is invisible to queries and repaired by reindex(). - set/replace: the index update (the last step that runs user code) now happens BEFORE the storage slot is overwritten, so a throw leaves the map observably unchanged. As a side effect, set-path failures no longer destructively de-index the previous entity state. - update/apply: split into a prepare phase (indexers on the pre-mutation state; a throw aborts cleanly with the map unchanged) and an apply phase. Once the logic may have mutated the entity, the documented destructive-removal contract - previously limited to ConstraintViolationException - applies to every RuntimeException: the unreliable entity is removed, the original exception is rethrown raw and cleanup failures are suppressed. On a logic throw the indices are first brought in line with the entity's current state so the removal's key re-derivation finds all entries; a group whose update fails instead de-indexes via its prepared change handlers (new removeOnFailure flag, threaded through a binary-compatible default method on IndexGroup.Internal). - Dirty-marking: index add/remove loops now mark state-changed even when an indexer throws mid-loop (previously a mid-loop throw could mutate children without marking, so store() silently skipped them - the GM-B lost-update class); conversely, the clean-abort paths no longer mark when provably nothing was mutated. New regression tests: ThrowingIndexerConsistencyTest (13 scenarios covering add/addAll/removeById/set/replace/apply with throwing single-and multi-value indexers, id burning vs. reclaiming, constraint interplay, suppressed cleanup failures and lock-state health) and ThrowingIndexerRestartTest (restart round-trips asserting the persisted state after failed add/remove is consistent and repairable). Full gigamap suite passes (1058 tests); lucene/jvector modules compile unchanged against the new default method.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens GigaMap against exceptions thrown by user-provided code (indexers, update/apply logic, constraints) so that failed mutations don’t leave persisted state inconsistent, and documents the resulting failure semantics on the public API.
Changes:
- Add rollback / best-effort-cleanup semantics across
add,addAll,remove,set/replace, andupdate/apply, including id “burning” when cleanup itself fails. - Reorder
setso index updates run before overwriting the storage slot; split in-placeapply/updateinto prepare vs. apply phases. - Add regression and restart round-trip tests for throwing indexers and the reported id-reuse corruption scenario.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| gigamap/gigamap/src/main/java/org/eclipse/store/gigamap/types/GigaMap.java | Documents new failure semantics; reorders set; adds rollback + id-burning behavior for add/addAll; broadens update/apply exception handling. |
| gigamap/gigamap/src/main/java/org/eclipse/store/gigamap/types/GigaIndices.java | Adds exception-safe dirty marking; introduces prepare/apply update phases; implements best-effort index cleanup behaviors across index groups. |
| gigamap/gigamap/src/main/java/org/eclipse/store/gigamap/types/BitmapIndices.java | Makes add/remove marking exception-safe; makes removals best-effort; adds removeOnFailure support to update indices without re-running user code. |
| gigamap/gigamap/src/main/java/org/eclipse/store/gigamap/types/IndexGroup.java | Adds a binary-compatible default overload for internalUpdateIndices to pass removeOnFailure semantics to index groups. |
| gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/indexer/edge/ThrowingIndexerConsistencyTest.java | New regression suite covering throwing indexers across CRUD + update/apply failure semantics. |
| gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/restart/ThrowingIndexerRestartTest.java | New restart round-trip tests ensuring persisted state after failed add/remove stays consistent and repairable. |
| gigamap/gigamap/src/test/java/org/eclipse/store/gigamap/issues/AddIndexerExceptionIdReuseReproTest.java | Adds the reported reproducer test to prevent id-reuse corruption after failed add(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- GigaIndices.internalApplyUpdate, logic-throws path: align the indices with the mutated entity state best-effort PER GROUP instead of stopping at the first group that throws, so every group gets its chance to align (or de-index via its prepared change handlers) before the destructive removal; each failure is attached as a suppressed exception. - Fix javadoc typo "entitiy" in GigaMap.removeById.
…s, update logic) Fixes microstream-one/internal#94 (add()/set() have no index rollback on indexer exception - entityId reuse binds foreign index entries to a different entity, persisted); the reporter's reproducer is adopted as issues/AddIndexerExceptionIdReuseReproTest and passes. GigaMap runs user code (Indexer.index, key equality, update/apply logic, custom constraints) in the middle of mutations. On several paths internal state (storage slots, size counters, dirty flags, sibling indices) was already mutated when that user code ran, so a thrown RuntimeException left the map inconsistent: stale or aliased index entries after a failed add, orphaned entries after a failed remove, storage/index divergence after a failed set, and half-mutated entities with stale indices after a failed update/apply - all of which a subsequent store() persisted. Failure semantics now enforced (documented on the GigaMap interface): - add: rolls back like addAll (which gained an exception-tolerant rollback). If the cleanup itself fails again, the secondary failure is attached as a suppressed exception and the affected ids are "burned" (never reused) so stale index entries can never alias a future entity. - removeById/remove: the removal always completes; remaining indices and index groups are cleaned best-effort per-index/per-group and the first exception is rethrown afterwards. Residue in a broken index points at an empty id, is invisible to queries and repaired by reindex(). - set/replace: the index update (the last step that runs user code) now happens BEFORE the storage slot is overwritten, so a throw leaves the map observably unchanged. As a side effect, set-path failures no longer destructively de-index the previous entity state. - update/apply: split into a prepare phase (indexers on the pre-mutation state; a throw aborts cleanly with the map unchanged) and an apply phase. Once the logic may have mutated the entity, the documented destructive-removal contract - previously limited to ConstraintViolationException - applies to every RuntimeException: the unreliable entity is removed, the original exception is rethrown raw and cleanup failures are suppressed. On a logic throw the indices are first brought in line with the entity's current state so the removal's key re-derivation finds all entries; a group whose update fails instead de-indexes via its prepared change handlers (new removeOnFailure flag, threaded through a binary-compatible default method on IndexGroup.Internal). - Dirty-marking: index add/remove loops now mark state-changed even when an indexer throws mid-loop (previously a mid-loop throw could mutate children without marking, so store() silently skipped them - the GM-B lost-update class); conversely, the clean-abort paths no longer mark when provably nothing was mutated. New regression tests: ThrowingIndexerConsistencyTest (13 scenarios covering add/addAll/removeById/set/replace/apply with throwing single-and multi-value indexers, id burning vs. reclaiming, constraint interplay, suppressed cleanup failures and lock-state health) and ThrowingIndexerRestartTest (restart round-trips asserting the persisted state after failed add/remove is consistent and repairable). Full gigamap suite passes (1058 tests); lucene/jvector modules compile unchanged against the new default method.
- GigaIndices.internalApplyUpdate, logic-throws path: align the indices with the mutated entity state best-effort PER GROUP instead of stopping at the first group that throws, so every group gets its chance to align (or de-index via its prepared change handlers) before the destructive removal; each failure is attached as a suppressed exception. - Fix javadoc typo "entitiy" in GigaMap.removeById.
…to gigamap-index-error-handling
zdenek-jonas
approved these changes
Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GigaMap runs user code (
Indexer.index, key equality,update/applylogic, custom constraints) in the middle of mutations. On several paths the internal state (storage slots, size counters, dirty flags, sibling indices) was already mutated when that user code ran, so a thrownRuntimeExceptionleft the map inconsistent — and the nextstore()persisted that state. Fixes an issue, which reports theadd()/set()part of this class: a failedadd()leaked index entries and the not-consumed entityId was reused by the nextadd(), binding the leaked entries to a different entity (persisted wrong query results).Failure semantics now enforced (documented on the
GigaMapinterface)add / addAll:
add()now rolls back likeaddAll(), whose rollback additionally became exception-tolerant: the cleanup re-runs the indexers, and if the broken indexer throws again, the secondary failure is attached as a suppressed exception and the affected ids are "burned" (never reused), so stale index entries can never alias a future entity. Remainders are repaired byreindex().removeById / remove: the removal always completes (entity gone, size correct); the remaining indices and index groups are cleaned best-effort per index/per group and the first exception is rethrown afterwards. Residue in a broken index points at an empty id, is invisible to queries, and is repaired by
reindex(). Aborting instead would make an entity with a broken indexer permanently unremovable.set / replace: the index update — the last step that runs user code — now happens before the storage slot is overwritten, so a throw leaves the map observably unchanged. As a side effect, set-path failures no longer destructively de-index the previous entity state (new
removeOnFailureflag, threaded through a binary-compatible default method onIndexGroup.Internal; the lucene/jvector modules compile unchanged).update / apply: split into a prepare phase (indexers run on the pre-mutation state; a throw aborts cleanly with the map unchanged) and an apply phase. Once the logic may have mutated the entity, the documented destructive-removal contract — previously limited to
ConstraintViolationException— applies to everyRuntimeException: the unreliable entity is removed, the original exception is rethrown raw, and cleanup failures are suppressed. On a logic throw the indices are first brought in line with the entity's current state so the removal's key re-derivation finds all entries; a group whose update fails de-indexes via its prepared change handlers instead.Dirty-marking: index add/remove loops now mark state-changed even when an indexer throws mid-loop (previously a mid-loop throw could mutate children without marking, so
store()silently skipped them — the same lost-update class as review finding GM-B); conversely, clean-abort paths no longer mark when provably nothing was mutated.Accepted limitation
On maps with multiple index groups (bitmap + Lucene/vector), a failure in a later group can still leave the groups diverged: a cross-group prepare/apply restructure would break the
IndexGroup.InternalAPI implemented by external modules. This is documented on the affected methods;reindex()repairs such states.Tests
indexer/edge/ThrowingIndexerConsistencyTest— 13 scenarios: throwing single- and multi-value indexers on every CRUD path, id burning vs. reclaiming, constraint interplay, suppressed cleanup failures, and lock-state health after failed mutations.restart/ThrowingIndexerRestartTest— restart round-trips asserting the state persisted after a failedadd/removeis consistent and repairable.issues/AddIndexerExceptionIdReuseReproTest— the reporter's reproducer from internal#94, adopted verbatim (red on main, green here).