matrei commented on PR #16344:
URL: https://github.com/apache/grails-core/pull/16344#issuecomment-5703596659

   ## Review round 2
   
   **Head:** `293fb7768c` (3 commits) · **Base:** `8.1.x` · Merge-base 
`0980623`, `git diff --check` clean.
   
   **What I ran locally** (`cleanTest` + `--no-build-cache`, XML reports 
verified):
   
   | Run | Result |
   |---|---|
   | `grails-datamapping-core:test` (full) | 983 pass, 1 skipped |
   | `grails-data-hibernate5-core:test` (full, incl. 
`Hibernate5RefreshLockSpec` 43/43) | 881 pass, 39 skipped |
   | `grails-data-hibernate7-core:test` (full, incl. 
`Hibernate7RefreshLockSpec` 43/43) | 3122 pass, 29 skipped |
   | `codeStyle` on the three modules | clean |
   
   Thanks for the thorough round. Everything from round 1 is addressed and I 
re-verified each item (list at the end). The Hibernate 5 dirty reset now 
follows the refresh cascade, the detached-instance contract is enforced 
identically on both versions, the doc example is fixed, the static form no 
longer does the unlocked `get`, `book.lock(refresh: true)` gets a useful 
message, the constants moved off the public interfaces, and 
`GormEntityApi.refresh(Map)` has a body so the trait stays source-compatible. 
The `type` argument is a sensible generalisation and its parsing 
(`RefreshLockArguments`) is well covered.
   
   I again ran throwaway specs against both Hibernate versions to probe the new 
code paths. Two of them found real problems, one of them serious.
   
   ### Findings
   
   #### 1. Hibernate 7: `refresh(lock: true)` acquires **no database lock at 
all** when a refresh-cascaded association is join-fetched (high)
   
   `HibernateGormInstanceApi.groovy:266-271` sets `Locking.FollowOn.IGNORE` on 
every locked refresh. In Hibernate 7.4.7 that option does not "skip follow-on 
locking and keep the root row locked"; it makes the SQL translator drop the 
lock clause entirely. `AbstractSqlAstTranslator.determineLockingStrategy` 
returns `LockStrategy.NONE` for `IGNORE` whenever the select contains an outer 
join and the dialect cannot lock outer-joined rows 
(`H2LockingSupport.getOuterJoinLockingType() == IGNORED`, same for PostgreSQL). 
The `Locking.FollowOn.IGNORE` Javadoc says as much: *"This can lead to rows not 
being locked when they are expected to be."*
   
   The new cascade test doesn't catch this because it only asserts 
`session.getCurrentLockMode(parent) == PESSIMISTIC_WRITE`, which is the lock 
mode Hibernate *recorded* on the entity entry, not evidence that a lock was 
taken. Probe (H2, `Hibernate7RefreshLockCascadeParent` from your spec, `child 
cascade: 'all', lazy: false`): transaction A does `parent.refresh(lock: true)` 
and holds; transaction B runs a raw `select id from 
hibernate7_refresh_lock_cascade_parent where id = ? for update` over JDBC.
   
   | Scenario | SQL issued by the refresh | Contender acquired the row while A 
held it? |
   |---|---|---|
   | `Hibernate7RefreshLockBook` (no association), `refresh(lock: true)` | `... 
where id=? for update` | no (correct) |
   | `Hibernate7RefreshLockCascadeParent`, `refresh(lock: true)` | `select ... 
from ..._parent left join ..._child ... where id=?` — **no `for update`** | 
**yes** |
   | `Hibernate7RefreshLockCascadeParent.lock(id, refresh: true)`, entity not 
in session (goes through `session.find`, default follow-on) | locked via 
follow-on select | no (correct) |
   
   So on H2 and PostgreSQL, any entity with an eagerly fetched refresh-cascaded 
association silently gets an unlocked refresh while `getCurrentLockMode` 
reports `PESSIMISTIC_WRITE`. For a feature whose whole purpose is "reload under 
the lock", that is worse than the NullPointerException it replaces. Hibernate 5 
is fine: its refresh issues the parent select `for update` and refreshes the 
child in a separate unlocked select (verified in the same probe).
   
   I did verify the NullPointerException you worked around: with the default 
