(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779
(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779borinquenkid wants to merge 9 commits into
Conversation
da5001a to
4b9d006
Compare
There was a problem hiding this comment.
Pull request overview
Introduces foundational datastore-layer infrastructure needed for the upcoming GormRegistry O(M+N) scaling work by adding session-resolution abstractions, expanding mapping-context lifecycle hooks, and tightening several low-level behaviors across datastore + Hibernate adapters.
Changes:
- Add
SessionResolver+ defaultThreadLocalSessionResolver, and wire session lookup intoDatastore/AbstractDatastoreandDatastoreUtils. - Extend
MappingContext/AbstractMappingContextinitialization and multi-tenancy configurability; adjust key mapping-context implementations accordingly. - Refine supporting utilities and behaviors (query pre-event source, dirty-checking semantics, transaction attribute copying, Hibernate template TSM handling) and add/adjust targeted tests.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy | Adjust test service stub to include a datastore property. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolverSpec.groovy | New unit spec covering basic bind/resolve/unbind for ThreadLocalSessionResolver. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/SessionResolverIntegrationSpec.groovy | New spec exercising resolver availability through an AbstractDatastore subclass. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy | New spec validating event publisher behavior and session creation event publication. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java | Improve copying semantics (incl. qualifier/rollbackRules/connection) and fix trace logging strings. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy | Minor formatting-only adjustment. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java | Add getIntegerFromMap helper. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy | Avoid duplicating annotations when copying to a target node. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java | Update PreQueryEvent creation to use (source, query) constructor. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java | Add initialize(ConnectionSourceSettings) and setMultiTenancyMode(...) hooks. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java | Lazily resolve tenantId property and fix multi-tenant check call site. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java | Make initialize public and add setter for multi-tenancy mode. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java | Switch to GormMappingConfigurationStrategy and route through updated initialization. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java | Align initialize visibility with new MappingContext contract. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy | Expand dirty-checking to detect changes within initialized collections and DirtyCheckableCollection. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy | New default session resolver implementation. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy | New session-resolver SPI. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java | Prefer resolver-based session lookup; add execute-with-new-session helpers; adjust session binding behavior and a generic array cast. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java | Add getSessionResolver() to the datastore contract. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy | Add constructor supporting fallback configuration. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java | Add createSettings(PropertyResolver) convenience. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java | Add default event publisher + listener wiring, session resolver field, destroy-time unbind, and updated “current session” logic. |
| grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerAllQualifiersSpec.groovy | Expand tests around qualifier allocation, lazy API creation, and close semantics. |
| grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy | Add enhancer registry, eager-vs-lazy API qualifier separation, lazy API initialization, and safer close semantics. |
| grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java | Align initialize visibility with new mapping-context contract. |
| grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GrailsHibernateTemplateSpec.groovy | Add regression coverage for TSM restoration when DS is bound without SF. |
| grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy | New spec verifying lazy API allocation behavior across multi-tenancy modes. |
| grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTemplate.java | Unbind/rebind DataSource holders independently of SessionFactory holders in executeWithNewSession. |
| grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/GrailsHibernateTemplateSpec.groovy | New regression test for TSM restoration in Hibernate 5 template. |
| grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy | New spec verifying lazy API allocation behavior across multi-tenancy modes (Hibernate 5). |
| grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTemplate.java | Same TSM unbind/rebind fix as Hibernate 7 template. |
| grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateDatastore.java | Trivial whitespace removal. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15779 +/- ##
==================================================
+ Coverage 49.4833% 51.1080% +1.6247%
- Complexity 16883 17459 +576
==================================================
Files 1998 2014 +16
Lines 93672 94310 +638
Branches 16400 16482 +82
==================================================
+ Hits 46352 48200 +1848
+ Misses 40169 38899 -1270
- Partials 7151 7211 +60
🚀 New features to boost your workflow:
|
- ThreadLocalSessionResolver: qualifier-bound sessions were stored in a single shared ConcurrentHashMap, leaking across threads; move to a ThreadLocal<Map> and clear it on unbind(). - AbstractDatastore.DefaultApplicationEventPublisher: dispatched every event to every listener regardless of declared generic type, risking ClassCastException for typed listeners; filter via Spring's own GenericApplicationListenerAdapter/ResolvableType before invoking, and make the listener list a CopyOnWriteArrayList. - DatastoreUtils.bindSession: silently skipped binding when a SessionHolder was already present for the datastore, which could leave a freshly created session unbound (e.g. DataTestSetupInterceptor creates a new session per test method). Add the session to the existing holder instead, matching bindNewSession's push/pop semantics. - Align new spec file license headers with the standard ASF header used elsewhere in the module. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduce SessionResolver and ThreadLocalSessionResolver for thread-safe session lookup without coupling callers to a specific Datastore instance. Extend AbstractDatastore, Datastore, DatastoreUtils, and MappingContext with the hooks GormRegistry needs for O(M+N) API registration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pilation Making getDatastore()/setDatastore() abstract in the Service trait breaks @CompileStatic classes that implement the trait (e.g. DefaultTenantService), because Groovy's static compiler does not properly satisfy trait abstract method contracts when the implementing class declares the same method in its own body. Restore the original backing-field implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MappingContext interface declares initialize(ConnectionSourceSettings) as public; MongoMappingContext.initialize was protected, which Java rejects as assigning weaker access privileges to an interface method implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ThreadLocalSessionResolver: qualifier-bound sessions were stored in a single shared ConcurrentHashMap, leaking across threads; move to a ThreadLocal<Map> and clear it on unbind(). - AbstractDatastore.DefaultApplicationEventPublisher: dispatched every event to every listener regardless of declared generic type, risking ClassCastException for typed listeners; filter via Spring's own GenericApplicationListenerAdapter/ResolvableType before invoking, and make the listener list a CopyOnWriteArrayList. - DatastoreUtils.bindSession: silently skipped binding when a SessionHolder was already present for the datastore, which could leave a freshly created session unbound (e.g. DataTestSetupInterceptor creates a new session per test method). Add the session to the existing holder instead, matching bindNewSession's push/pop semantics. - Align new spec file license headers with the standard ASF header used elsewhere in the module. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9145c07 to
0f8e6c6
Compare
jdaugherty
left a comment
There was a problem hiding this comment.
Thanks for the work on this. I do not think the PR is ready to merge in its current form.
The primary concern is architectural: SessionResolver introduces a second independently mutable source of truth alongside TransactionSynchronizationManager and SessionHolder, then gives it precedence during lookup. This can cause work to execute on one session while transaction commit or rollback operates on another, and it breaks nested and executeWithNewSession semantics. I recommend keeping SessionHolder as the authoritative session stack and composing the resolver as an adapter over it.
The event-publisher changes have a similar parallel-state problem. Concrete datastores publish through their own publisher fields, while the new listener API registers against the superclass publisher. Using a datastore-owned Spring multicaster with an optional outbound publisher delegate would provide one consistent path without custom dispatch or reflection.
The PR also widens public interfaces with mutable lifecycle methods and includes several unrelated behavioral changes, including key-value JPA mapping, tenant metadata, dirty checking, AST annotation copying, and transaction-attribute copying. These should be removed or split into focused PRs with dedicated tests.
The module tests pass, but they do not cover the transaction precedence, nested-session restoration, concrete datastore publisher, or cleanup scenarios raised in the inline comments. My recommendation is to address the session and event architecture first, narrow the public API changes, and separate the unrelated behavior changes before proceeding.
| @Override | ||
| void bind(S session) { | ||
| currentSession.set(session) | ||
| // Note: In a production scenario, we'd need to link the session's datastore qualifier here. |
There was a problem hiding this comment.
This placeholder comment ("In a production scenario, we’d need to...") signals the design is incomplete and should not ship as the default core implementation. A single value also cannot support nested scopes: bind(A); bind(B); unbind() leaves no current session instead of restoring A, while the existing SessionHolder already has push/pop semantics. Qualified bindings have the same replacement problem, and plain unbind() unexpectedly clears every qualified binding as well. Prefer composing the default resolver over TSM/SessionHolder; if an independent context is genuinely required, it needs scoped stack semantics and automatic cleanup tied to session close.
There was a problem hiding this comment.
The independent ThreadLocal source has been replaced with the shared SessionHolder, but nested resolver cleanup is still incomplete. bind() pushes onto the holder while unbind() removes the entire holder, so bind(A); bind(B); unbind() loses A instead of restoring it. I am leaving this thread open and adding a current-line comment.
There was a problem hiding this comment.
Replied on the current-line thread you split this into — see the comment on ThreadLocalSessionResolver.groovy re: bind()/unbind(). Fixed in 48c9c49.
…hitecture Responds to jdaugherty's CHANGES_REQUESTED review (#15779). Fixes the session/event architecture concerns, narrows the public API surface, and splits out the unrelated behavioral changes he flagged, per that review's blast-radius check against PR2/PR3 (#15780/#15790) - neither depends on anything reworked here beyond hasCurrentSession(), which is now strictly more correct. Session/event architecture (the core concern): - SessionResolver/ThreadLocalSessionResolver no longer maintain independent ThreadLocal state. They're now a thin, stateless view over the same SessionHolder/TSM store DatastoreUtils already uses, so resolve() can never disagree with the transactional session. Nested scopes fall out for free from SessionHolder's existing stack. - DatastoreUtils.doGetSession() no longer short-circuits through the resolver before transaction-synchronization registration and session validation - that shortcut silently bypassed both, plus the allowCreate contract. - AbstractDatastore.hasCurrentSession() collapses to a single check now that resolver and TSM read the same state instead of being OR'd together. - Dropped the unused, asymmetric resolve(String)/bind(String, S) qualifier surface from SessionResolver (zero callers anywhere in the codebase; the concrete class's own bind() admitted the feature was never finished). - Replaced the hand-rolled event publisher with one composing SimpleApplicationEventMulticaster. addApplicationListener() now routes through getApplicationEventPublisher() (virtual) instead of the raw field, so it reaches whatever publisher a subclass (Mongo/Hibernate/Neo4j) actually publishes through, without touching those modules. - Fixed the applicationEventPublisher triple-assignment and the bug where setApplicationContext(null) discarded a caller-installed custom publisher. - @PreDestroy now closes every session held by the current thread's SessionHolder instead of just dropping the reference. API surface: - Datastore.getSessionResolver() is now a default method (was abstract - broke every external implementer); the default is now safe to construct per-call since the resolver holds no private state of its own. - MappingContext.initialize(ConnectionSourceSettings) is back to protected on AbstractMappingContext, not promoted onto the public interface - nothing needed the promotion. Restored/fixed semantics: - DatastoreUtils.bindSession()/bindSession(creator) fail fast again (IllegalStateException) on a double-bind, instead of silently stacking - bindNewSession() already provides stacking for callers that need it (used internally by executeWithNewSession). - CustomizableRollbackTransactionAttribute's copy constructors now deep-copy the rollback-rule list instead of aliasing the source's mutable list, and also copy transaction labels. - AbstractConnectionSourceFactory.createSettings() now composes the same fallback-settings path create(name, configuration) uses, so it also applies the injected TenantResolver/customTypes. - Deduplicated DatastoreUtils.executeWithNewSession's void-overload to delegate instead of copy-pasting the whole method body. Split out (unrelated to SessionResolver infrastructure, reverted from this PR): - KeyValueMappingContext's JpaMappingConfigurationStrategy -> GormMappingConfigurationStrategy swap - untested, no registry-related justification found. - DirtyCheckingSupport's O(elements)/transitive dirty-checking change - algorithmic and semantic change, zero tests. - AstUtils's annotation-copy dedup change - unrelated AST behavior change, no coverage. - Dropped MappingContext.setMultiTenancyMode and ClassUtils.getIntegerFromMap - zero callers anywhere in the codebase. Every touched class has new or updated Spock coverage, including the specific gaps the review called out as untested: transaction precedence (resolver reads the same store as TSM), nested-session restoration, concrete-datastore publisher wiring, and @PreDestroy cleanup. Full test sweep across grails-datastore-core, grails-datamapping-core, grails-data-mongodb-core, grails-data-simple, grails-data-hibernate5-core, and grails-data-hibernate7-core: BUILD SUCCESSFUL, 0 failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@jdaugherty Thanks for the thorough review — pushed a commit (23f6b48) addressing all of it. Session/event architecture (your primary concern): Event publisher: replaced the hand-rolled dispatch with one composing API surface: Split out of this PR (reverted, all untested and unrelated to Other fixes: Every touched class has new/updated Spock coverage for exactly the gaps you called out as untested — transaction precedence, nested-session restoration, concrete-datastore publisher wiring, I checked the blast radius against #15780/#15790 before touching the resolver: neither depends on anything here beyond |
…coverage gaps getTenantId()'s lazy fallback ignored the DISCRIMINATOR-mode check that initialize() uses and NPE'd when persistentProperties was null under deferred entity initialization. Also adds unit coverage for the still-live gaps Codecov flagged after review: Datastore's default getSessionResolver(), AbstractDatastore's reflective listener fallback, its Object-payload event wrapping and destroy() error handling, the bare-TransactionDefinition copy constructor, and the 3-arg ConnectionSourceSettingsBuilder constructor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR1 (#15779) correctly reverted this AstUtils.copyAnnotations change as unrelated scope creep on the SessionResolver PR, per jdaugherty's review. But merging that revert into this branch broke ServiceTransformSpec and MethodValidationTransformSpec: ServiceTransformation.groovy pre-annotates generated method impls with @NotTransactional/@readonly before calling copyAnnotations(method, methodImpl), and without the dedup guard, Groovy rejects the resulting duplicate annotation outright. This is where the fix actually belongs - it has real, previously-missing test coverage now (AstUtilsSpec) plus the existing integration coverage in ServiceTransformSpec/MethodValidationTransformSpec that demonstrably fails without it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|
||
| @Override | ||
| void unbind() { | ||
| TransactionSynchronizationManager.unbindResourceIfPossible(datastore) |
There was a problem hiding this comment.
bind() pushes onto the existing SessionHolder, but unbind() removes the entire holder. This still breaks nested scope restoration: after bind(A); bind(B); unbind(), resolve() returns null instead of A, and both sessions are detached without either being closed. The new nested test currently asserts this destructive behavior. Please remove/close only the top session and leave the outer binding intact.
There was a problem hiding this comment.
Fixed in 48c9c49. unbind() no longer calls unbindResourceIfPossible() unconditionally — it now pops and closes only the top session:
void unbind() {
SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
if (holder == null) {
return
}
Session session = holder.getSession()
if (session != null) {
holder.removeSession(session)
}
if (holder.isEmpty()) {
TransactionSynchronizationManager.unbindResourceIfPossible(datastore)
}
if (session != null) {
DatastoreUtils.closeSessionOrRegisterDeferredClose(session, datastore)
}
}Same removeSession()/isEmpty()/closeSessionOrRegisterDeferredClose() sequence DatastoreUtils.executeWithNewSession already used, so it's consistent with the rest of the session-binding code rather than a new pattern.
The nested test now asserts restoration instead of the destructive behavior:
when:
resolver.bind(first)
resolver.bind(second)
then: "resolve() returns the most recently bound (top-of-stack) session"
resolver.resolve() == second
when:
resolver.unbind()
then: "unbind() closes and pops only the top session, restoring the outer binding"
1 * second.disconnect()
0 * first.disconnect()
resolver.resolve() == first
when:
resolver.unbind()
then: "unbinding the last remaining session closes it and clears the binding entirely"
1 * first.disconnect()
resolver.resolve() == nullbind(A); bind(B); unbind() now leaves resolve() == A, with only B closed — verified this actually passes locally, not just trusting the commit message.
|
|
||
| public boolean hasCurrentSession() { | ||
| return TransactionSynchronizationManager.hasResource(this); | ||
| return sessionResolver.resolve() != null; |
There was a problem hiding this comment.
hasCurrentSession() uses the replaceable sessionResolver, but getCurrentSession() still delegates to DatastoreUtils.doGetSession(), which reads only TSM/SessionHolder. A custom resolver can therefore make this return true while getCurrentSession() throws or returns a different session. This also breaks DatastoreUtils.execute(), which branches here and then calls getCurrentSession(). Either remove the alternate resolver contract/setter or make both methods use the same authoritative resolution path.
There was a problem hiding this comment.
Fixed in 48c9c49. sessionResolver is now final, set once in the constructor to new ThreadLocalSessionResolver<>(this) — the alternate-resolver contract/setter you flagged (setSessionResolver()) is gone entirely; confirmed zero remaining references to it anywhere in the repo.
ThreadLocalSessionResolver no longer maintains independent state — its resolve() reads the exact same TransactionSynchronizationManager.getResource(datastore)/SessionHolder state that DatastoreUtils.doGetSession() (which getCurrentSession() delegates to) also reads. So hasCurrentSession() and getCurrentSession() are now guaranteed to agree — there's no longer a second source of truth for either of them, or for DatastoreUtils.execute()'s branch, to disagree over.
| else if (publisher instanceof MulticasterApplicationEventPublisher) { | ||
| ((MulticasterApplicationEventPublisher) publisher).addApplicationListener(listener); | ||
| } | ||
| else if (publisher != null) { |
There was a problem hiding this comment.
This still silently loses listeners for any valid ApplicationEventPublisher that does not expose the unrelated addApplicationListener method; logging does not satisfy this API contract. The new test explicitly accepts the drop with only noExceptionThrown(). Please retain listeners in a datastore-owned multicaster and use the configured publisher as an outbound delegate, or narrow the accepted publisher type so registration cannot silently succeed without delivery.
There was a problem hiding this comment.
Fixed in 48c9c49, along the second option you suggested (narrow what can silently succeed, rather than retain-and-delegate): the reflective fallback now throws IllegalStateException instead of logging and swallowing when the publisher exposes no addApplicationListener(ApplicationListener) method, so a caller can no longer register a listener that will never fire without finding out immediately.
else if (publisher != null) {
try {
Method method = publisher.getClass().getMethod("addApplicationListener", ApplicationListener.class);
method.invoke(publisher, listener);
}
catch (Exception e) {
throw new IllegalStateException("Could not register application listener [" + listener + "] with publisher [" +
publisher + "]: it does not expose an addApplicationListener(ApplicationListener) method", e);
}
}The test was rewritten to match — it no longer accepts the drop with noExceptionThrown(); it now asserts the throw:
void "addApplicationListener fails loudly rather than silently dropping the listener when the publisher exposes no addApplicationListener method"() {
...
when:
datastore.addApplicationListener(listener)
then: "the caller finds out immediately that the listener will never receive events, instead of it being silently dropped"
thrown(IllegalStateException)
}…tDatastore Three remaining issues from the re-review of #15779: - ThreadLocalSessionResolver.unbind() called unbindResourceIfPossible(), discarding the whole SessionHolder instead of popping just the top session. bind(A); bind(B); unbind() lost A entirely instead of restoring it, and neither session was closed. unbind() now pops and closes only the top session via the same removeSession()/isEmpty()/closeSessionOrRegisterDeferredClose() path DatastoreUtils.executeWithNewSession already uses, leaving the outer binding intact. The nested-scope test previously asserted the destructive behavior as correct; it now asserts restoration. - AbstractDatastore.hasCurrentSession() read the swappable sessionResolver field, while getCurrentSession() read TSM/SessionHolder directly via DatastoreUtils.doGetSession() - a caller-installed custom resolver could make these two methods disagree. setSessionResolver() had zero callers anywhere in the codebase (confirmed via search), so removed it and made sessionResolver final: both methods are now guaranteed to read the same authoritative state. - addApplicationListener()'s reflective fallback silently logged and swallowed registration failures for a plain ApplicationEventPublisher with no addApplicationListener method, so a caller had no way to know the listener would never fire. It now throws IllegalStateException instead of silently succeeding from the caller's perspective. Every fix has updated Spock coverage. Full grails-datastore-core, grails-datamapping-core, and grails-data-simple suites pass; codeStyle/ CodeNarc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…enantId()'s lazy fallback PR #15779's getTenantId() lazy DISCRIMINATOR-mode fallback (lines 101-108, added to fix jdaugherty's review comment about the eager-only lookup) had 25% Codecov patch coverage - 5 missing lines and 1 partial branch - despite AbstractPersistentEntityGetTenantIdSpec already existing. Root cause: that spec's existing tests all configure DISCRIMINATOR mode *before* adding the entity, so initialize()'s eager loop (line 165-169) already assigns tenantId by the time getTenantId() runs, and the new lazy block's `this.tenantId == null` guard is never true. Added 4 tests that switch the context into DISCRIMINATOR mode *after* the entity is already initialized (an entity added while still in NONE mode never runs the eager assignment, so tenantId stays null even once DISCRIMINATOR mode is applied later) - the exact scenario the lazy fallback exists for: - successful lazy match (drives the loop's find-and-break path) - no tenantId property present (drives the loop's exhaust-without-match path; this needed NONE mode at initialize() time since DISCRIMINATOR mode at that point makes initialize() itself throw ConfigurationException for a multi-tenant class with no tenant identifier property) - a plain non-multi-tenant entity (drives the isMultiTenant()==false short-circuit branch of the compound guard, the one remaining uncovered branch outcome after the above) Verified via local JaCoCo: lines 101-110 (the PR's new code) now have 0 missed instructions and 0 missed branches, up from 5 missing lines/1 partial branch. Full grails-datastore-core suite and codeStyle both pass with no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
✅ All tests passed ✅🏷️ Commit: ce346f0 Learn more about TestLens at testlens.app. |
Summary
Prerequisite for the GormRegistry O(M+N) scaling stack. Introduces low-level infrastructure in
grails-datastore-corethat the GormRegistry layer depends on:SessionResolverandThreadLocalSessionResolverfor thread-safe session lookup decoupled from a specificDatastorereferenceAbstractDatastore,Datastore, andDatastoreUtilswith hooks for registry lifecycle events (register/deregister on open/close)MappingContextandAbstractMappingContextextensions needed byGormRegistryfor entity lookupAbstractPersistentEntity,ClassUtils,DirtyCheckingSupport,ConnectionSourceSettingsBuilder,CustomizableRollbackTransactionAttribute,AstUtilsThis PR carries no GORM API changes and has no dependency on any GormRegistry class — it can be reviewed and merged independently.
Relationship to the upstream fix
This stack builds on top of fix/gorm-api-registration-scaling, which solves the same O(M×N) problem through a different strategy. The two approaches are deliberately complementary:
Upstream fix strategy — incremental optimization (4 files, ~815 lines)
The partner's fix adds
apiQualifiers()toGormEnhancerto split tenant qualifiers into a canonical eager set and a lazy-on-demand set, then introduces an internalGormEnhancerRegistrythat allocates per-qualifier API providers viacomputeIfAbsent. This collapses startup allocation from O(entityCount × tenantCount) to O(entityCount + tenantCount) while keepingGormEnhanceras the sole stateholder. Two follow-up commits fix TSM consistency bugs that surfaced from the lazy-allocation: a staleConnectionHolderinGrailsHibernateTemplate.executeWithNewSession(DATABASE mode) and stale cached APIs afteraddTenantForSchemare-creates a child datastore (SCHEMA mode).Footprint: all changes contained within
GormEnhancer.groovyandGrailsHibernateTemplate.java(H5 + H7). No new public types.This stack strategy — registry-first architecture (251 files across 9 PRs)
This stack extracts all per-entity, per-qualifier API state from
GormEnhancerinto a dedicatedGormRegistrysingleton.GormEnhancerbecomes stateless — it delegates entity registration, API lookup, and datastore lifecycle to the registry. APIs are created at entity-registration time by a pluggableGormApiFactory(one implementation per datastore type), then looked up O(1) by(entityClass, qualifier)at call time.Key differences from the upstream fix:
computeIfAbsentGormRegistry,GormApiFactory,SessionResolverregisterEntityGormApiFactoryGormEnhancer+ H5/H7 templateConflict resolution: our
GormRegistrysupersedes the upstreamGormEnhancerRegistry. When rebasing this stack onto the upstream fix, conflicts inGormEnhancer.groovyare always resolved in favour of our version — the registry approach provides a strict superset of the optimization.Review response
See this comment for the full writeup responding to @jdaugherty's review. Short version:
SessionResolverno longer maintains independent state (it's now a stateless view over the sameSessionHolder/TSM storeDatastoreUtilsalready uses), the public API surface is narrower (Datastore.getSessionResolver()is adefaultmethod,MappingContext.initialize()is back toprotected), several unrelated behavioral changes were reverted out of this PR, and every touched class has new/updated test coverage for the gaps called out as untested.Test plan
./gradlew :grails-datastore-core:testpassesAbstractDatastoreSpec,SessionResolverIntegrationSpec,ThreadLocalSessionResolverSpec,DatastoreUtilsSpec,AbstractConnectionSourceFactorySpec,CustomizableRollbackTransactionAttributeSpecgrails-datastore-core,grails-datamapping-core,grails-data-mongodb-core,grails-data-simple,grails-data-hibernate5-core,grails-data-hibernate7-core—BUILD SUCCESSFUL, 0 failurescodeStyle(Checkstyle/CodeNarc) clean ongrails-datastore-coreandgrails-data-mongodb-coreStack
This is the prereq for:
🤖 Generated with Claude Code