Skip to content

(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779

Open
borinquenkid wants to merge 8 commits into
8.0.xfrom
feat/gorm-datastore-infra
Open

(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779
borinquenkid wants to merge 8 commits into
8.0.xfrom
feat/gorm-datastore-infra

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Jun 27, 2026

Copy link
Copy Markdown
Member

📖 Reading order: 1 of 3 — this GormRegistry O(M+N) scaling change is split across three PRs; please review them in order:

  1. (1/3) (1/3) feat: add SessionResolver infrastructure and extend core datastore APIs #15779 — SessionResolver infrastructure & core datastore API extensions
  2. (2/3) (2/3) feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor #15780 — GormRegistry implementation (core production code)
  3. (3/3) (3/3) test: add core-class unit tests for the GormRegistry O(M+N) rewrite #15790 — core-class unit tests

You are reading (1/3).


Summary

Prerequisite for the GormRegistry O(M+N) scaling stack. Introduces low-level infrastructure in `grails-datastore-core` that the GormRegistry layer depends on:

  • Add `SessionResolver` and `ThreadLocalSessionResolver` for thread-safe session lookup decoupled from a specific `Datastore` reference
  • Extend `AbstractDatastore`, `Datastore`, and `DatastoreUtils` with hooks for registry lifecycle events (register/deregister on open/close)
  • Add `MappingContext` and `AbstractMappingContext` extensions needed by `GormRegistry` for entity lookup
  • Minor additions to `AbstractPersistentEntity`, `ClassUtils`, `DirtyCheckingSupport`, `ConnectionSourceSettingsBuilder`, `CustomizableRollbackTransactionAttribute`, `AstUtils`

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() to GormEnhancer to split tenant qualifiers into a canonical eager set and a lazy-on-demand set, then introduces an internal GormEnhancerRegistry that allocates per-qualifier API providers via computeIfAbsent. This collapses startup allocation from O(entityCount × tenantCount) to O(entityCount + tenantCount) while keeping GormEnhancer as the sole stateholder. Two follow-up commits fix TSM consistency bugs that surfaced from the lazy-allocation: a stale ConnectionHolder in GrailsHibernateTemplate.executeWithNewSession (DATABASE mode) and stale cached APIs after addTenantForSchema re-creates a child datastore (SCHEMA mode).

Footprint: all changes contained within GormEnhancer.groovy and GrailsHibernateTemplate.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 GormEnhancer into a dedicated GormRegistry singleton. GormEnhancer becomes stateless — it delegates entity registration, API lookup, and datastore lifecycle to the registry. APIs are created at entity-registration time by a pluggable GormApiFactory (one implementation per datastore type), then looked up O(1) by (entityClass, qualifier) at call time.

Key differences from the upstream fix:

Dimension Upstream fix This stack
Complexity O(M+N) via lazy computeIfAbsent O(M+N) via eager factory allocation at registration time
New public types None GormRegistry, GormApiFactory, SessionResolver
Stale-entry eviction Explicit eviction heuristic in registerEntity Not needed — registry owns the authoritative copy
Lifecycle No change Datastore open/close fires register/deregister events on the registry
Pluggability Single allocation path Each datastore registers its own GormApiFactory
Scope GormEnhancer + H5/H7 template All datastore adapter modules

Conflict resolution: our GormRegistry supersedes the upstream GormEnhancerRegistry. When rebasing this stack onto the upstream fix, conflicts in GormEnhancer.groovy are always resolved in favour of our version — the registry approach provides a strict superset of the optimization.


Test plan

  • `./gradlew :grails-datastore-core:test` passes
  • New specs: `AbstractDatastoreSpec`, `SessionResolverIntegrationSpec`, `ThreadLocalSessionResolverSpec`

Stack

This is the prereq for:

🤖 Generated with Claude Code

jamesfredley and others added 2 commits June 27, 2026 12:32
…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>
borinquenkid and others added 2 commits June 27, 2026 12:19
…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>
@borinquenkid borinquenkid force-pushed the feat/gorm-datastore-infra branch from da5001a to 4b9d006 Compare June 27, 2026 17:35
borinquenkid and others added 2 commits June 27, 2026 13:09
…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>
@borinquenkid borinquenkid requested review from jdaugherty and matrei and removed request for jdaugherty June 30, 2026 04:37
@borinquenkid borinquenkid changed the title feat: add SessionResolver infrastructure and extend core datastore APIs (1/3) feat: add SessionResolver infrastructure and extend core datastore APIs Jul 1, 2026
Copilot AI review requested due to automatic review settings July 2, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + default ThreadLocalSessionResolver, and wire session lookup into Datastore/AbstractDatastore and DatastoreUtils.
  • Extend MappingContext/AbstractMappingContext initialization 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;
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.06736% with 79 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.5185%. Comparing base (66cd24c) to head (9145c07).

Files with missing lines Patch % Lines
.../grails/datastore/mapping/core/DatastoreUtils.java 33.3333% 19 Missing and 1 partial ⚠️
...ions/CustomizableRollbackTransactionAttribute.java 38.0952% 10 Missing and 3 partials ⚠️
...g/grails/datastore/mapping/reflect/ClassUtils.java 0.0000% 12 Missing ⚠️
...oovy/org/grails/datastore/gorm/GormEnhancer.groovy 78.0000% 1 Missing and 10 partials ⚠️
...ails/datastore/mapping/core/AbstractDatastore.java 73.8095% 6 Missing and 5 partials ⚠️
...e/connections/AbstractConnectionSourceFactory.java 0.0000% 3 Missing ⚠️
...mapping/dirty/checking/DirtyCheckingSupport.groovy 50.0000% 2 Missing and 1 partial ⚠️
...connections/ConnectionSourceSettingsBuilder.groovy 0.0000% 2 Missing ⚠️
...atastore/mapping/model/AbstractMappingContext.java 0.0000% 2 Missing ⚠️
...astore/mapping/model/AbstractPersistentEntity.java 85.7143% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15779        +/-   ##
==================================================
+ Coverage     49.4842%   49.5185%   +0.0343%     
- Complexity      16697      16752        +55     
==================================================
  Files            1947       1948         +1     
  Lines           92474      92630       +156     
  Branches        16152      16196        +44     
==================================================
+ Hits            45760      45869       +109     
- Misses          39606      39631        +25     
- Partials         7108       7130        +22     
Files with missing lines Coverage Δ
...ails/orm/hibernate/AbstractHibernateDatastore.java 62.0438% <ø> (ø)
...tore/mapping/mongo/config/MongoMappingContext.java 83.3333% <ø> (ø)
...ore/mapping/core/ThreadLocalSessionResolver.groovy 100.0000% <100.0000%> (ø)
...apping/document/config/DocumentMappingContext.java 69.6970% <ø> (ø)
...eyvalue/mapping/config/KeyValueMappingContext.java 96.4286% <100.0000%> (ø)
...oovy/org/grails/datastore/mapping/query/Query.java 81.1494% <100.0000%> (ø)
...astore/mapping/model/AbstractPersistentEntity.java 75.8242% <85.7143%> (+0.2560%) ⬆️
...g/grails/datastore/mapping/reflect/AstUtils.groovy 67.1141% <66.6667%> (-0.2260%) ⬇️
...connections/ConnectionSourceSettingsBuilder.groovy 66.6667% <0.0000%> (-33.3333%) ⬇️
...atastore/mapping/model/AbstractMappingContext.java 66.6667% <0.0000%> (-0.6144%) ⬇️
... and 7 more

... and 11 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- 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>
@testlens-app

testlens-app Bot commented Jul 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 9145c07
▶️ Tests: 47042 executed
⚪️ Checks: 44/44 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants