Skip to content

feat: add SessionResolver infrastructure and extend core datastore APIs#15779

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

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

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Jun 27, 2026

Copy link
Copy Markdown
Member

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

testlens-app Bot commented Jun 27, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: a870fc8
▶️ Tests: 9717 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.

2 participants