(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779
Open
borinquenkid wants to merge 8 commits into
Open
(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779borinquenkid wants to merge 8 commits into
borinquenkid wants to merge 8 commits into
Conversation
…enantCount)
GormEnhancer.registerEntity eagerly instantiated a static, instance, and
validation API provider for every (entity, qualifier) pair. For a MultiTenant
entity expanded across N tenant connection sources this is
O(entityCount * tenantCount) provider allocations at application startup.
Where the eager fan-out occurs:
DISCRIMINATOR (column) No - single shared connection; entities
expand to [DEFAULT] only
SCHEMA (schema-per-tenant) Yes - allQualifiers() resolves existing schemas
from the live database at startup
(schemaHandler.resolveSchemaNames); with N
pre-registered schemas it allocated N*M
DATABASE (database-per-tenant) Yes - statically-configured per-tenant
datasources expand every entity across N
apiQualifiers() now restricts eager provider creation to the canonical qualifier
set while still registering datastore routing for every qualifier; per-qualifier
providers are created lazily on first access via computeIfAbsent (single-flight),
collapsing startup allocation to O(entityCount + tenantCount).
Adds GormEnhancerAllQualifiersSpec (datamapping-core) and mirrored
GormApiAllocationSpec for hibernate5 and hibernate7.
Assisted-by: claude-code:claude-opus-4.8
Two bugs surfaced when lazy GORM API allocation was introduced for tenant qualifiers: 1. DATABASE per-tenant: SQLErrorCodesFactory eagerly acquires a connection via DataSourceUtils during GrailsHibernateTemplate construction while a parent-transaction synchronisation is already active, binding the child DataSource to TSM. The original executeWithNewSession code only unbound the DataSource when the SessionFactory was also bound, so the stale ConnectionHolder was still present when HibernateTransactionManager called doBegin, causing "Already value bound" for the child connection. Fix: decouple the DataSource unbind/rebind from the SessionFactory null-check so both are restored independently (hibernate5 + hibernate7). 2. SCHEMA per-tenant: addTenantForSchema re-creates a child datastore (new SessionFactory) on every test setup. registerAllEntitiesWithEnhancer updated DATASTORES routing but not STATIC_APIS/INSTANCE_APIS/VALIDATION_APIS for non-eager schema qualifiers, leaving a stale API referencing the old SessionFactory. The next withNewSession bound the new SF to TSM, but findStaticApi returned the cached API for the old SF, so getCurrentSession() found nothing. Fix: in GormEnhancer.registerEntity, evict the stale lazy-cached API entries for any qualifier that is not in apiQualifiers, forcing re-creation against the current datastore on next access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced Jun 27, 2026
…ng fixes
Cover the two bugs fixed in the previous commit:
1. GrailsHibernateTemplate.executeWithNewSession TSM bug (H5 + H7):
- H5: standalone spec; manually pre-binds a ConnectionHolder for the
DataSource without a matching SessionFactory holder in TSM, then
verifies executeWithNewSession completes without "Already value bound".
- H7: uses the outer transaction's active synchronisation; a secondary
local datastore triggers SQLErrorCodesFactory to auto-bind its DS to
TSM, replicating the production trigger without manual bindResource.
2. GormEnhancer.registerEntity stale API eviction (H5 + H7):
- H5: calls addTenantForSchema('tenantA') twice (H5 has no idempotency
guard, so the second call re-creates the child and re-registers
entities); verifies stale lazily-cached APIs for tenantA are evicted.
- H7: creates a second SCHEMA datastore for the same entity class without
closing the first; verifies that SchemaTenantGormEnhancer.registerEntity
evicts stale lazily-cached tenant APIs on the new datastore's
registration pass.
Co-Authored-By: Claude Sonnet 4.6 <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>
da5001a to
4b9d006
Compare
…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>
Contributor
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.
Comment on lines
+35
to
+46
| private final ThreadLocal<S> currentSession = new ThreadLocal<>() | ||
| private final Map<String, S> qualifiedSessions = new ConcurrentHashMap<>() | ||
|
|
||
| @Override | ||
| S resolve() { | ||
| return currentSession.get() | ||
| } | ||
|
|
||
| @Override | ||
| S resolve(String qualifier) { | ||
| return qualifiedSessions.get(qualifier) | ||
| } |
Comment on lines
+48
to
+65
| @Override | ||
| void bind(S session) { | ||
| currentSession.set(session) | ||
| // Note: In a production scenario, we'd need to link the session's datastore qualifier here. | ||
| } | ||
|
|
||
| void bind(String qualifier, S session) { | ||
| qualifiedSessions.put(qualifier, session) | ||
| } | ||
|
|
||
| @Override | ||
| void unbind() { | ||
| currentSession.remove() | ||
| } | ||
|
|
||
| void unbind(String qualifier) { | ||
| qualifiedSessions.remove(qualifier) | ||
| } |
Comment on lines
+65
to
+87
| private static final class DefaultApplicationEventPublisher implements ApplicationEventPublisher { | ||
| private final List<ApplicationListener> listeners = new ArrayList<>(); | ||
|
|
||
| @Override | ||
| public void publishEvent(ApplicationEvent event) { | ||
| publishEvent((Object) event); | ||
| } | ||
|
|
||
| @Override | ||
| public void publishEvent(Object event) { | ||
| for (ApplicationListener listener : new ArrayList<>(listeners)) { | ||
| if (event instanceof ApplicationEvent) { | ||
| listener.onApplicationEvent((ApplicationEvent) event); | ||
| } else { | ||
| listener.onApplicationEvent(new PayloadApplicationEvent(this, event)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public void addApplicationListener(ApplicationListener<?> listener) { | ||
| listeners.add(listener); | ||
| } | ||
| } |
Comment on lines
419
to
423
| public static Session bindSession(final Session session) { | ||
| TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session)); | ||
| if (!TransactionSynchronizationManager.hasResource(session.getDatastore())) { | ||
| TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session)); | ||
| } | ||
| return session; |
Comment on lines
431
to
435
| public static Session bindSession(final Session session, Object creator) { | ||
| TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session, creator)); | ||
| if (!TransactionSynchronizationManager.hasResource(session.getDatastore())) { | ||
| TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session, creator)); | ||
| } | ||
| return session; |
- 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>
✅ All tests passed ✅🏷️ Commit: 9145c07 Learn more about TestLens at testlens.app. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Prerequisite for the GormRegistry O(M+N) scaling stack. Introduces low-level infrastructure in `grails-datastore-core` that the GormRegistry layer depends on:
This 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.Test plan
Stack
This is the prereq for:
🤖 Generated with Claude Code