Keep collection properties dirty-checked after reassignment; track iterator-based removals - #16282
Open
codeconsole wants to merge 3 commits into
Open
Conversation
…erator-based removals Interception-based stores (MongoDB) rely exclusively on the DirtyChecking* wrappers — there is no flush-time snapshot comparison — so mutations that escape them are silently lost: save() sees a clean entity and persists nothing. Two real-world escapes: 1. Reassignment through a generated setter stored the raw value, so 'entity.items = []' over a tracked list replaced the wrapper with a plain untracked ArrayList. The common defensive re-init 'if (!entity.items) entity.items = []' triggers this on every load (an empty tracked collection is falsy in Groovy), and because [] == [] the equality-suppressed markDirty never flagged the assignment either. The in-place add() that followed was lost. 2. DirtyCheckingCollection never overrode iterator(), so every iterator-based removal — including Groovy's removeAll(Closure) and retainAll(Closure) and Java's removeIf — bypassed tracking, along with retainAll(Collection), List.sort and List.replaceAll. The fix stays interception-only (no snapshots, no flush-time diffing): - Wrappers override iterator()/listIterator() with dirty-marking iterators plus the missing direct mutators — the same approach as Hibernate's PersistentCollection. - Generated setters for Collection/List/Set/Map-typed properties assign through DirtyCheckingSupport.rewrap, which wraps the incoming value ONLY when the value being replaced was itself a tracked wrapper. A never-tracked property (transient instance, or a store like Hibernate with its own dirty checking) stores the raw value as before, and non-collection properties compile to identical bytecode. - A replacement wrapper is flagged isAssigned() so PersistentEntityCodec takes the full-rewrite path rather than per-element diffing — a replacement's layout need not match the stored array (a same-size replacement of clean elements previously emitted no update at all once wrapped). Specs reproduce each escape before the fix: DirtyCheckingCollectionSpec (wrapper mutation paths), DirtyCheckCollectionReassignmentSpec (setter reassignment), and EmbeddedCollectionDirtyTrackingSpec (end-to-end against MongoDB, replicating the production shape where an auto-timestamped entity dropped an embedded-collection add).
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16282 +/- ##
==================================================
+ Coverage 54.7365% 54.7809% +0.0444%
- Complexity 20470 20507 +37
==================================================
Files 2103 2104 +1
Lines 101077 101152 +75
Branches 17928 17935 +7
==================================================
+ Hits 55326 55412 +86
+ Misses 37876 37862 -14
- Partials 7875 7878 +3
🚀 New features to boost your workflow:
|
- SortedSet wrapper: construction, assigned flag, iterator-based removal - ListIterator navigation methods (hasPrevious/previous/nextIndex/previousIndex) - DirtyCheckingMap assigned flag on both constructors - DirtyCheckableCollection.isAssigned() interface default (kept false for implementations that do not override it, e.g. PersistentCollection) - rewrap: tracked-wrapper passthrough, bare-Collection fallback, and the defensive non-collection tail
codeconsole
requested review from
borinquenkid,
jamesfredley,
jdaugherty,
matrei and
sbglasius
September 2, 2026 03:58
✅ All tests passed ✅🏷️ Commit: 708aa2e Learn more about TestLens at testlens.app/docs. |
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.
Mongo Collection Property Dirty Tracking
On interception-based stores (MongoDB), two everyday mutation patterns silently escape dirty checking —
save()reports success and persists nothing:Hibernate is unaffected — its flush-time snapshot comparison catches everything. Mongo relies exclusively on the
DirtyChecking*wrappers, so anything that escapes them is lost. Hit in production: a schedule-sharing feature showed "shared" while the document keptshares: [].The fix (interception only — no snapshots, no flush-time diffing)
1. Wrappers track every mutation path.
iterator()/listIterator()now return dirty-marking iterators (coversremoveAll(Closure),retainAll(Closure),removeIf), plus the missing direct overrides:retainAll(Collection),List.sort,List.replaceAll. Same approach as Hibernate'sPersistentCollection.2. Generated setters keep tracking across reassignment. Collection/List/Set/Map-typed properties assign through
DirtyCheckingSupport.rewrap:rewrapwraps the new value only when the value being replaced was itself a tracked wrapper — otherwise it returns the raw value after oneinstanceof. Never-tracked properties (transient instances, Hibernate entities) behave exactly as before, and non-collection properties compile to identical bytecode.3. Replacement wrappers are flagged
isAssigned()(default method onDirtyCheckableCollection, so binary-compatible).PersistentEntityCodecthen takes the full-rewrite path instead of per-element diffing — a replacement's layout need not match the stored array. Without the flag, a same-size replacement holding clean elements emitted no update at all.Tests
Each escape is reproduced by a spec that fails without the fix:
DirtyCheckingCollectionSpec— 8 wrapper mutation paths that bypassed trackingDirtyCheckCollectionReassignmentSpec— reassignment loses tracking (List/Set/Map); never-tracked values stay untouchedEmbeddedCollectionDirtyTrackingSpec— end-to-end against MongoDB, replicating the production shape (an auto-timestamped entity: thelastUpdatedwrite during flush resets the explicit-save dirty marker, so persistence depends entirely on the wrappers)