You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/05/23 03:12:56 UTC
(grails-core) 04/04: Stabilize multi-tenant datastore resolution and fix test isolation
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 <wb...@mac.com>
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 <no...@anthropic.com>
---
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)
}