Keep collection properties dirty-checked after reassignment; track iterator-based removals - #16282
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 55.0529% 55.1221% +0.0693%
- Complexity 20773 20864 +91
==================================================
Files 2110 2111 +1
Lines 101368 101520 +152
Branches 18005 18025 +20
==================================================
+ Hits 55806 55960 +154
+ Misses 37517 37514 -3
- Partials 8045 8046 +1
🚀 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
jdaugherty
left a comment
There was a problem hiding this comment.
Ran the two unit specs locally (both green) and read through the wrapper, transformer and codec changes. The approach is sound and the Mongo fix is real. Three things before this goes in:
- Neo4j regression (inline on
rewrap): reassigning ahasManynow permanently replaces the Neo4j-aware collection with a generic wrapper, so later in-place removals stop deleting relationships. Reproduced on this branch against the embedded harness; the same spec passes on the merge-base. - Map wrapper still leaks (inline on
DirtyCheckingMap): the@Delegate-generated default methods and the three views bypass tracking. - Docs:
grails-data-mongodb/docs/src/docs/asciidoc/objectMapping/dirtyChecking.adocdocuments the exact caveat this PR removes ("if you override the collection with a non-dirty checking aware type it can disable dirty checking and prevent the property from being updated"). That paragraph needs to describe the new behaviour, plus whatever gaps remain after the above (Map views,subList, hand-written setters).
| * @param newValue The value being assigned | ||
| * @return The value to store: {@code newValue}, wrapped if it replaces a tracked value | ||
| */ | ||
| static Object rewrap(DirtyCheckable parent, String property, Object oldValue, Object newValue) { |
There was a problem hiding this comment.
PersistentCollection and the Neo4j store's own wrappers (Neo4jList/Neo4jSet/Neo4jSortedSet, Neo4jPersistentList/Neo4jPersistentSet) all implement DirtyCheckableCollection, so this also fires when one of those is the value being replaced, and it installs a plain DirtyChecking* wrapper in their place. Neo4jEntityPersister.createDirtyCheckableAwareCollection then takes its "already dirty-checkable" branch and never re-wraps into a Neo4j collection. Before this change the raw replacement went through the other branch and came back as a Neo4jList/Neo4jSet on the first save.
Net effect: once a hasMany property has been reassigned, in-place removals on that instance stop deleting relationships for the rest of its life (inserts still happen because hasChanged() is true, and they are MERGEs, so adds look fine). Orphan removal in GraphAdapter.adaptGraphUponRemove is lost the same way.
Repro against the embedded harness, unidirectional List songs / hasMany = [songs: Song]:
p = Playlist.get(id); p.trackChanges() // songs: Neo4jPersistentList
p.songs = [a, b, c]; p.save(flush: true) // songs: DirtyCheckingList, and still is after save
p.songs.remove(a); p.save(flush: true) // hasChanged('songs') == true, but no RelationshipPendingDelete| relationships after reassign+save | after remove+save | reload | |
|---|---|---|---|
| this branch | 3 | 3 | [a, b, c] |
| merge-base | 3 | 2 | [b, c] |
Two ways out, either works for me: (a) have the Neo4j persister treat an isAssigned() wrapper like a raw collection, which is what the flag means, by wrapping it (or its target) in Neo4jList/Neo4jSet and registering the inserts; or (b) only rewrap when the old value is one of the generic DirtyChecking* classes and leave store-specific collections to the store. Either way this needs a Neo4j spec covering reassign, save, remove, save.
There was a problem hiding this comment.
Took option (b), with one adjustment your repro made necessary: Neo4jList extends DirtyCheckingList, so an instanceof check against the generic classes would still have caught the Neo4j wrappers. rewrap now gates on the exact class of the replaced value being one of the five generic wrappers (isGenericWrapper). A store-specific wrapper or PersistentCollection being replaced stores the raw value, and the store's persister re-wraps it on save exactly as at merge-base.
Added HasManyReassignDirtyCheckingSpec in grails-data-neo4j (embedded harness) running your exact reassign → save → remove → save → reload sequence — verified it fails with the previous instanceof gate and passes with the exact-class one. There's also a unit spec pinning that a DirtyCheckingList subclass as the old value is left alone. In 8581d5604f.
| final DirtyCheckable parent | ||
| final String property | ||
| final int originalSize | ||
| final boolean assigned |
There was a problem hiding this comment.
The Map wrapper still has the same class of escape this PR closes for collections. Checked the compiled class: @Delegate generates putIfAbsent, merge, compute/computeIfAbsent/computeIfPresent, replaceAll, replace and remove(key, value) as straight calls on target with no markDirty, and entrySet()/keySet()/values() hand out the raw views. So Groovy's Map.removeAll(Closure) / retainAll(Closure) (they iterate entrySet()), keySet().remove(k) and values().removeIf { } are all invisible to tracking, exactly like List.removeAll(Closure) was. DirtyCheckingList.subList() is a raw view too.
Since the description says the wrappers now track every mutation path, either cover these (override the default methods, return tracking views for the three collection views) or call them out as known gaps in the docs update.
There was a problem hiding this comment.
Covered rather than documented away, in 8581d5604f: overrides for the @Delegate-generated default methods (putIfAbsent, merge, compute/computeIfAbsent/computeIfPresent, replace, replace(k,old,new), replaceAll, remove(k,v)), and entrySet()/keySet()/values() now return tracking wrappers — which is what catches Groovy's Map.removeAll(Closure)/retainAll(Closure) (they iterate entrySet()), keySet().remove(k) and values().removeIf { }. DirtyCheckingList.subList() returns a tracking view as well, with a spec per path.
The one Map path left untracked is Map.Entry.setValue during iteration; that's now called out explicitly in the docs update.
There was a problem hiding this comment.
Following up on this thread, since it is where the "tracks every mutation path" claim was litigated: reviewing the compiled classes for the latest round turned up two more @Delegate-generated escapes of exactly this kind, now closed in a2ac5178cf.
- The Java 21
SequencedCollectionmutators —addFirst,addLast,removeFirst,removeLast— were generated straight through to the target onDirtyCheckingListandDirtyCheckingSortedSet, with nomarkDirty. DirtyCheckingSortedSethanded outheadSet,tailSetandsubSetas raw live views, the same holesubListwas overridden for.
Those views (plus reversed) now come back as tracking wrappers, and are constructed as assigned, because a view stored back onto a property is a wholesale replacement rather than something to diff element by element. The docs sentence is narrowed to List and SortedSet properties, since a plain Set/Map wrapper does not expose the sequenced API at all.
Map.Entry.setValue during iteration is still the one Map path left untracked, and remains listed as a gap.
| // Interface-typed collection properties whose generated setter re-establishes change | ||
| // tracking via DirtyCheckingSupport.rewrap. Restricted to the exact interfaces the | ||
| // DirtyChecking* wrappers implement so the cast in the generated setter is always valid. | ||
| private static final Set<String> REWRAPPABLE_TYPE_NAMES = [ |
There was a problem hiding this comment.
SortedSet is a supported GORM collection type and DirtyCheckingSortedSet exists for it, but it isn't in this set, so a SortedSet-typed property reassigned over a tracked value loses tracking exactly the way List/Set did. Adding SortedSet.name here plus a newValue instanceof SortedSet branch in rewrap ahead of the Set check keeps the generated cast valid and stops a TreeSet assigned to a Set property from losing its SortedSet API behind the wrapper. wrap() has the same blind spot, so worth fixing both together.
There was a problem hiding this comment.
Done in 8581d5604f: SortedSet.name added here, and both wrap() and rewrap() now produce DirtyCheckingSortedSet ahead of the Set check — so a TreeSet keeps its SortedSet API behind the wrapper on Set-declared properties too (the wrap() blind spot you pointed out). Specs cover the SortedSet-typed reassignment and wrap(TreeSet) returning the SortedSet wrapper.
| // invisible to change tracking. rewrap is a no-op when the old value was untracked, | ||
| // so stores with their own dirty checking (Hibernate) are unaffected. | ||
| Expression assignedValue | ||
| if (REWRAPPABLE_TYPE_NAMES.contains(returnType.name)) { |
There was a problem hiding this comment.
Only generated setters get the rewrap. A domain class with its own void setShares(List shares) goes through weaveIntoExistingSetter, which only prepends markDirty, so the original bug is still there for hand-written setters. Fine to leave for a follow-up, but it should be stated in the docs update so nobody assumes it's covered.
There was a problem hiding this comment.
Left for a follow-up as you suggested, and now stated explicitly in the docs update: the rewritten caveat in objectMapping/dirtyChecking.adoc lists hand-written setters first among the paths that still require an explicit markDirty(propertyName) — "a hand-written setter … stores the value it is given without re-wrapping, so a collection assigned through it loses tracking until the next load."
…-collection-tracking-8.0.x
…Set, docs - rewrap now fires only when the replaced value is one of the plugin's own generic wrapper classes (exact-class check). Store-specific subclasses (Neo4jList/Neo4jSet/...) and PersistentCollection implementations are left alone: the raw replacement is stored so the store's persister re-wraps it into its relationship-aware type on save, exactly as before. Covered by HasManyReassignDirtyCheckingSpec against the embedded Neo4j harness (reassign, save, remove, save, reload) — it fails with the previous instanceof gate. - DirtyCheckingMap covers the @Delegate-generated default methods (putIfAbsent/merge/compute*/replace*/remove(k,v)/replaceAll) and returns tracking wrappers from entrySet()/keySet()/values(), so Groovy's Map.removeAll(Closure), keySet().remove and values().removeIf mark the parent. DirtyCheckingList.subList() returns a tracking view. Map.Entry.setValue during iteration remains untracked and is documented as such. - SortedSet: added to the transformer's rewrappable types, and both wrap() and rewrap() now produce DirtyCheckingSortedSet ahead of the Set check, so a TreeSet keeps its SortedSet API behind the wrapper. - Docs: the dirty-checking caveat in grails-data-mongodb now describes reassignment staying tracked and lists the remaining gaps (hand-written setters, Map.Entry.setValue, store-specific wrappers deliberately left to their store).
…acking-8.0.x' into fix/dirty-checking-collection-tracking-8.0.x
The tracking views DirtyCheckingMap now returns from keySet()/entrySet() /values() broke Groovy's Map == Map: its extension method compares self.keySet().equals(other.keySet()), and the view wrapper fell back to Object identity. Failed BasicCollectionsSpec (Currency/BigDecimal maps) and MapOfDomainsSpec on CI — a decoded map compared unequal to a plain map with identical contents. The wrapper is transparent, so it equals whatever its target equals — the same contract AbstractPersistentCollection already implements. Applied to DirtyCheckingCollection (List/Set/SortedSet inherit) and DirtyCheckingMap, with a spec pinning content equality in both directions plus the three map views.
|
The Mongo CI failures (BasicCollectionsSpec Currency/BigDecimal maps, MapOfDomainsSpec) were caused by the new Map views: Groovy's |
|
One edge in
if (newValue instanceof DirtyCheckableCollection) {
return newValue
}So assigning one entity's tracked collection onto another entity's property stores the source wrapper, whose a.shares = b.shares // a.shares is now b's DirtyCheckingList
a.shares.add(share) // marks b dirty for 'shares' — a stays cleanThe wrong-parent binding pre-dates this PR (the old generated setter stored that wrapper verbatim), so nothing regresses here. What is new is the Re-wrapping in that branch when the existing wrapper's |
@DeleGate was generating the Java 21 sequenced methods straight through to the target, so addFirst/addLast/removeFirst/removeLast bypassed change tracking entirely - the same class of escape as the iterator-based removals, on a wrapper family whose whole job is to notice mutation. DirtyCheckingSortedSet was also handing out headSet/tailSet/subSet as raw, live, write-through views. Override the sequenced mutators, and return the views (subList, reversed, headSet, tailSet, subSet, and the three Map views) as tracking wrappers. Views are constructed as assigned: a view stored back onto a property is a wholesale replacement, so a persister must re-encode it rather than diff it element by element against what the property held before. The flag is inert for a view that is only traversed. Not requested by either reviewer - raised here because the PR claims the wrappers track every mutation path, and without this that claim is false.
… assigned to rewrap early-returned any value that was already a DirtyCheckableCollection, so 'a.shares = b.shares' stored B's wrapper on A. Its parent still pointed at B, so 'a.shares.add(x)' marked B dirty and left A clean, and the value kept assigned=false, so a persister diffed a wholesale replacement element by element. The worst shape is silent: when the two collections are equal in content the assignment is equality-suppressed, so A is never flagged at all, the save finds only lastUpdated to write, and the addition is lost while B absorbs it. That is what the new Mongo spec pins. A wrapper from another entity is now re-bound - its raw target re-wrapped against this parent, with assigned=true. Three things this must not do, each covered by a spec: - replace a store-specific wrapper (Neo4jList and friends) or a PersistentCollection with a generic one, which would reintroduce the Neo4j relationship-delete regression: those fail the exact-class check and are returned untouched; - mistake another entity's wrapper for this property's own when the two entities are equal by business key: the owner is compared by identity, never equals, which on an association can initialise a proxy; - re-bind onto an inner wrapper when an encoder has left one nested inside another, which would mark both entities dirty on every mutation. The re-binding runs whatever the property held before, including null - 'new Entity(shares: other.shares)' has the same defect and does not self-heal on insert, because the write-back assigns the field directly and never reaches this method. Tracking is still never introduced for a plain collection replacing an untracked value. Docs: describe the re-binding, and scope it to the properties GORM actually wraps. A one-to-many is held in a PersistentCollection, so the previous hasMany example was a case this does not cover.
|
Thanks — taken rather than deferred, in
Two things worth flagging back: Your repro passes as-is today. The codec condition is a three-way AND, and the It also fires when the property was untracked. Not addressed here, deliberately: |
✅ All tests passed ✅🏷️ Commit: f11e855 Learn more about TestLens at testlens.app/docs. |
AI Review FindingsThe three findings from the first round and the borrowed-wrapper edge from the second are all resolved, and the fixes hold up: I ran One finding below I would like either fixed or stated in the docs before this goes in, because it is the PR's own bug on a shape one mapping away from the one in the description. The rest are non-blocking. Resolved Findings
FindingsMedium: A loaded
|
| class held after re-init | document after save | reload | |
|---|---|---|---|
| this branch | java.util.ArrayList |
members: [], version: 1 |
0 members |
This is not a regression: the generated setter stored the raw list before this PR as well. But it is the bug in the title, on the mapping most applications would reach for before embedded, and the instanceof DirtyCheckableCollection gate from the first push did cover it; the exact-class gate that fixed Neo4j dropped it. The docs sentence at line 39 reads as if reassignment is covered generally, and the one-to-many caveat at line 53 only appears inside the borrowed-collection paragraph.
Two ways out:
- Accept the three datastore-core classes
PersistentList,PersistentSetandPersistentSortedSetas exact classes in the old-value gate alongside the generic wrappers.Neo4jPersistentList/Neo4jPersistentSet/Neo4jPersistentSortedSetare subclasses, so the Neo4j path is unchanged. The resulting state, ahasManyproperty holding a genericDirtyCheckingList, is already whatMongoCodecEntityPersisterwrites back after any save, and the probe's second case (reassign after an in-session save, then add) round-trips through exactly that state today. It would need a Mongo spec on thehasManyshape next to the embedded one. - Leave the behaviour and add a bullet to the gap list at line 56: a one-to-many or many-to-many loaded from the database is a
PersistentCollection, is not re-wrapped on reassignment, and needsmarkDirtyafter an in-place mutation that follows a reassignment.
I would take the first, since it is the production shape with a different mapping keyword.
Low: Wrapper equals is not reflexive when the target has identity equality
Reference:
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingCollection.groovy:82
target.equals(other) is fine when the target defines content equality (ArrayList, HashSet, TreeSet, every Map), but the new values() view and any DirtyCheckingCollection produced by wrap() for a non-List, non-Set target wrap an AbstractCollection, which inherits Object.equals. The wrapper then fails to equal itself. Checked against the compiled classes:
map.values().equals(map.values()) = false
[map.values()].contains(map.values()) = false
new DirtyCheckingCollection(new ArrayDeque(['x']), o, 'p').equals(self) = false
Groovy == still returns true because it checks identity first, so nothing in the specs sees it, but List.contains, indexOf and HashSet membership go through equals. Short-circuiting on identity keeps the delegation and restores the contract:
boolean equals(Object other) {
other.is(this) || target.equals(other)
}Low: BasicCollectionTypeEncoder nests a new DirtyCheckingMap on every save (follow-up)
References:
grails-data-mongodb/bson/src/main/groovy/org/grails/datastore/bson/codecs/encoders/BasicCollectionTypeEncoder.groovy:76-78grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy:255
The write-back for a map property wraps whatever the property currently holds, with no already-wrapped guard, so an entity saved N times in a session holds a map N wrappers deep. The unwrap loop in genericWrapperTarget works around this for rewrap, and the comment there names the cause. Pre-existing, so fine to defer, but worth a follow-up now that each level also hands out a fresh view wrapper per entrySet() call, since the cost of the nesting is no longer just an extra markDirty. The collection branch a few lines up already goes through DirtyCheckingSupport.wrap, which has the guard; the map branch only needs the same instanceof check.
Nit: The explicit iterator() overrides in the three subclasses are redundant
References:
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingList.groovy:83grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSet.groovy:45grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSortedSet.groovy:45
@Delegate skips methods the owner already has, including inherited ones. javap on DirtyCheckingSet shows no removeIf, retainAll or removeAll of its own, so the ones inherited from DirtyCheckingCollection are what run, and the same holds for iterator(). The three super.iterator() overrides and their "route through" comments can go. Harmless if kept.
Nit: The docs list addFirst/addLast for SortedSet properties
Reference:
grails-data-mongodb/docs/src/docs/asciidoc/objectMapping/dirtyChecking.adoc:43-44
SortedSet.addFirst/addLast throw UnsupportedOperationException by contract, which is why DirtyCheckingSortedSet does not override them (its own comment at line 50 says so). The sentence should attach those two to List properties only; the removals and views apply to both.
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)