diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java index 9b8d020f877b..d3e01617ffc7 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java @@ -623,17 +623,6 @@ protected void doCleanupAfterCompletion(Object transaction) { // Remove the JDBC connection holder from the thread, if exposed. if (getDataSource() != null && txObject.hasConnectionHolder()) { TransactionSynchronizationManager.unbindResource(getDataSource()); - ConnectionHandle conHandle = txObject.getConnectionHolder().getConnectionHandle(); - if (conHandle != null) { - try { - getJpaDialect().releaseJdbcConnection(conHandle, - txObject.getEntityManagerHolder().getEntityManager()); - } - catch (Throwable ex) { - // Just log it, to keep a transaction-related exception. - logger.error("Failed to release JDBC connection after transaction", ex); - } - } } getJpaDialect().cleanupTransaction(txObject.getTransactionData()); @@ -648,6 +637,20 @@ protected void doCleanupAfterCompletion(Object transaction) { } else { logger.debug("Not closing pre-bound JPA EntityManager after transaction"); + // Give JpaDialect it's chance to release JDBC connection + if (getDataSource() != null && txObject.hasConnectionHolder()) { + ConnectionHandle conHandle = txObject.getConnectionHolder().getConnectionHandle(); + if (conHandle != null) { + try { + getJpaDialect().releaseJdbcConnection(conHandle, + txObject.getEntityManagerHolder().getEntityManager()); + } + catch (Throwable ex) { + // Just log it, to keep a transaction-related exception. + logger.error("Failed to release JDBC connection after transaction", ex); + } + } + } } } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java index cc62cde226e6..eac604fbabfb 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java @@ -47,6 +47,7 @@ import org.hibernate.exception.JDBCConnectionException; import org.hibernate.exception.LockAcquisitionException; import org.hibernate.exception.SQLGrammarException; +import org.hibernate.resource.jdbc.spi.LogicalConnectionImplementor; import org.jspecify.annotations.Nullable; import org.springframework.dao.CannotAcquireLockException; @@ -89,6 +90,8 @@ public class HibernateJpaDialect extends DefaultJpaDialect { boolean prepareConnection = true; + boolean releaseConnectionAfterTransaction = false; + private @Nullable SQLExceptionTranslator jdbcExceptionTranslator; private @Nullable SQLExceptionTranslator transactionExceptionTranslator = new SQLExceptionSubclassTranslator(); @@ -118,6 +121,34 @@ public void setPrepareConnection(boolean prepareConnection) { this.prepareConnection = prepareConnection; } + /** + * Set, whether to release connection after transaction is commited or rolled + * back. Only works for pre-bound sessions. + *

This setting is needed to work around the fact, that it is not possible + * to use {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode#DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION} + * handling mode without sacrificing setting non-default isolation levels + * or read-only flag. Releasing connection might prevent connection pool + * starvation if open-in-view is on. + *

Default is "false" for backward compatibility. If you turn this flag + * on it still does nothing unless session is pre-bound (most likely if + * open-in-view) is off. If session is pre-bound and the flag is on, then + * after transaction is finished (successfully or not), underlying JDBC + * connection will be released and acquired later according to {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode} + * (is set to {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode#DELAYED_ACQUISITION_AND_HOLD} + * with {@link HibernateJpaVendorAdapter#getJpaPropertyMap()} if you don't + * specify it yourself). + *

Please pay attention, that this setting doesn't affect how Hibernate + * handles connections, which were acquired on-demand, to lazily load + * collections outside of transaction context. + *

Specifically, connections, acquired to serialize entities, returned + * by rest controller method will only be closed, after serialization is + * complete. Hibernate will not acquire and release connections for each + * lazy field loading. + */ + public void setReleaseConnectionAfterTransaction(boolean releaseConnectionAfterTransaction) { + this.releaseConnectionAfterTransaction = releaseConnectionAfterTransaction; + } + /** * Set the JDBC exception translator for Hibernate exception translation purposes. *

Applied to any detected {@link java.sql.SQLException} root cause of a Hibernate @@ -231,6 +262,16 @@ public ConnectionHandle getJdbcConnection(EntityManager entityManager, boolean r return new HibernateConnectionHandle(session); } + @Override + public void releaseJdbcConnection(ConnectionHandle conHandle, EntityManager em) throws PersistenceException, SQLException { + if (releaseConnectionAfterTransaction) { + final LogicalConnectionImplementor logicalConnection = getSession(em).getJdbcCoordinator().getLogicalConnection(); + if (logicalConnection.isPhysicallyConnected()) { + logicalConnection.manualDisconnect(); + } + } + } + @Override public @Nullable DataAccessException translateExceptionIfPossible(RuntimeException ex) { if (ex instanceof HibernateException hibernateEx) { diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java index b8561cd3450c..106935580345 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java @@ -109,6 +109,34 @@ public void setPrepareConnection(boolean prepareConnection) { this.jpaDialect.setPrepareConnection(prepareConnection); } + /** + * Set, whether to release connection after transaction is commited or rolled + * back. Only works for pre-bound sessions. + *

This setting is needed to work around the fact, that it is not possible + * to use {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode#DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION} + * handling mode without sacrificing setting non-default isolation levels + * or read-only flag. Releasing connection might prevent connection pool + * starvation if open-in-view is on. + *

Default is "false" for backward compatibility. If you turn this flag + * on it still does nothing unless session is pre-bound (most likely if + * open-in-view) is off. If session is pre-bound and the flag is on, then + * after transaction is finished (successfully or not), underlying JDBC + * connection will be released and acquired later according to {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode} + * (is set to {@link org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode#DELAYED_ACQUISITION_AND_HOLD} + * with {@link HibernateJpaVendorAdapter#getJpaPropertyMap()} if you don't + * specify it yourself). + *

Please pay attention, that this setting doesn't affect how Hibernate + * handles connections, which were acquired on-demand, to lazily load + * collections outside of transaction context. + *

Specifically, connections, acquired to serialize entities, returned + * by rest controller method will only be closed, after serialization is + * complete. Hibernate will not acquire and release connections for each + * lazy field loading. + */ + public void setReleaseConnectionAfterTransaction(boolean releaseConnectionAfterTransaction) { + this.jpaDialect.setReleaseConnectionAfterTransaction(releaseConnectionAfterTransaction); + } + @Override public PersistenceProvider getPersistenceProvider() { diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/JpaTransactionManagerConnectionReleaseTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/JpaTransactionManagerConnectionReleaseTests.java new file mode 100644 index 000000000000..284c1fa448f6 --- /dev/null +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/JpaTransactionManagerConnectionReleaseTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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.springframework.orm.jpa; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.jdbc.datasource.ConnectionHandle; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +/** + * @author Ilia Sazonov + */ +class JpaTransactionManagerConnectionReleaseTests { + + private EntityManagerFactory factory = mock(); + + private EntityManager manager = mock(); + + private EntityTransaction tx = mock(); + + private JpaTransactionManager tm = new JpaTransactionManager(factory); + + private TransactionTemplate tt = new TransactionTemplate(tm); + + private DataSource ds = mock(); + + private ConnectionHandle connHandle = mock(); + + private JpaDialect jpaDialect = spy(new DefaultJpaDialect()); + + + @BeforeEach + void setup() throws SQLException { + given(factory.createEntityManager()).willReturn(manager); + given(manager.getTransaction()).willReturn(tx); + given(manager.isOpen()).willReturn(true); + given(jpaDialect.getJdbcConnection(same(manager), anyBoolean())).willReturn(connHandle); + tm.setJpaDialect(jpaDialect); + tm.setDataSource(ds); + } + + @AfterEach + void verifyTransactionSynchronizationManagerState() { + assertThat(TransactionSynchronizationManager.getResourceMap()).isEmpty(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + assertThat(TransactionSynchronizationManager.isCurrentTransactionReadOnly()).isFalse(); + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + } + + @Test + void testConnectionIsReleasedAfterTransactionCleanup() throws SQLException { + given(manager.getTransaction()).willReturn(tx); + + final List l = new ArrayList<>(); + l.add("test"); + + assertThat(TransactionSynchronizationManager.hasResource(factory)).isFalse(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + TransactionSynchronizationManager.bindResource(factory, new EntityManagerHolder(manager)); + + try { + Object result = tt.execute(status -> { + assertThat(TransactionSynchronizationManager.hasResource(factory)).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + EntityManagerFactoryUtils.getTransactionalEntityManager(factory); + return l; + }); + assertThat(result).isSameAs(l); + + assertThat(TransactionSynchronizationManager.hasResource(factory)).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } + finally { + TransactionSynchronizationManager.unbindResource(factory); + } + + verify(tx).begin(); + verify(tx).commit(); + + InOrder cleanupBeforeRelease = inOrder(jpaDialect); + cleanupBeforeRelease.verify(jpaDialect).cleanupTransaction(any()); + cleanupBeforeRelease.verify(jpaDialect).releaseJdbcConnection(same(connHandle), same(manager)); + } + + @Test + void testConnectionIsNotReleasedIfSesionIsClosing() throws SQLException { + given(manager.getTransaction()).willReturn(tx); + + final List l = new ArrayList<>(); + l.add("test"); + + assertThat(TransactionSynchronizationManager.hasResource(factory)).isFalse(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + + Object result = tt.execute(status -> { + assertThat(TransactionSynchronizationManager.hasResource(factory)).isTrue(); + EntityManagerFactoryUtils.getTransactionalEntityManager(factory).flush(); + return l; + }); + assertThat(result).isSameAs(l); + + assertThat(TransactionSynchronizationManager.hasResource(factory)).isFalse(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + + verify(tx).begin(); + verify(tx).commit(); + + verify(jpaDialect).cleanupTransaction(any()); + verify(jpaDialect, never()).releaseJdbcConnection(any(), any()); + } +} diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryReleaseConnectionAfterTransactionIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryReleaseConnectionAfterTransactionIntegrationTests.java new file mode 100644 index 000000000000..8c91cf9338c7 --- /dev/null +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateEntityManagerFactoryReleaseConnectionAfterTransactionIntegrationTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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.springframework.orm.jpa.hibernate; + +import jakarta.persistence.EntityManager; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.resource.jdbc.spi.LogicalConnectionImplementor; +import org.junit.jupiter.api.Test; +import org.springframework.orm.jpa.AbstractContainerEntityManagerFactoryIntegrationTests; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link org.springframework.orm.jpa.vendor.HibernateJpaDialect#releaseConnectionAfterTransaction} + * + * @author Ilia Sazonov + */ +class HibernateEntityManagerFactoryReleaseConnectionAfterTransactionIntegrationTests extends AbstractContainerEntityManagerFactoryIntegrationTests { + + @Override + protected String[] getConfigLocations() { + return new String[] {"/org/springframework/orm/jpa/hibernate/hibernate-manager-release-after-transaction.xml", + "/org/springframework/orm/jpa/memdb.xml", "/org/springframework/orm/jpa/inject.xml"}; + } + + @Test + public void testReleaseConnectionAfterTransaction() { + endTransaction(); + + try (EntityManager em = entityManagerFactory.createEntityManager()) { + EntityManagerHolder emHolder = new EntityManagerHolder(em); + TransactionSynchronizationManager.bindResource(entityManagerFactory, emHolder); + + startNewTransaction(); + endTransaction(); + + assertThat(em.isOpen()).isTrue(); + final LogicalConnectionImplementor logicalConnection = em.unwrap(SessionImplementor.class) + .getJdbcCoordinator().getLogicalConnection(); + assertThat(logicalConnection.isPhysicallyConnected()).isFalse(); + + } finally { + TransactionSynchronizationManager.unbindResource(entityManagerFactory); + } + } +} diff --git a/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager-release-after-transaction.xml b/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager-release-after-transaction.xml new file mode 100644 index 000000000000..82ede15cfba6 --- /dev/null +++ b/spring-orm/src/test/resources/org/springframework/orm/jpa/hibernate/hibernate-manager-release-after-transaction.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + org.springframework.orm.hibernate5.SpringSessionContext + org.hibernate.cache.HashtableCacheProvider + + + + + + + + + + + + + + + +