follow-on strategy, `session.refresh(parent, new 
LockOptions(PESSIMISTIC_WRITE))` on H2 with the child already in the 
persistence context fails with `Cannot invoke 
"EntityInitializer.getNavigablePath()" because the return value of 
"EntityHolder.getEntityInitializer()" is null`. I couldn't find a matching 
upstream ticket in a quick search. The static form (`session.find` with the 
parent not yet loaded, child loaded or not) does not hit it.
   
   Suggested fix: don't let Hibernate decide whether the row is locked. One 
option that works on every dialect and keeps the "reload the version under the 
lock" semantics is to take the row lock first, by identifier and **without** a 
version check, and then run a plain (cascading) refresh under it, e.g. an 
HQL/criteria query for the id with `LockModeType.PESSIMISTIC_WRITE` (no join 
fetch, so no outer join, no follow-on) followed by `session.refresh(instance)`. 
Two statements, but no window between them in which a stale version can fail 
the lock, because the lock is already held when the refresh reads. If you would 
rather keep the single-statement path, use `Locking.FollowOn.DISALLOW` so the 
unsupported case fails loudly instead of silently unlocking, and document the 
limitation. Either way, please add a contention test for the cascade parent 
modelled on your existing *"waits for a competing commit and holds the write 
lock"* feature (a raw JDBC `for update` contender is the most direc
 t check), because `getCurrentLockMode` cannot detect this class of bug.
   
   Related: `session.refresh(Object, LockOptions)` is `@Deprecated(since = 
"7.0", forRemoval = true)` in Hibernate 7. The previous revision used the JPA 
`refresh(Object, LockModeType)` overload, which is not deprecated. If a 
`LockOptions` is still needed after the fix, `session.refresh(instance, 
(RefreshOption) lockOptions)` reaches the supported varargs overload.
   
   #### 2. Hibernate 5: locked refresh throws `Unknown entity` for an embedded 
component that holds a cascading association (medium)
   
   `AbstractHibernateGormInstanceApi.resetDirtyAfterRefresh` walks 
`persister.propertyCascadeStyles` and recurses into every value whose style 
cascades `REFRESH`. Hibernate computes a component property's cascade style as 
`ALL` as soon as any sub-property cascades 
(`Property.getCompositeCascadeStyle`), so an embedded component containing a 
cascading association is passed to `resetDirtyAfterRefresh` as if it were an 
entity. It is `DirtyCheckable` (embedded classes carry `@DirtyCheck`), so the 
method calls `session.getEntityPersister(null, component)`, and 
`MetamodelImpl.entityPersister` throws for a class that is not an entity.
   
   Probe (H5): `@Entity class Owner { Details details; static embedded = 
['details'] }`, `@DirtyCheck class Details { String note; CascadeChild child; 
static mapping = { child cascade: 'all' } }`:
   
   ```
   propertyCascadeStyles = [STYLE_NONE, STYLE_NONE, STYLE_ALL]   // version, 
title, details
   owner.refresh(lock: true)
     -> HibernateSystemException: Unknown entity: 
grails.gorm.tests.Hibernate5ProbeDetails
   ```
   
   Plain `refresh()` on the same entity works, and the row is already locked 
when this throws, so the caller gets an exception after the refresh has 
succeeded. Fix: in the loop, treat a component value as a container rather than 
an entity. `persister.propertyTypes[i].isComponentType()` tells you; for 
components, reset via the existing `resetDirty(root, ...)` (which already 
handles embedded objects) and recurse into the component's own cascading 
sub-properties, or simply skip values that are not entities 
(`session.factory.metamodel.entityPersisters.containsKey(...)` / 
`Hibernate.getClass`). Please add an embedded-with-association case to 
`Hibernate5RefreshLockSpec`.
   
   #### 3. `Book.lock(id, refresh: true)` returns the proxy's target instead of 
