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