This is an automated email from the ASF dual-hosted git repository. borinquenkid pushed a commit to branch 8.0.x-hibernate7.gorm-scaling-clean in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit dcaff8688781bde7c854d9ef489eb78790907132 Author: Walter Duque de Estrada <[email protected]> AuthorDate: Fri May 22 23:08:12 2026 -0400 Stabilize multi-tenant datastore resolution and fix test isolation The O(M+N) GormRegistry refactor exposed two classes of regression in multi-tenancy and multi-datasource scenarios. This commit addresses both. Production fixes: GormApiResolver: Move the DISCRIMINATOR mode check before the MultipleConnectionSourceCapableDatastore delegation so that tenant IDs are never mistaken for datasource connection names. For the DEFAULT qualifier, return the preferred (active-transaction) datastore directly rather than re-routing through getDatastoreForConnection, which would return the parent and mismatch the session factory already bound to the transaction. GormRegistry.registerEntityDatastores: Stop overwriting child datastores with the parent for non-DEFAULT qualifiers that resolve back to the parent. In SCHEMA and DISCRIMINATOR mode the qualifier is a runtime tenant ID, not a datasource name; routing it back to the parent is correct and must not clobber the child entries added by addTenantForSchemaInternal. GormRegistry.findTransactionManager: Fall back through the full apiResolver when getDatastore returns null so that DISCRIMINATOR/SCHEMA tenant IDs still resolve to a transaction manager. HibernateDatastore (H5) / ChildHibernateDatastore (H7): Return null instead of throwing ConfigurationException when getDatastoreForConnection is called for a sibling that is not yet registered during initialization. GormRegistry will re-register all entities with the correct datastores once initialization completes. Child datastores also delegate to the parent for unrecognized connection names so the lookup chain stays consistent. HibernateGormInstanceApi (H7): Always resolve the template via the datastore registry rather than caching a DEFAULT-qualifier instance, so that preferred-datastore switching in multi-datasource transactions picks up the correct session factory. GrailsHibernateTransactionManager (H7): Remove debug System.err.println statements left over from investigation. Test infrastructure fixes: gradle/hibernate5-test-config.gradle, gradle/hibernate7-test-config.gradle: Set forkEvery = 1 so each test class runs in its own JVM. The root test-config.gradle uses forkEvery = 50 (CI) / 100 (local) for speed; with a shared GormRegistry singleton that per-test setup/teardown mutates, TCK specs running before PartitionedMultiTenancySpec in the same JVM were clearing datastoresByQualifier["default"], causing a NullPointerException in count() when PartitionedMultiTenancySpec later resolved a GormPersistentEntity. forkEvery = 1 eliminates cross-class singleton contamination at the cost of extra JVM startup overhead, which is acceptable given the test isolation requirement. GrailsDataHibernate5TckManager: Add grailsConfig field and populate a local ConfigObject from it in createSession(), fixing MissingPropertyException when test specs assign grailsConfig before calling setup(). Verified: H5 669 tests / 0 failures, H7 2960 tests / 0 failures. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- gradle/hibernate5-test-config.gradle | 5 +++ gradle/hibernate7-test-config.gradle | 5 +++ .../grails/orm/hibernate/HibernateDatastore.java | 25 ++++++++++--- .../core/GrailsDataHibernate5TckManager.groovy | 14 ++++--- .../orm/hibernate/ChildHibernateDatastore.java | 10 +++++ .../GrailsHibernateTransactionManager.groovy | 4 -- .../orm/hibernate/HibernateGormInstanceApi.groovy | 8 ++-- .../support/ClosureEventTriggeringInterceptor.java | 43 +++++++++++++++++++--- .../grails/datastore/gorm/GormApiResolver.groovy | 35 +++++++++++++----- .../org/grails/datastore/gorm/GormRegistry.groovy | 31 ++++++++++++---- .../org/grails/datastore/gorm/GormStaticApi.groovy | 7 ++++ 11 files changed, 146 insertions(+), 41 deletions(-) diff --git a/gradle/hibernate5-test-config.gradle b/gradle/hibernate5-test-config.gradle index afaa18f8f7..044399b8fe 100644 --- a/gradle/hibernate5-test-config.gradle +++ b/gradle/hibernate5-test-config.gradle @@ -28,6 +28,11 @@ tasks.withType(Test).configureEach { outputs.cacheIf { !doNotCacheTests } outputs.upToDateWhen { !doNotCacheTests } + // Each test class runs in its own JVM to prevent cross-class GormRegistry singleton + // pollution between TCK specs (which register/destroy datastores per feature) and + // standalone multi-tenant specs that rely on @Shared datastore state across features. + forkEvery = 1 + onlyIf { ![ 'onlyFunctionalTests', diff --git a/gradle/hibernate7-test-config.gradle b/gradle/hibernate7-test-config.gradle index 5d03dff9f1..c5a861a45b 100644 --- a/gradle/hibernate7-test-config.gradle +++ b/gradle/hibernate7-test-config.gradle @@ -28,6 +28,11 @@ tasks.withType(Test).configureEach { outputs.cacheIf { !doNotCacheTests } outputs.upToDateWhen { !doNotCacheTests } + // Each test class runs in its own JVM to prevent cross-class GormRegistry singleton + // pollution between TCK specs (which register/destroy datastores per feature) and + // standalone multi-tenant specs that rely on @Shared datastore state across features. + forkEvery = 1 + onlyIf { ![ 'onlyFunctionalTests', diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateDatastore.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateDatastore.java index 2877035572..55673b85ac 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateDatastore.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateDatastore.java @@ -210,13 +210,19 @@ public class HibernateDatastore extends AbstractHibernateDatastore implements Me } if (connectionName.equals(Settings.SETTING_DATASOURCE) || connectionName.equals(ConnectionSource.DEFAULT)) { return parent; - } else { - HibernateDatastore hibernateDatastore = parent.datastoresByConnectionSource.get(connectionName); - if (hibernateDatastore == null) { - throw new ConfigurationException("DataSource not found for name [" + connectionName + "] in configuration. Please check your multiple data sources configuration and try again."); - } + } + HibernateDatastore hibernateDatastore = parent.datastoresByConnectionSource.get(connectionName); + if (hibernateDatastore != null) { return hibernateDatastore; } + // If this child is not yet in the parent map, it is still being initialized. + // Sibling datastores may not exist yet; return null so GormRegistry falls back + // to this datastore for the unresolved qualifier. The parent will re-register + // all entities with the correct datastores once all children are created. + if (!parent.datastoresByConnectionSource.containsKey(myName)) { + return null; + } + throw new ConfigurationException("DataSource not found for name [" + connectionName + "] in configuration. Please check your multiple data sources configuration and try again."); } }; } @@ -686,6 +692,15 @@ public class HibernateDatastore extends AbstractHibernateDatastore implements Me protected HibernateGormEnhancer initialize() { return new HibernateGormEnhancer(this, transactionManager, getConnectionSources().getDefaultConnectionSource().getSettings()); } + + @Override + public HibernateDatastore getDatastoreForConnection(String connectionName) { + String myName = getConnectionSources().getDefaultConnectionSource().getName(); + if (connectionName.equals(myName)) { + return this; + } + return HibernateDatastore.this.getDatastoreForConnection(connectionName); + } }; datastoresByConnectionSource.put(connectionSource.getName(), childDatastore); } diff --git a/grails-data-hibernate5/core/src/test/groovy/org/apache/grails/data/hibernate5/core/GrailsDataHibernate5TckManager.groovy b/grails-data-hibernate5/core/src/test/groovy/org/apache/grails/data/hibernate5/core/GrailsDataHibernate5TckManager.groovy index e71e88bd59..596226af12 100644 --- a/grails-data-hibernate5/core/src/test/groovy/org/apache/grails/data/hibernate5/core/GrailsDataHibernate5TckManager.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/org/apache/grails/data/hibernate5/core/GrailsDataHibernate5TckManager.groovy @@ -53,6 +53,7 @@ class GrailsDataHibernate5TckManager extends GrailsDataTckManager { ApplicationContext applicationContext HibernateDatastore multiDataSourceDatastore HibernateDatastore multiTenantMultiDataSourceDatastore + Map grailsConfig @Override void setup(Class<? extends Specification> spec) { @@ -62,17 +63,20 @@ class GrailsDataHibernate5TckManager extends GrailsDataTckManager { @Override Session createSession() { - ConfigObject grailsConfig = new ConfigObject() + ConfigObject config = new ConfigObject() + if (grailsConfig) { + config.putAll(grailsConfig) + } boolean isTransactional = true System.setProperty('hibernate5.gorm.suite', "true") grailsApplication = new DefaultGrailsApplication(domainClasses as Class[], new GroovyClassLoader(GrailsDataHibernate5TckManager.getClassLoader())) - if (grailsConfig) { - grailsApplication.config.putAll(grailsConfig) + if (config) { + grailsApplication.config.putAll(config) } - grailsConfig.dataSource.dbCreate = "create-drop" - hibernateDatastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver(grailsConfig), domainClasses as Class[]) + config.dataSource.dbCreate = "create-drop" + hibernateDatastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver(config), domainClasses as Class[]) transactionManager = hibernateDatastore.getTransactionManager() sessionFactory = hibernateDatastore.sessionFactory if (transactionStatus == null && isTransactional) { diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/ChildHibernateDatastore.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/ChildHibernateDatastore.java index 9f165aaf79..b67bd54c7e 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/ChildHibernateDatastore.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/ChildHibernateDatastore.java @@ -94,6 +94,16 @@ public class ChildHibernateDatastore extends HibernateDatastore { if (hibernateDatastore != null) { return hibernateDatastore; } + // During initialization this child may not yet be registered in the parent's runtime map, + // while sibling datastores being initialized in parallel may also be absent. Return null + // only when (a) this child is not yet registered (so we are in the initialization phase) + // AND (b) the requested connection name is a sibling that is configured in the parent's + // connection sources (i.e., it will exist once initialization completes). Truly unknown + // names always throw ConfigurationException regardless of initialization state. + if (!p.datastoresByConnectionSource.containsKey(myName) && + p.connectionSources.getConnectionSource(connectionName) != null) { + return null; + } } throw new org.grails.datastore.mapping.core.exceptions.ConfigurationException( diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTransactionManager.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTransactionManager.groovy index b9411bf887..eaf49b2e60 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTransactionManager.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTransactionManager.groovy @@ -45,7 +45,6 @@ class GrailsHibernateTransactionManager extends HibernateTransactionManager { private Datastore datastore void setDatastore(Datastore datastore) { - System.err.println "SETTING DATASTORE ON TM [${System.identityHashCode(this)}]: ${datastore}" this.datastore = datastore } @@ -73,10 +72,7 @@ class GrailsHibernateTransactionManager extends HibernateTransactionManager { org.grails.datastore.mapping.core.Session session = new HibernateSession((HibernateDatastore) this.datastore, sessionFactory as SessionFactory, null); TransactionSynchronizationManager.bindResource(this.datastore, new org.grails.datastore.mapping.transactions.SessionHolder(session)); } - System.err.println "SETTING PREFERRED DATASTORE: ${this.datastore}" org.grails.datastore.gorm.GormEnhancerRegistry.getInstance().setPreferredDatastore(this.datastore) - } else { - System.err.println "DATASTORE IS NULL in TransactionManager!" } } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy index 26e800a265..45f9b46de5 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy @@ -144,7 +144,11 @@ class HibernateGormInstanceApi<D> extends GormInstanceApi<D> { this.hibernateTemplate = template } } else { - this.hibernateTemplate = template + // For DEFAULT qualifier or non-discriminator mode, the datastore resolver may return + // different datastores in different transaction contexts (e.g., preferred datastore switching + // between a multi-datasource parent and a secondary child). Do not cache here — resolve + // the template dynamically on every call to avoid using a stale template from a prior context. + return template } } return hibernateTemplate @@ -494,10 +498,8 @@ class HibernateGormInstanceApi<D> extends GormInstanceApi<D> { protected void flushSession(Session session) { HibernateDatastore datastore = getHibernateDatastore() if (datastore.isOsivReadOnly(datastore.sessionFactory)) { - System.err.println "SKIPPING flush because OSIV is read-only" return } - System.err.println "Executing session.flush() on ${session}" session.flush() } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/ClosureEventTriggeringInterceptor.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/ClosureEventTriggeringInterceptor.java index 643168aade..b0c3beacc4 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/ClosureEventTriggeringInterceptor.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/ClosureEventTriggeringInterceptor.java @@ -20,6 +20,7 @@ package org.grails.orm.hibernate.support; import java.io.Serial; import java.io.Serializable; +import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -70,8 +71,10 @@ import org.grails.datastore.mapping.engine.ModificationTrackingEntityAccess; import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent; import org.grails.datastore.mapping.model.MappingContext; import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.PersistentProperty; import org.grails.datastore.mapping.model.types.Embedded; import org.grails.datastore.mapping.proxy.ProxyHandler; +import org.grails.datastore.mapping.reflect.EntityReflector; import org.grails.orm.hibernate.HibernateDatastore; /** @@ -282,29 +285,57 @@ public class ClosureEventTriggeringInterceptor private void synchronizeHibernateState( PreInsertEvent hibernateEvent, ModificationTrackingEntityAccess entityAccess) { - Map<String, Object> modifiedProperties = entityAccess.getModifiedProperties(); + Object[] state = hibernateEvent.getState(); + EntityPersister persister = hibernateEvent.getPersister(); + Map<String, Object> modifiedProperties = findModifiedProperties(hibernateEvent.getEntity(), persister, state); + modifiedProperties.putAll(entityAccess.getModifiedProperties()); if (!modifiedProperties.isEmpty()) { - Object[] state = hibernateEvent.getState(); - EntityPersister persister = hibernateEvent.getPersister(); synchronizeHibernateState(persister, state, modifiedProperties); } } private void synchronizeHibernateState( PreUpdateEvent hibernateEvent, ModificationTrackingEntityAccess entityAccess, boolean autoTimestamp) { - Map<String, Object> modifiedProperties = entityAccess.getModifiedProperties(); + Object[] state = hibernateEvent.getState(); + EntityPersister persister = hibernateEvent.getPersister(); + Map<String, Object> modifiedProperties = findModifiedProperties(hibernateEvent.getEntity(), persister, state); + modifiedProperties.putAll(entityAccess.getModifiedProperties()); if (autoTimestamp) { updateModifiedPropertiesWithAutoTimestamp(modifiedProperties, hibernateEvent); } if (!modifiedProperties.isEmpty()) { - Object[] state = hibernateEvent.getState(); - EntityPersister persister = hibernateEvent.getPersister(); synchronizeHibernateState(persister, state, modifiedProperties); } } + private Map<String, Object> findModifiedProperties(Object entity, EntityPersister persister, Object[] state) { + Map<String, Object> modifiedProperties = new HashMap<>(); + PersistentEntity persistentEntity = mappingContext.getPersistentEntity(Hibernate.getClass(entity).getName()); + if (persistentEntity != null) { + EntityReflector reflector = persistentEntity.getReflector(); + EntityMappingType entityMappingType = persister.getEntityMappingType(); + entityMappingType.getAttributeMappings().forEach(attributeMapping -> { + String propertyName = attributeMapping.getAttributeName(); + if ("version".equals(propertyName)) { + return; + } + PersistentProperty property = persistentEntity.getPropertyByName(propertyName); + if (property != null) { + int stateIdx = attributeMapping.getStateArrayPosition(); + if (stateIdx >= 0 && stateIdx < state.length) { + Object value = reflector.getProperty(entity, propertyName); + if (state[stateIdx] != value) { + modifiedProperties.put(propertyName, value); + } + } + } + }); + } + return modifiedProperties; + } + private void updateModifiedPropertiesWithAutoTimestamp( Map<String, Object> modifiedProperties, PreUpdateEvent hibernateEvent) { diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormApiResolver.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormApiResolver.groovy index 60c4f21e5f..b43b46c9ba 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormApiResolver.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormApiResolver.groovy @@ -167,6 +167,17 @@ class PreferredDatastoreSelector { return null } if (qualifier != null) { + if (ConnectionSource.DEFAULT.equals(qualifier)) { + // For the DEFAULT qualifier, the preferred datastore itself is the active + // transaction's datastore — return it directly rather than routing through + // getDatastoreForConnection (which would return the parent and mismatch the + // session factory bound by the active transaction). Skip only if preferred + // doesn't know the entity (e.g., an unrelated single-datasource datastore). + if (className == null || preferred.mappingContext.getPersistentEntity(className) != null) { + return preferred + } + return null + } if (preferred instanceof MultipleConnectionSourceCapableDatastore) { try { Datastore ds = ((MultipleConnectionSourceCapableDatastore) preferred).getDatastoreForConnection(qualifier) @@ -177,9 +188,6 @@ class PreferredDatastoreSelector { // ignore } } - if (ConnectionSource.DEFAULT.equals(qualifier)) { - return preferred - } return null } @@ -224,20 +232,27 @@ class QualifiedDatastoreSelector { return (Datastore) resource } + // Check the entity-specific datastore map first. For SCHEMA mode, tenant IDs ARE connection + // names (each tenant has its own child datastore registered here). For DISCRIMINATOR mode + // with an explicit datasource mapping (e.g. datasource 'analytics'), the analytics child is + // also registered here and must be returned directly. Datastore ds = registry.getDatastoreByString(className, qualifier) if (ds != null) { return ds } Datastore defaultDs = registry.getDatastoreByString(className, ConnectionSource.DEFAULT) - if (defaultDs instanceof MultipleConnectionSourceCapableDatastore) { - if (defaultDs instanceof MultiTenantCapableDatastore) { - MultiTenancySettings.MultiTenancyMode mode = ((MultiTenantCapableDatastore) defaultDs).getMultiTenancyMode() - if (mode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR || - mode == MultiTenancySettings.MultiTenancyMode.SCHEMA) { - return defaultDs - } + // For DISCRIMINATOR mode: the qualifier is a logical tenant ID, not a datasource connection + // name. Discriminator switching happens at the Hibernate session/filter level, so the parent + // datastore must be returned. (SCHEMA tenants are already handled above via the entity map.) + if (defaultDs instanceof MultiTenantCapableDatastore) { + MultiTenancySettings.MultiTenancyMode mode = ((MultiTenantCapableDatastore) defaultDs).getMultiTenancyMode() + if (mode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR) { + return defaultDs } + } + + if (defaultDs instanceof MultipleConnectionSourceCapableDatastore) { try { stateRegistry.setResolvingDatastoreDepth(depth + 1) ds = ((MultipleConnectionSourceCapableDatastore) defaultDs).getDatastoreForConnection(qualifier) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy index ec78b44956..256ecdd94b 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy @@ -200,6 +200,12 @@ class GormRegistry { */ PlatformTransactionManager findTransactionManager(Class entityClass, String qualifier) { Datastore ds = getDatastore(entityClass, qualifier) + if (ds == null) { + // The qualifier may be a tenant ID rather than a registered datastore qualifier + // (e.g. DISCRIMINATOR / SCHEMA multi-tenancy). Fall back via the full resolver + // which understands the multi-tenancy mode and returns the correct datastore. + ds = apiResolver.findDatastore(entityClass, qualifier) + } if (ds == null) { throw new IllegalStateException("No GORM implementations configured. Ensure GORM has been initialized correctly") } @@ -671,23 +677,32 @@ class GormRegistry { Datastore primaryDatastore = defaultDatastore - // Register datastores for each connection source, resolving connection-specific datastores when available. + // Register datastores for each connection source. For each qualifier, attempt to resolve a + // connection-specific child datastore. If the resolution falls back to the parent (meaning + // the qualifier is a runtime tenant ID, not a datasource connection name), skip registration + // for non-DEFAULT qualifiers in multi-tenant mode so that we do not overwrite the correctly + // registered child datastores (e.g. those added by addTenantForSchemaInternal). for (String connectionSourceName in qualifiers) { String normalizedQualifier = normalizeQualifier(connectionSourceName) Datastore qualifierDatastore = defaultDatastore if (defaultDatastore instanceof MultipleConnectionSourceCapableDatastore && !ConnectionSource.DEFAULT.equals(normalizedQualifier)) { - boolean canUseConnectionDatastore = !(multiTenantEntity && - (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR || - multiTenancyMode == MultiTenancySettings.MultiTenancyMode.SCHEMA)) - if (canUseConnectionDatastore) { - Datastore resolvedDatastore = ((MultipleConnectionSourceCapableDatastore) defaultDatastore) + try { + Datastore resolved = ((MultipleConnectionSourceCapableDatastore) defaultDatastore) .getDatastoreForConnection(normalizedQualifier) - if (resolvedDatastore != null) { - qualifierDatastore = resolvedDatastore + if (resolved != null) { + qualifierDatastore = resolved } + } catch (Throwable e) { + // qualifier is not a datasource connection name; keep defaultDatastore } } + // Skip non-DEFAULT qualifiers that resolve back to the parent for multi-tenant entities. + // Those qualifiers are runtime tenant IDs handled at the session level, not datasource names. + if (multiTenantEntity && !ConnectionSource.DEFAULT.equals(normalizedQualifier) && + qualifierDatastore == defaultDatastore) { + continue + } if (!ConnectionSource.DEFAULT.equals(normalizedQualifier) && primaryDatastore == defaultDatastore) { primaryDatastore = qualifierDatastore } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy index d6b9032cc9..6b8e67855f 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy @@ -170,6 +170,13 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D } } } + // Fallback: the preferred/transactional datastore may be a single-datasource datastore + // that doesn't expose the named qualifier in its connectionSources. Check the registry + // directly so that entities mapped to multiple datasources (e.g. datasource 'ALL') can + // still be accessed via the qualifier even when a single-datasource transaction is active. + if (registry.getDatastoreByString(persistentClass.name, name) != null) { + return registry.findStaticApi(persistentClass, name) + } throw new MissingPropertyException(name, persistentClass) }
