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

   ## Review round 5
   
   **Head:** `d60b7a74fc` (7 commits) · **Base:** `8.1.x` · Merge-base 
`0980623`, `git diff --check` clean.
   
   **What I ran locally** (`cleanTest` + `--no-build-cache`, XML report 
timestamps verified):
   
   | Run | Result |
   |---|---|
   | `grails-data-hibernate5-core:test` (full, incl. 
`Hibernate5RefreshLockSpec` 77/77) | 915 tests, 0 failures, 39 skipped |
   | `grails-data-hibernate7-core:test` (full, incl. 
`Hibernate7RefreshLockSpec` 79/79) | 3158 tests, 0 failures, 29 skipped |
   | `codeStyle` on both modules | clean |
   | GitHub checks | 76 passed, 1 failed (see below), 1 pending |
   
   Both round-4 items are in: `lockRow` picks the concrete entity for 
union-mapped hierarchies and the root otherwise, both specs gain the 
table-per-concrete-class competing-commit pair, and the optional round-3 item 
is done too (`recordLockMode` sets the entry directly for 
`PESSIMISTIC_READ`/`WRITE`, with a statement-count feature pinning 2 vs 3). The 
`loaded.await || refreshing.get` diagnostic nit is applied in all three 
competing-commit features. Good.
   
   **The failing check is unrelated.** *Functional Tests (Java 25, indy=false)* 
fails in `grails-test-examples-scaffolding:integrationTest`, 
`UserControllerSpec > User list`, with a Geb `WaitTimeoutException` on the 
*Please sign in* page (`LoginPage.login`). The same job passed on the previous 
head `d2f76c9e3b`, the Java 21 and shard-1 Java 25 variants of the job pass on 
this head, and nothing in the two new commits touches that module. A rerun 
should clear it.
   
   As before I ran throwaway specs against both versions for what the new tests 
do not reach: proxies in the session, repeated lock calls in one transaction, 
and the plain lock forms on the union hierarchy. One of them found a new 
problem in the union branch.
   
   ### Findings
   
   #### 1. Hibernate 7: the union branch fails with `HibernateSystemException` 
when the managed instance is a root-typed proxy (medium)
   
   `lockRow` now names the instance's concrete entity in the HQL but still 
binds `instance` itself. When the static form finds a *root-typed* proxy in the 
session (`findManagedInstance` deliberately returns the proxy) for a row whose 
concrete class is a subclass, the query is `select 1 from Sub e where e = 
:instance` with a `Root$HibernateProxy` bound to a `Sub`-typed parameter. 
Hibernate cannot coerce it and fails before anything is locked:
   
   ```
   org.grails.orm.hibernate.support.hibernate7.HibernateSystemException:
     Could not convert 'grails.gorm.tests.ProbeR5bUnionRoot$HibernateProxy' to 
'java.lang.Long'
     using 'org.hibernate.type.descriptor.java.LongJavaType' to wrap
   ```
   
   Reproducer (`Root` mapped `tablePerConcreteClass true`, `id generator: 
'increment'`; `Sub extends Root`; row is a `Sub`):
   
   ```groovy
   Sub.withTransaction {
       def proxy = Root.load(id)
       Hibernate.initialize(proxy)      // or any access that initializes it, 
e.g. proxy.title
       Root.lock(id, refresh: true)     // throws on Hibernate 7
   }
   ```
   
   An initialized root-typed proxy is what a lazy to-one association declared 
with the root type holds after first use, so this is reachable from ordinary 
code. Joined and single-table hierarchies are unaffected because their query 
names the root and the proxy is root-typed. Hibernate 5 handles the same shape 
(`session.refresh(proxy, lockMode)` unproxies internally): all my proxy 
variants pass there. The instance form on Hibernate 7 is also fine because 
GORM's proxy forwards `refresh` to the target, so `lockRow` receives the real 
`Sub`.
   
   Probe matrix on Hibernate 7 (`Root.load(id)` first, then the static call; 
contender is a raw JDBC `FOR UPDATE` on the concrete table):
   
   | Proxy state | Call | `d60b7a74fc` | with the fix |
   |---|---|---|---|
   | uninitialized | `Root.lock(id, refresh: true)` | not-loaded branch, `find` 
