Reduce GORM API allocation for tenant qualifiers to O(entityCount + tenantCount)#15771
Reduce GORM API allocation for tenant qualifiers to O(entityCount + tenantCount)#15771jamesfredley wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reduces eager GORM API provider allocation when entities expand across many connection-source and multi-tenant-related qualifiers, while preserving datastore routing entries for all qualifiers and lazily creating qualifier-specific APIs on first lookup.
Changes:
- Add lazy initialization for static/instance/validation GORM APIs when a qualifier is first requested, backed by a datastore→enhancer lookup.
- Introduce
apiQualifiers(...)to limit eager API allocation forConnectionSource.ALLand default-mappedMultiTenantentities while still registering routing entries for all qualifiers. - Add/extend allocation coverage tests in datamapping-core and add mirrored Hibernate 5 / Hibernate 7 allocation specs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy |
Adds enhancer registry + lazy API initialization and adjusts registration to avoid eager per-qualifier API creation. |
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerAllQualifiersSpec.groovy |
Extends qualifier/allocation tests to assert lazy creation behavior across expanded qualifier sets. |
grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy |
New Hibernate 5 coverage asserting lazy API allocation across multi-tenancy/datasource combinations. |
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy |
New Hibernate 7 coverage asserting lazy API allocation across multi-tenancy/datasource combinations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@jamesfredley If you chain the PRs and the final PR works then we can roll them in reverse order |
|
@borinquenkid I think this one broke following @copilots feedback will have it working again later tonight and it should still be just a handful or two of files to fix the |
Do you mind if I tackle it? I am working on it. |
|
@borinquenkid by all means, I am just trying to see if there is a more compact way to achieve the same ends on this PR, it appeared to be working great, prior to making the copilot changes yesterday, proceed on your PRs. We can compare later. |
|
@borinquenkid as I have dug into this O(M×N) only occurs with DATABASE (database-per-tenant), which is why I had never seen it in my apps.
|
@jamesfredley We've also landed a fix for the CI failures the optimisation introduced: stale lazy-cached APIs were left in STATIC_APIS after addTenantForSchema re-created a child datastore, causing "No Session found" on the second test. GormEnhancer.registerEntity now evicts those entries for non-eager qualifiers so they're rebuilt against the current session factory on next access. |
|
@borinquenkid This looks solid. I am cleaning up the history and updating the PR description and then will ask for PR reviews. |
|
@jamesfredley The current PR also deals with ChilldDatastore but I am adding unit tests for - GormApiAllocationSpec: Tests that APIs are initially absent and get created lazily on first access. No test for stale cache invalidation when registerEntity is called again after addTenantForSchema re-creates the child datastore.
|
|
@borinquenkid does #15776 solve the child data store as a separate PR? |
…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>
0bfd35d to
f02f643
Compare
|
@jdaugherty @matrei This solves the There will be another, hopefully small PR, for a child data source issue. |
|
We might not need the ChildDataStore changes |
…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>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
I am adding some missing tests on GormEnhancerSpec |
GormEnhancer.registerEntity was changed to allocate APIs lazily via computeIfAbsent, but GormEnhancerSpec in the TCK was not updated to cover that new code path. Add two feature methods verifying that findStaticApi/findInstanceApi/findValidationApi return non-null APIs after enhancement and return the same cached instance on repeated calls. Tests verified passing across all four TCK-consuming modules: grails-datamapping-core-test, grails-data-hibernate5, grails-data-hibernate7, grails-data-mongodb. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…store init Wires Hibernate 7 to the new GormRegistry with correct child datastore initialization order, fixing the gaps that PR #15771 intentionally deferred: - HibernateDatastore (H7): registers child datastores with GormRegistry eagerly during initialization, preserving schema/database/datasource routing - HibernateGormInstanceApi: use getHibernateTemplate() lazy getter for isAttached(), lock(), and attach() — factory constructor leaves the field null - HibernateGormStaticApi: executeQuery(query, Map) passes params as both named params and query settings so max/offset pagination is applied - HibernateSession: coerceId() converts String IDs to the entity's declared identifier type in retrieveAll(), fixing String→Long lookup mismatch - HibernateGormEnhancerSpec: updated to use GormRegistry public API - HibernateDirtyCheckingSpec / HibernateUpdateFromListenerSpec: local Person renamed to DirtyCheckPerson to avoid DuplicateMappingException with TCK - grails-test-examples/hibernate7: demo33 UniqueConstraintOnHasOneSpec @NotYetImplemented removed (test now passes); GormAdvancedSpec pagination fixed - grails-data-hibernate7-core GormApiAllocationSpec: verifies O(M+N) API count across single-datasource, multi-datasource, and multi-tenant configurations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This looks complete to solve |
✅ All tests passed ✅🏷️ Commit: 947c045 Learn more about TestLens at testlens.app. |
There was a problem hiding this comment.
@jamesfredley This has been discussed extensively. I have a followup PR to refactor and not just patch so when merged don't squash commits please.
Summary
GormEnhancer.registerEntityeagerly instantiated a static, instance, and validation API provider for every (entity × qualifier) pair. For aMultiTenantentity expanded across N tenant connection sources this is O(entityCount × tenantCount) provider allocations at startup. This PR makes per-qualifier provider creation lazy - collapsing startup allocation to O(entityCount + tenantCount) - and fixes two transaction-synchronisation defects that lazy allocation exposed.Where the eager O(M×N) actually occurs
MultiTenantentity expands to[DEFAULT]onlyallQualifiers()resolves existing schemas from the live database at startup viaschemaHandler.resolveSchemaNames(); with N pre-registered schemas the original code created N×M providersMultiTenantentity across all N connection sources(h/t @borinquenkid for the SCHEMA clarification - the live-schema resolution means SCHEMA benefits from this optimisation too, not just DATABASE.)
The optimisation
apiQualifiers()restricts eager provider creation to the canonical qualifier set while still registering datastore routing for every qualifier. Per-qualifier static/instance/validation providers are then created lazily on first access viacomputeIfAbsent(single-flight, per the concurrency review feedback).Transaction-synchronisation fixes required by lazy allocation
SQLErrorCodesFactoryeagerly acquires a connection viaDataSourceUtilsduringGrailsHibernateTemplateconstruction while a parent-transaction synchronisation is active, binding the childDataSourceto theTransactionSynchronizationManager.executeWithNewSessiononly unbound theDataSourcewhen aSessionFactorywas also bound, so a staleConnectionHolderremained andHibernateTransactionManager.doBeginthrewAlready value bound. TheDataSourceunbind/rebind is now decoupled from theSessionFactorynull-check (hibernate5 + hibernate7).addTenantForSchemare-creates a child datastore (newSessionFactory) and re-registers entities, but lazy-cached providers for non-eager qualifiers still referenced the previousSessionFactory.GormEnhancer.registerEntitynow evicts those stale entries so they are rebuilt against the current datastore on next access.Tests
GormEnhancerAllQualifiersSpec(datamapping-core) - qualifier expansion + eager/lazy allocation counts.GormApiAllocationSpec(hibernate5 + hibernate7) - mirrored coverage including per-tenant session behaviour.Locally verified green: H5/H7
SchemaPerTenantIntegrationSpecandDatabasePerTenantIntegrationSpec.