the proxy the caller holds (low)
   
   `findManagedInstance` uses `persistenceContext.getEntity(key)`, which 
returns the underlying entity for an initialized proxy, so the static form 
refreshes and returns the implementation object. `Book.lock(id)` (both 
versions) and `Book.get(id)` (Hibernate 7) return the proxy itself. Probe, 
initialized proxy in session:
   
   | | H5 | H7 |
   |---|---|---|
   | `Book.get(id).is(proxy)` | false | true |
   | `Book.lock(id).is(proxy)` | true | true |
   | `Book.lock(id, refresh: true).is(proxy)` | **false** (returns unproxied 
target) | **false** (returns unproxied target) |
   
   The docs say the call "returns that same managed instance", and a caller 
comparing with `is()` or relying on the returned object being the one they hold 
in a collection will be surprised. The uninitialized-proxy case goes through 
`session.find`, which returns the narrowed proxy (verified: `Book.lock(id, 
refresh: true).is(uninitializedProxy)` is true on both versions), so the two 
proxy states behave differently. Cheapest fix: after `getEntity`, also check 
`persistenceContextInternal.getProxy(key)` and return the proxy when one exists 
(refresh the unwrapped target, return the proxy). One feature per spec with an 
initialized proxy would pin it.
   
   #### 4. Minor
   
   - `GormStaticApi.lock(Map, Serializable)` (core) still resolves the instance 
API with a `null` qualifier for the `refresh: true` path. Both Hibernate static 
APIs now override it, so nothing shipped is affected, but any future datastore 
that implements `refresh(D, Map)` without overriding `lock(Map, Serializable)` 
inherits the round-1 named-connection bug. Passing `qualifier` there costs 
nothing.
   - `Hibernate7RefreshLockSpec` / `Hibernate5RefreshLockSpec` cascade feature: 
`session.getCurrentLockMode(...)` assertions should be complemented by a 
contention check (see finding 1); as written they would pass with no lock taken.
   - PR title and description still describe `lockLatest()`; the checklist 
still ticks `./gradlew build --rerun-tasks` while the text says it was not run.
   
   ### Round-1 items, re-verified
   
   - **Round-1 item 1, named connection:** `HibernateGormStaticApi.lock(Map, 
Serializable)` on both versions now does the whole operation on its own 
`hibernateTemplate`; the new *"uses the named connection rather than the 
default database"* feature checks the JDBC URL and that the default database is 
untouched. Good.
   - **Round-1 item 2, detached instances:** `session.contains` check before 
`proxyHandler.unwrap` on both versions, `IllegalArgumentException` with the 
same message, tested with and without a proxy, proxy not initialized. Good.
   - **Round-1 item 3, doc example:** now `Book.secondary.withTransaction { 
Book.secondary.get(1) ... }` with the explanatory callout. Good.
   - **Round-1 item 4, static form:** transaction check first, 
persistence-context lookup, single locked `find` when not loaded 
(statement-count assertion added). Good.
   - **Round-1 item 5, `book.lock(refresh: true)`:** guard in 
`GormEntity.lock(Serializable)`, tested in core and both Hibernate specs. Good.
   - **Round-1 item 6, cascade dirty reset (H5):** implemented and tested 
(`discards cascaded child edits without a spurious child update at flush`), 
documented in the H5 guide. Good, modulo finding 2 above.
   - **Round-1 item 7, nits:** constants moved to `RefreshLockArguments`, 
Javadoc no longer promises a JPA exception type on the datastore-agnostic 
interfaces, `GormEntityApi.refresh(Map)` has a default body with a 
direct-implementation test. Good.
   - `grails-doc/build.gradle` `hibernate7Guide` attribute: 
`https://grails.apache.org/docs/latest/grails-data/hibernate7/manual/` resolves 
(200), so the new links work.
   
   **Verdict:** request changes for finding 1 (silent no-lock on H2/PostgreSQL 
with an eager refresh-cascaded association, and the test cannot see it) and 
finding 2 (crash). Findings 3 and 4 at your discretion.
   


-- 
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