through the union, **no lock** (pre-existing `lock(id)` limit, see #2) | same |
   | **initialized** | `Root.lock(id, refresh: true)` | 
**`HibernateSystemException`** | locked, `PESSIMISTIC_WRITE` |
   | uninitialized | `Sub.lock(id, refresh: true)` | locked (find on the 
concrete table) | same |
   | uninitialized | `Sub.load(id)` then `Sub.lock(id, refresh: true)` | 
locked, returns the proxy | same |
   
   **Fix (verified locally):** bind the unproxied target. 
`session.getEntityName(instance)` has already initialized the proxy in this 
branch, so it costs nothing there:
   
   ```groovy
   .setParameter('instance', Hibernate.unproxy(instance))
   ```
   
   With that one-line change all 11 probe features pass and 
`Hibernate7RefreshLockSpec` still passes 79/79. If you would rather keep an 
uninitialized proxy uninitialized on the joined/single-table branches (where 
binding the proxy works today), make it `descriptor instanceof 
UnionSubclassEntityPersister ? Hibernate.unproxy(instance) : instance`. Please 
add the reproducer above as a feature next to the union competing-commit one.
   
   #### 2. Docs: the union-subclass NOTE describes the limit too narrowly (low)
   
   The new NOTE in the Hibernate 7 guide says the union rendering applies to 
"an already-loaded instance of a class that has subclasses". On Hibernate 7 the 
entity's `session.lock` always goes through the *root* persister, so it applies 
to every class in the hierarchy, leaf included. Measured on H2 with `Sub` a 
leaf under a union root (contender = raw JDBC `FOR UPDATE` on the concrete 
table after the call):
   
   | Form on a union hierarchy | Row locked? |
   |---|---|
   | `Sub.get(id).lock()` | **no** (`select ... from (select ... from 
probe_union_sub) ... for update`) |
   | `Sub.lock(id)` with the instance managed | **no** |
   | `Root.lock(id)` / `Root.lock(id, refresh: true)` not loaded | **no** 
(`find` through the union) |
   | `Sub.lock(id)` not loaded | yes (plain concrete table) |
   | `refresh(lock: true)` / managed `lock(id, refresh: true)` on a leaf 
instance, loaded via `Sub` or `Root` | yes (this PR) |
   
   Suggested wording: *"In a hierarchy mapped with `tablePerConcreteClass 
true`, Hibernate 7 locks through a union of the concrete tables whenever it 
goes through the hierarchy root: `lock()` and `lock(id)` on a managed instance 
of any class in the hierarchy, and `lock(id)` or `lock(id, refresh: true)` 
through the root class for a row not yet loaded. Some databases, H2 among them, 
do not lock rows through such a query. `refresh(lock: true)` and `lock(id, 
refresh: true)` on a managed instance of a class without subclasses, and 
`lock(id)` through that class for a row not yet loaded, lock its concrete table 
directly."* The `lockRow` javadoc's last sentence ("the same limit `lock()` 
has") could say `lock()` has it for every class in the hierarchy. The plain 
`lock()` behaviour itself is pre-existing Hibernate 7 and deserves a separate 
issue, not this PR.
   
   #### 3. `recordLockMode` can downgrade the recorded lock (low)
   
   Setting the entry unconditionally records a weaker mode than the transaction 
holds, which Hibernate's own `upgradeLock` never does 
(`requestedLockMode.greaterThan(entry.getLockMode())`). Measured on Hibernate 
7, same transaction:
   
   | Sequence | Recorded after (`getCurrentLockMode`) | Before `2842f51fe0` |
   |---|---|---|
   | `lock()` then `refresh(lock: PESSIMISTIC_READ)` | `PESSIMISTIC_READ` | 
`PESSIMISTIC_WRITE` |
   | `refresh(lock: true)` then `refresh(lock: PESSIMISTIC_READ)` | 
`PESSIMISTIC_READ` | `PESSIMISTIC_WRITE` |
   | `refresh(lock: PESSIMISTIC_FORCE_INCREMENT)` then `refresh(lock: true)` | 
`PESSIMISTIC_WRITE` | `PESSIMISTIC_FORCE_INCREMENT` |
   
   The database lock is unaffected (the exclusive lock is still held), so the 
consequence is only what `getCurrentLockMode` reports and a redundant 
version-checked re-lock statement on a later `lock()`. Guarding with `if 
(requested.greaterThan(entry.lockMode)) entry.setLockMode(requested)` restores 
Hibernate's semantics. Optional.
   
   ### Verified as correct
   
   - **Union branch (real instances):** `select 1 from <Sub> e ... for update` 
on the concrete table for `Sub.get`, `Root.get` (concrete class `Sub`) and the 
static forms; all four competing-commit rows pass on both versions in the fresh 
full runs. The `#16349` reference for the insert-then-update version start is 
accurate (I saw the same with the `table` generator in round 4).
   - **`recordLockMode`:** the statement-count feature holds (2 for 
`PESSIMISTIC_READ`/`WRITE`, 3 for `FORCE_INCREMENT`). Follow-ups in the same 
transaction behave: `refresh(lock: true)` then `lock()` issues 0 statements and 
keeps `PESSIMISTIC_WRITE`; then plain `refresh()` issues 1 and keeps it 
(7.4.7's HHH-19937 restore); `refresh(lock: PESSIMISTIC_READ)` then 
`refresh(lock: true)` upgrades to `PESSIMISTIC_WRITE`; a second `refresh(lock: 
true)` costs 2 again. `EntityEntryImpl.setLockMode` throws 
`UnsupportedLockAttemptException` for a non-mutable persister, which is exactly 
what `session.lock` threw before, and GORM's mapping DSL has no immutable 
keyword, so no behaviour change there.
   - **Proxies on the instance form:** `Sub.load(id).refresh(lock: true)` locks 
and returns the proxy on both union and joined hierarchies. 
`Root.load(id).refresh(lock: true)` returns the target rather than the proxy, 
but so do plain `refresh()`, `lock()` and `save()` on a root-typed proxy (GORM 
proxy forwarding, pre-existing), so nothing new.
   - **Hibernate 5:** every static and instance form with a root or leaf proxy 
in the session works on union and joined hierarchies, and the union hierarchy 
locks the concrete table for every form, so no Hibernate 5 change is needed.
   - **Tests and helpers:** the H5 union entities use `id generator: 
'increment'`, the H7 spec asserts versions relative to the saved instance with 
an accurate comment, and the shared helpers are unchanged.
   
   **Verdict:** request changes for finding 1 (deterministic exception on a 
supported mapping, one-line fix verified). Findings 2 and 3 at your discretion, 
though the NOTE wording is worth fixing while you are in the file.
   


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