codeconsole opened a new pull request, #16282:
URL: https://github.com/apache/grails-core/pull/16282

   ### TL;DR
   
   On interception-based stores (MongoDB), two everyday mutation patterns 
silently escape dirty checking — `save()` reports success and persists nothing:
   
   ```groovy
   // 1. The defensive re-init — true for an EMPTY tracked list, because empty 
collections are falsy
   if (!schedule.shares) {
       schedule.shares = []              // replaces the tracked wrapper with a 
plain ArrayList
   }                                     // and [] == [] means the assignment 
isn't even flagged
   schedule.shares.add(newShare)         // invisible: plain list, nothing 
marks the entity dirty
   schedule.save(flush: true)            // writes nothing
   ```
   
   ```groovy
   // 2. Groovy's closure-based removal — removes via iterator().remove()
   schedule.shares.removeAll { it.userId == userId }   // 
DirtyCheckingCollection doesn't override iterator()
   schedule.save(flush: true)                          // writes 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 kept `shares: []`.
   
   ### The fix (interception only — no snapshots, no flush-time diffing)
   
   **1. Wrappers track every mutation path.** `iterator()`/`listIterator()` now 
return dirty-marking iterators (covers `removeAll(Closure)`, 
`retainAll(Closure)`, `removeIf`), plus the missing direct overrides: 
`retainAll(Collection)`, `List.sort`, `List.replaceAll`. Same approach as 
Hibernate's `PersistentCollection`.
   
   **2. Generated setters keep tracking across reassignment.** 
Collection/List/Set/Map-typed properties assign through 
`DirtyCheckingSupport.rewrap`:
   
   ```groovy
   // generated setter, before:
   void setShares(List shares) { markDirty("shares", shares); this.shares = 
shares }
   // after:
   void setShares(List shares) { markDirty("shares", shares); this.shares = 
(List) DirtyCheckingSupport.rewrap(this, "shares", this.shares, shares) }
   ```
   
   `rewrap` wraps the new value **only when the value being replaced was itself 
a tracked wrapper** — otherwise it returns the raw value after one 
`instanceof`. 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 on 
`DirtyCheckableCollection`, so binary-compatible). `PersistentEntityCodec` then 
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 
tracking
   - `DirtyCheckCollectionReassignmentSpec` — reassignment loses tracking 
(List/Set/Map); never-tracked values stay untouched
   - `EmbeddedCollectionDirtyTrackingSpec` — end-to-end against MongoDB, 
replicating the production shape (an auto-timestamped entity: the `lastUpdated` 
write during flush resets the explicit-save dirty marker, so persistence 
depends entirely on the wrappers)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to