borinquenkid opened a new pull request, #16066:
URL: https://github.com/apache/grails-core/pull/16066
# feat: GORM O(M+N) scaling — GormRegistry, SessionResolver infrastructure,
and core-class tests (consolidates #15779, #15780, #15790)
## Why one PR
The previous 3-PR stack (#15779 infra → #15780 implementation → #15790
tests) generated review churn because the infrastructure PR added public API
whose callers lived one PR downstream, so "no caller in this PR" objections and
"the tests are elsewhere" objections could not both be answered at once. Per
discussion with @jdaugherty, the stack is consolidated into this single PR:
every new API lands next to its consumer and its tests.
Supersedes and closes #15779, #15780, #15790.
## Summary
Extracts all per-entity, per-qualifier GORM API state out of `GormEnhancer`
into a `GormRegistry` singleton. `GormEnhancer` becomes a thin facade and
delegates entity registration, API lookup, and datastore lifecycle to the
registry. APIs are created by a pluggable `GormApiFactory` and looked up by
`(entityClass, qualifier)` at call time — collapsing up-front API allocation
from O(entityCount × tenantCount) to O(entityCount + tenantCount), with
per-(entity, qualifier) APIs materialized lazily on first use.
Modules touched: `grails-datamapping-core` (registry + enhancer, bulk of the
diff), `grails-datastore-core` (session-resolution infrastructure below),
Hibernate 5/7, MongoDB, Simple adapters (minimal wiring; the full adapter
migrations remain follow-up PRs), plus TCK/test-example updates.
### grails-datastore-core infrastructure (formerly #15779)
- `SessionResolver` + `TransactionSynchronizationSessionResolver`: a
stateless view over the existing
`SessionHolder`/`TransactionSynchronizationManager` state — one authoritative
session store, no parallel bookkeeping. `resolve()` performs the same
validation housekeeping as `doGetSession` (evicts disconnected sessions,
unbinds a holder emptied by eviction unless a transaction owns it).
- `AbstractDatastore`: lazy resolver accessor; `destroy()` closes
thread-bound sessions via `DatastoreUtils.closeSession`, skipping holders owned
by an active transaction; `hasCurrentSession()` now agrees with
`getCurrentSession()` (validated-session semantics); publisher wiring no longer
routes through the deprecated `getApplicationContext()`.
- `DatastoreUtils`: new `executeWithNewSession(..)` overloads (used by
`GormStaticApi`); `execute`/`doWithSession` now stack via `bindNewSession` and
clean up via the one canonical `unbindSession`, so a bound-but-empty holder can
never fail a later bind.
- `SessionHolder.getSessions()` (used by `destroy()`),
`AbstractConnectionSourceFactory` fallback-settings extraction,
`MultipleConnectionSourceCapableDatastore` (used by
`GormApiResolver`/`AbstractGormApi`/`GormRegistry`), and an
`AstUtils.copyAnnotations` dedup guard needed by `ServiceTransformation` (with
`AstUtilsSpec` coverage).
Note on `SessionResolver.bind()`/`unbind()`: `resolve()` is what core
consumes (via `hasCurrentSession()`); `bind`/`unbind` complete the SPI contract
that the per-adapter follow-up PRs implement (e.g. a Hibernate resolver
bridging native `SessionFactory`-keyed bindings). They are specified, tested,
and small; flagging rather than hiding that their production callers arrive
with the adapter PRs.
## Review response (2026-07-29 round on #15779)
1. **Scope**: `Query.java`, `Service.groovy`, `DefaultServiceRegistrySpec`,
the `AbstractPersistentEntity.getTenantId()` fallback + `isMultiTenant` swap,
and the `createPropertyResolver` cast are reverted. The
`CustomizableRollbackTransactionAttribute` copy-semantics change is split into
its own PR (#16063, branch `fix/customizable-rollback-tx-attribute-copy`) with
the reworked implementation per review (Spring copy constructors, label
independence, no lazy-getter mutation of the source, `timeoutString` preserved,
the GString-in-`.java` log fixes) and behavior-level tests through
`GrailsTransactionTemplate`/`DefaultTransactionService`. The same
lossy-copy-constructor pattern was also found in two sibling classes and fixed
in follow-up PRs #16064 (`grails.gorm.transactions.GrailsTransactionAttribute`)
and #16065 (`org.grails.transaction.GrailsTransactionAttribute`).
2. **API duplication**: the unbind sequence now exists exactly once
(`DatastoreUtils.unbindSession`); `executeWithNewSession`, `execute`,
`doWithSession`, and `TransactionSynchronizationSessionResolver.unbind()` all
delegate to it (also fixing the TSM-key mismatch for sessions owned by a child
datastore — regression test added). The `AbstractDatastore` event-publisher
machinery (third publisher implementation + reflective listener registration)
is removed entirely rather than moved: nothing in this stack or the adapter
follow-ups calls it; concrete datastores keep publishing through their own
`ConfigurableApplicationEventPublisher`, as today.
`getApplicationEventPublisher()` stays null when no context is configured (no
per-query event allocation for bare datastores — unchanged from 8.0.x).
3. **Speculative API**: removed as caller-less —
`Datastore.getSessionResolver()` default method (allocated per call),
`AbstractConnectionSourceFactory.createSettings(PropertyResolver)`, the 3-arg
`ConnectionSourceSettingsBuilder` constructor.
Inline-comment items: constructor `this`-escape fixed (lazy resolver);
`bind()` rejects a session owned by a different datastore (spec added);
`resolve()` uses validated sessions with disconnected-session and empty-holder
specs; resolver class renamed since it holds no `ThreadLocal`; interface
generics dropped; `@author` tags use a real name; `unbind()` keeps its name
(matching `DatastoreUtils.unbindSession`, to which it now delegates) with the
close-on-unbind contract stated explicitly in the interface javadoc.
## Fixes from the post-consolidation contrarian review
An adversarial multi-agent review of the consolidated branch surfaced and
fixed:
- `DatastoreUtils.execute`/`doWithSession` could throw
`IllegalStateException("Already value bound")` when a bound-but-empty
`SessionHolder` remained on the thread; they now stack via `bindNewSession` and
release via `unbindSession` (spec added).
- `TransactionSynchronizationSessionResolver.resolve()` left an emptied
holder bound (poisoning later binds) and returned null when a valid session sat
beneath a stale one; it now resolves down the stack and unbinds an emptied
non-transactional holder (specs added, including the transaction-owned case).
- `GormStaticApi.withStatelessSession` had been rewired through
`executeWithNewSession`, silently handing out **stateful** sessions and
dropping the `UnsupportedOperationException` guard; baseline
`connectStateless()` behavior restored.
- `GormStaticApi.saveAll` force-flushed mid-transaction, deviating from the
8.0.x baseline it claimed to restore; the flush is removed.
- `TenantDelegatingGormOperations.delete(instance, params)` called `save`
instead of `delete` — a pre-existing 8.0.x data-integrity bug in a touched
class; fixed with a delegation spec that would have caught it.
- Cross-datastore tenant bleed: three selector paths in `GormApiResolver`
read the tenant via no-arg `CurrentTenantHolder.get()` (an arbitrary
datastore's tenant); they now pass the datastore being evaluated.
- Registry races: `registerEntityDatastores` rebuilt an entity's routing map
with remove-then-repopulate (a concurrent lookup could observe no routing and
fall back to the wrong datastore) — it now publishes the rebuilt map
atomically; `AbstractGormApiRegistry.getDirect` could cache a qualified API
derived from a superseded default API indefinitely — it now re-validates after
publishing and retracts if superseded.
- Memory: `GormRegistry.removeDatastore` now clears the normalization caches
(`Class`-keyed keys retain classloaders across dev reloads; the qualifier cache
grew per tenant-ID ever seen) once the last datastore is gone.
- The `ActiveSessionDatastoreSelector` ≤10-datastore fallback scan is now
documented as the only discovery path for non-`Datastore`-keyed sessions (e.g.
Hibernate's `SessionFactory` keying) and as intentionally bounded.
### Known limitations (deliberate, documented)
- Steady-state memory is still O(entities × qualifiers-actually-used) —
lazily materialized, never eagerly allocated. The eager win is
allocation/startup, not asymptotic worst-case residency.
- Beyond 10 registered datastores, unbound non-transactional Hibernate
sessions are not discovered by the selector fallback (routing falls back to the
entity's DEFAULT datastore); deployments at that scale should bind sessions
explicitly (transactions / `Tenants.withId`).
- The six per-adapter follow-up branches predate this consolidation's
resolver rework (renamed class, no setter, private field) and must be rebased
onto it before review.
## Test plan
- [x] `./gradlew :grails-datastore-core:test :grails-datamapping-core:test
:grails-data-simple:test` — 0 failures
- [x] `./gradlew :grails-data-hibernate5-core:test
:grails-data-hibernate7-core:test :grails-data-mongodb-core:test` — 0 failures
- [x] `codeStyle` (Checkstyle + CodeNarc) clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
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]