This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch fix/multi-datasource-osiv in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit afffd5706445949a3e2c53e735b6fc8989638b7b Author: James Fredley <[email protected]> AuthorDate: Fri Feb 20 13:02:34 2026 -0500 fix: extend OSIV to manage sessions for all datasources GrailsOpenSessionInViewInterceptor only registered a session for the default datasource SessionFactory. Calling withSession on a secondary datasource during a web request threw 'No Session found for current thread' because no session was bound for that SessionFactory. The interceptor now iterates all connection sources from the HibernateDatastore and opens/binds sessions for each non-default datasource in preHandle, flushes them in postHandle, and unbinds/closes them in afterCompletion. Existing sessions that are already bound (e.g. by a transaction) are left untouched. Fixes #14333 Fixes #11798 Assisted-by: Claude Code <[email protected]> --- grails-data-hibernate5/grails-plugin/build.gradle | 1 + .../GrailsOpenSessionInViewInterceptor.java | 111 +++++++++++- .../support/MultiDataSourceSessionSpec.groovy | 193 +++++++++++++++++++++ .../grails-multiple-datasources/build.gradle | 6 + .../datasources/SecondaryBookController.groovy | 78 +++++++++ .../controllers/datasources/UrlMappings.groovy | 34 ++++ .../MultiDataSourceWithSessionSpec.groovy | 86 +++++++++ 7 files changed, 507 insertions(+), 2 deletions(-) diff --git a/grails-data-hibernate5/grails-plugin/build.gradle b/grails-data-hibernate5/grails-plugin/build.gradle index 4a58e2a1af..61d6ffc06f 100644 --- a/grails-data-hibernate5/grails-plugin/build.gradle +++ b/grails-data-hibernate5/grails-plugin/build.gradle @@ -66,6 +66,7 @@ dependencies { testImplementation project(':grails-testing-support-datamapping') testImplementation 'org.spockframework:spock-core' + testImplementation 'jakarta.servlet:jakarta.servlet-api' testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.apache.tomcat:tomcat-jdbc' diff --git a/grails-data-hibernate5/grails-plugin/src/main/groovy/org/grails/plugin/hibernate/support/GrailsOpenSessionInViewInterceptor.java b/grails-data-hibernate5/grails-plugin/src/main/groovy/org/grails/plugin/hibernate/support/GrailsOpenSessionInViewInterceptor.java index 62bc0fdf0d..a378db3a0a 100644 --- a/grails-data-hibernate5/grails-plugin/src/main/groovy/org/grails/plugin/hibernate/support/GrailsOpenSessionInViewInterceptor.java +++ b/grails-data-hibernate5/grails-plugin/src/main/groovy/org/grails/plugin/hibernate/support/GrailsOpenSessionInViewInterceptor.java @@ -18,22 +18,34 @@ */ package org.grails.plugin.hibernate.support; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.FlushMode; import org.hibernate.Session; +import org.hibernate.SessionFactory; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.orm.hibernate5.SessionFactoryUtils; import org.springframework.orm.hibernate5.SessionHolder; import org.springframework.orm.hibernate5.support.OpenSessionInViewInterceptor; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.ui.ModelMap; import org.springframework.web.context.request.WebRequest; +import org.grails.datastore.mapping.core.connections.ConnectionSource; import org.grails.orm.hibernate.AbstractHibernateDatastore; +import org.grails.orm.hibernate.HibernateDatastore; +import org.grails.orm.hibernate.connections.HibernateConnectionSourceSettings; /** - * Extends the default spring OSIV and doesn't flush the session if it has been set - * to MANUAL on the session itself. + * Extends the default Spring OSIV to support multiple datasources. + * <p> + * The default datasource's SessionFactory is managed by the parent class. + * Additional (non-default) datasource SessionFactories are managed by this + * subclass, which opens and closes sessions for each one alongside the + * default session. * * @author Graeme Rocher * @since 0.5 @@ -42,6 +54,22 @@ public class GrailsOpenSessionInViewInterceptor extends OpenSessionInViewInterce protected FlushMode hibernateFlushMode = FlushMode.MANUAL; + private final List<AdditionalSessionFactoryConfig> additionalSessionFactories = new ArrayList<>(); + + /** + * Holds configuration for an additional (non-default) SessionFactory + * that needs OSIV session management. + */ + private static class AdditionalSessionFactoryConfig { + final SessionFactory sessionFactory; + final FlushMode flushMode; + + AdditionalSessionFactoryConfig(SessionFactory sessionFactory, FlushMode flushMode) { + this.sessionFactory = sessionFactory; + this.flushMode = flushMode; + } + } + @Override protected Session openSession() throws DataAccessResourceFailureException { Session session = super.openSession(); @@ -53,6 +81,25 @@ public class GrailsOpenSessionInViewInterceptor extends OpenSessionInViewInterce session.setHibernateFlushMode(hibernateFlushMode); } + @Override + public void preHandle(WebRequest request) throws DataAccessException { + super.preHandle(request); + + for (AdditionalSessionFactoryConfig config : additionalSessionFactories) { + SessionFactory sf = config.sessionFactory; + if (TransactionSynchronizationManager.hasResource(sf)) { + continue; + } + if (logger.isDebugEnabled()) { + logger.debug("Opening additional Hibernate Session for datasource in OpenSessionInViewInterceptor"); + } + Session session = sf.openSession(); + session.setHibernateFlushMode(config.flushMode); + SessionHolder sessionHolder = new SessionHolder(session); + TransactionSynchronizationManager.bindResource(sf, sessionHolder); + } + } + @Override public void postHandle(WebRequest request, ModelMap model) throws DataAccessException { SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager.getResource(getSessionFactory()); @@ -73,6 +120,46 @@ public class GrailsOpenSessionInViewInterceptor extends OpenSessionInViewInterce session.setHibernateFlushMode(FlushMode.MANUAL); } } + + for (AdditionalSessionFactoryConfig config : additionalSessionFactories) { + SessionFactory sf = config.sessionFactory; + SessionHolder additionalHolder = (SessionHolder) TransactionSynchronizationManager.getResource(sf); + if (additionalHolder != null) { + Session additionalSession = additionalHolder.getSession(); + try { + FlushMode additionalFlushMode = additionalSession.getHibernateFlushMode(); + boolean additionalIsNotManual = additionalFlushMode != FlushMode.MANUAL && additionalFlushMode != FlushMode.COMMIT; + if (additionalIsNotManual) { + if (logger.isDebugEnabled()) { + logger.debug("Eagerly flushing additional Hibernate session"); + } + additionalSession.flush(); + } + } + finally { + additionalSession.setHibernateFlushMode(FlushMode.MANUAL); + } + } + } + } + + @Override + public void afterCompletion(WebRequest request, Exception ex) throws DataAccessException { + for (int i = additionalSessionFactories.size() - 1; i >= 0; i--) { + AdditionalSessionFactoryConfig config = additionalSessionFactories.get(i); + SessionFactory sf = config.sessionFactory; + SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager.getResource(sf); + if (sessionHolder != null) { + Session session = sessionHolder.getSession(); + TransactionSynchronizationManager.unbindResource(sf); + if (logger.isDebugEnabled()) { + logger.debug("Closing additional Hibernate Session in OpenSessionInViewInterceptor"); + } + SessionFactoryUtils.closeSession(session); + } + } + + super.afterCompletion(request, ex); } public void setHibernateDatastore(AbstractHibernateDatastore hibernateDatastore) { @@ -84,5 +171,25 @@ public class GrailsOpenSessionInViewInterceptor extends OpenSessionInViewInterce this.hibernateFlushMode = FlushMode.valueOf(defaultFlushModeName); } setSessionFactory(hibernateDatastore.getSessionFactory()); + + if (hibernateDatastore instanceof HibernateDatastore) { + HibernateDatastore hibernateDs = (HibernateDatastore) hibernateDatastore; + for (ConnectionSource<SessionFactory, HibernateConnectionSourceSettings> connectionSource : hibernateDs.getConnectionSources().getAllConnectionSources()) { + String connectionName = connectionSource.getName(); + if (!ConnectionSource.DEFAULT.equals(connectionName)) { + AbstractHibernateDatastore childDatastore = hibernateDs.getDatastoreForConnection(connectionName); + FlushMode childFlushMode; + if (childDatastore.isOsivReadOnly()) { + childFlushMode = FlushMode.MANUAL; + } + else { + childFlushMode = FlushMode.valueOf(childDatastore.getDefaultFlushModeName()); + } + additionalSessionFactories.add( + new AdditionalSessionFactoryConfig(childDatastore.getSessionFactory(), childFlushMode) + ); + } + } + } } } diff --git a/grails-data-hibernate5/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/MultiDataSourceSessionSpec.groovy b/grails-data-hibernate5/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/MultiDataSourceSessionSpec.groovy new file mode 100644 index 0000000000..8b9e07513d --- /dev/null +++ b/grails-data-hibernate5/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/MultiDataSourceSessionSpec.groovy @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.plugin.hibernate.support + +import grails.gorm.annotation.Entity +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.orm.hibernate.HibernateDatastore +import org.hibernate.Session +import org.hibernate.SessionFactory +import org.hibernate.dialect.H2Dialect +import org.springframework.orm.hibernate5.SessionHolder +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.web.context.request.WebRequest +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +class MultiDataSourceSessionSpec extends Specification { + + @Shared Map config = [ + 'dataSource.url':"jdbc:h2:mem:grailsDB;LOCK_TIMEOUT=10000", + 'dataSource.dbCreate': 'create-drop', + 'dataSource.dialect': H2Dialect.name, + 'dataSource.formatSql': 'true', + 'hibernate.flush.mode': 'COMMIT', + 'hibernate.cache.queries': 'true', + 'hibernate.hbm2ddl.auto': 'create-drop', + 'dataSources.secondary':[url:"jdbc:h2:mem:secondaryDb;LOCK_TIMEOUT=10000"], + ] + + @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver(config), OsivBook, OsivAuthor) + + def "withSession on default datasource works with OSIV"() { + given: "OSIV interceptor configured with the datastore" + def interceptor = new GrailsOpenSessionInViewInterceptor() + interceptor.setHibernateDatastore(datastore) + WebRequest webRequest = Mock(WebRequest) + + when: "OSIV preHandle is called" + interceptor.preHandle(webRequest) + + then: "a session is bound for the default SessionFactory" + TransactionSynchronizationManager.hasResource(datastore.sessionFactory) + + when: "withSession is called on default datasource" + boolean sessionObtained = false + OsivAuthor.withSession { Session s -> + sessionObtained = s != null + } + + then: "session is available" + sessionObtained + + cleanup: + interceptor.afterCompletion(webRequest, null) + } + + def "withSession on secondary datasource works with OSIV"() { + given: "OSIV interceptor configured with the datastore" + def interceptor = new GrailsOpenSessionInViewInterceptor() + interceptor.setHibernateDatastore(datastore) + WebRequest webRequest = Mock(WebRequest) + + when: "OSIV preHandle is called" + interceptor.preHandle(webRequest) + + then: "a session is bound for both the default and secondary SessionFactory" + def secondaryDatastore = datastore.getDatastoreForConnection('secondary') + TransactionSynchronizationManager.hasResource(datastore.sessionFactory) + TransactionSynchronizationManager.hasResource(secondaryDatastore.sessionFactory) + + when: "withSession is called on secondary datasource" + boolean sessionObtained = false + OsivBook.secondary.withSession { Session s -> + sessionObtained = s != null + } + + then: "session is available without 'No Session found for current thread' error" + sessionObtained + + cleanup: + interceptor.afterCompletion(webRequest, null) + } + + def "afterCompletion cleans up sessions for all datasources"() { + given: "OSIV interceptor with preHandle already called" + def interceptor = new GrailsOpenSessionInViewInterceptor() + interceptor.setHibernateDatastore(datastore) + WebRequest webRequest = Mock(WebRequest) + interceptor.preHandle(webRequest) + + def secondaryDatastore = datastore.getDatastoreForConnection('secondary') + + when: "afterCompletion is called" + interceptor.afterCompletion(webRequest, null) + + then: "sessions are unbound for all datasources" + !TransactionSynchronizationManager.hasResource(datastore.sessionFactory) + !TransactionSynchronizationManager.hasResource(secondaryDatastore.sessionFactory) + } + + def "OSIV skips secondary datasource if session already bound"() { + given: "a pre-bound session for the secondary datasource" + def interceptor = new GrailsOpenSessionInViewInterceptor() + interceptor.setHibernateDatastore(datastore) + WebRequest webRequest = Mock(WebRequest) + + def secondaryDatastore = datastore.getDatastoreForConnection('secondary') + SessionFactory secondarySf = secondaryDatastore.sessionFactory + Session preBoundSession = secondarySf.openSession() + SessionHolder preBoundHolder = new SessionHolder(preBoundSession) + TransactionSynchronizationManager.bindResource(secondarySf, preBoundHolder) + + when: "OSIV preHandle is called" + interceptor.preHandle(webRequest) + + then: "the pre-bound session is preserved (not replaced)" + def currentHolder = TransactionSynchronizationManager.getResource(secondarySf) + currentHolder.is(preBoundHolder) + + cleanup: + interceptor.afterCompletion(webRequest, null) + if (TransactionSynchronizationManager.hasResource(secondarySf)) { + TransactionSynchronizationManager.unbindResource(secondarySf) + } + preBoundSession.close() + } + + def "CRUD operations work on secondary datasource with OSIV"() { + given: "OSIV interceptor configured and preHandle called" + def interceptor = new GrailsOpenSessionInViewInterceptor() + interceptor.setHibernateDatastore(datastore) + WebRequest webRequest = Mock(WebRequest) + interceptor.preHandle(webRequest) + + when: "data is saved to secondary datasource within a transaction" + OsivBook book = OsivBook.withTransaction { + new OsivBook(title: "Test Book").save(flush: true) + OsivBook.first() + } + + then: "the book is saved successfully" + book != null + book.title == "Test Book" + + when: "data is read from secondary datasource using withSession" + int count = 0 + OsivBook.secondary.withSession { Session s -> + count = OsivBook.count() + } + + then: "the count is correct" + count == 1 + + cleanup: + OsivBook.withTransaction { + OsivBook.list()*.delete(flush: true) + } + interceptor.afterCompletion(webRequest, null) + } +} + +@Entity +class OsivBook { + String title + Date dateCreated + Date lastUpdated + + static mapping = { + datasource 'secondary' + } +} + +@Entity +class OsivAuthor { + String name +} diff --git a/grails-test-examples/hibernate5/grails-multiple-datasources/build.gradle b/grails-test-examples/hibernate5/grails-multiple-datasources/build.gradle index f9057b3f59..005369f776 100644 --- a/grails-test-examples/hibernate5/grails-multiple-datasources/build.gradle +++ b/grails-test-examples/hibernate5/grails-multiple-datasources/build.gradle @@ -44,12 +44,18 @@ dependencies { runtimeOnly 'com.zaxxer:HikariCP' runtimeOnly 'org.apache.grails:grails-databinding' runtimeOnly 'org.apache.grails:grails-controllers' + runtimeOnly 'org.apache.grails:grails-url-mappings' runtimeOnly 'org.springframework.boot:spring-boot-autoconfigure' runtimeOnly 'org.springframework.boot:spring-boot-starter-logging' runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat' testImplementation 'org.apache.grails.testing:grails-testing-support-core' + integrationTestImplementation "io.micronaut:micronaut-http-client:$micronautHttpClientVersion" + integrationTestImplementation 'org.spockframework:spock-core' + + integrationTestRuntimeOnly "io.micronaut.serde:micronaut-serde-jackson:$micronautSerdeJacksonVersion" + testRuntimeOnly 'org.apache.grails:grails-testing-support-web' } diff --git a/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/SecondaryBookController.groovy b/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/SecondaryBookController.groovy new file mode 100644 index 0000000000..01a84ac465 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/SecondaryBookController.groovy @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package datasources + +import ds2.Book +import org.hibernate.Session + +class SecondaryBookController { + + def withSessionTest() { + boolean sessionObtained = false + Book.secondary.withSession { Session session -> + sessionObtained = session != null + } + render "sessionObtained:${sessionObtained}" + } + + def crudViaWithSession() { + Book.withTransaction { + new Book(title: 'OSIV Test').save(flush: true) + } + int count = 0 + Book.secondary.withSession { Session session -> + count = Book.count() + } + render "count:${count}" + } + + def validateCommandObject() { + Book book = new Book() + boolean validated = false + Book.secondary.withSession { Session session -> + validated = true + book.validate() + } + render "validated:${validated},hasErrors:${book.hasErrors()}" + } + + def sessionAfterExecuteUpdate() { + Book.withTransaction { + new Book(title: 'Before Update').save(flush: true) + } + Book.secondary.withTransaction { + Book.executeUpdate('UPDATE Book b SET b.title = :newTitle WHERE b.title = :oldTitle', + [newTitle: 'After Update', oldTitle: 'Before Update']) + } + String title = null + Book.secondary.withSession { Session session -> + session.clear() + title = Book.first()?.title + } + render "title:${title}" + } + + def cleanup() { + Book.withTransaction { + Book.list()*.delete(flush: true) + } + render "cleaned" + } +} diff --git a/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/UrlMappings.groovy b/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/UrlMappings.groovy new file mode 100644 index 0000000000..b9b3170933 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-multiple-datasources/grails-app/controllers/datasources/UrlMappings.groovy @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package datasources + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?" { + constraints { + } + } + + "/"(view: '/index') + "500"(view: '/error') + "404"(view: '/notFound') + } +} diff --git a/grails-test-examples/hibernate5/grails-multiple-datasources/src/integration-test/groovy/functionaltests/MultiDataSourceWithSessionSpec.groovy b/grails-test-examples/hibernate5/grails-multiple-datasources/src/integration-test/groovy/functionaltests/MultiDataSourceWithSessionSpec.groovy new file mode 100644 index 0000000000..6a8c650809 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-multiple-datasources/src/integration-test/groovy/functionaltests/MultiDataSourceWithSessionSpec.groovy @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package functionaltests + +import datasources.Application +import grails.testing.mixin.integration.Integration +import grails.testing.spock.OnceBefore +import io.micronaut.http.client.HttpClient +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Stepwise + +@Integration(applicationClass = Application) +@Stepwise +class MultiDataSourceWithSessionSpec extends Specification { + + @Shared + HttpClient client + + @OnceBefore + void init() { + String baseUrl = "http://localhost:$serverPort" + this.client = HttpClient.create(baseUrl.toURL()) + } + + @Issue('https://github.com/apache/grails-core/issues/14333') + void "withSession on secondary datasource does not throw No Session found"() { + when: + String response = client.toBlocking().retrieve('/secondaryBook/withSessionTest') + + then: + response.contains('sessionObtained:true') + } + + @Issue('https://github.com/apache/grails-core/issues/14333') + void "CRUD via withSession on secondary datasource works"() { + when: + String response = client.toBlocking().retrieve('/secondaryBook/crudViaWithSession') + + then: + response.contains('count:1') + + cleanup: + client.toBlocking().retrieve('/secondaryBook/cleanup') + } + + @Issue('https://github.com/apache/grails-core/issues/11798') + void "domain class on secondary datasource can be validated via withSession"() { + when: + String response = client.toBlocking().retrieve('/secondaryBook/validateCommandObject') + + then: + response.contains('validated:true') + response.contains('hasErrors:true') + } + + @Issue('https://github.com/apache/grails-core/issues/14333') + void "withSession works after executeUpdate on secondary datasource"() { + when: + String response = client.toBlocking().retrieve('/secondaryBook/sessionAfterExecuteUpdate') + + then: + response.contains('title:After Update') + + cleanup: + client.toBlocking().retrieve('/secondaryBook/cleanup') + } +}
