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