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)
     }
 

Reply via email to