From d571c4097da575c1546a211d410caddefb38d060 Mon Sep 17 00:00:00 2001 From: guanchengang <115277968+guanchengang@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:31:37 +0800 Subject: [PATCH 1/2] Lazily handle setClientInfo/setNetworkTimeout in LazyConnectionDataSourceProxy This commit extends LazyConnectionInvocationHandler to cache early calls to: - setClientInfo(String, String) - setNetworkTimeout(Executor, int) These methods now defer physical connection acquisition until Statement creation, consistent with existing lazy behavior for autoCommit, readOnly, transactionIsolation, catalog, and schema. We also accept and lazily cache calls to setNetworkTimeout() even when the provided Executor is null. Since some JDBC driver implementations completely ignore the Executor parameter (or fall back to a default executor), we cannot meaningfully validate or handle a null Executor before the physical connection is obtained. getClientInfo() and getClientInfo(String) remain non-lazy (triggering immediate connection fetch), because they are read operations whose values cannot be reliably cached due to driver defaults, pooled connection remnants, or external session modifications. setClientInfo(Properties) also remains non-lazy. The reason is that JDBC driver implementations are inconsistent. Some treat it as overwrite, others as append/merge. To guarantee behavior identical to non-lazy execution across all drivers, we choose not to cache or replay it, avoiding any risk of semantic mismatch. See gh-37258 Closes gh-37261 Signed-off-by: Chengang Guan --- .../LazyConnectionDataSourceProxy.java | 75 +++++++- .../LazyConnectionDataSourceProxyTests.java | 179 ++++++++++++++++++ 2 files changed, 248 insertions(+), 6 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java index 8a9241c10e1..34c751d08c6 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java @@ -22,7 +22,9 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.SQLException; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Executor; import javax.sql.DataSource; @@ -35,12 +37,36 @@ import org.springframework.util.Assert; /** * Proxy for a target DataSource, fetching actual JDBC Connections lazily, * i.e. not until first creation of a Statement. Connection initialization - * properties like auto-commit mode, transaction isolation and read-only mode - * will be kept and applied to the actual JDBC Connection as soon as an actual - * Connection is fetched (if ever). Consequently, commit and rollback calls will - * be ignored if no Statements have been created. As of 6.1.2, there is also - * special support for a {@link #setReadOnlyDataSource read-only DataSource} to use - * during a read-only transaction, in addition to the regular target DataSource. + * properties like auto-commit mode, transaction isolation, read-only mode, + * catalog, schema, holdability, client info and network timeout will be kept + * and applied to the actual JDBC Connection as soon as an actual Connection + * is fetched (if ever). Consequently, commit and rollback calls will be ignored + * if no Statements have been created. + * + *

Once a property has been set, the corresponding getter method returns the + * set value until the actual Connection is fetched. If the property has not been + * set, invoking the getter triggers a fetch of the actual connection in order to + * obtain the default value. + * + *

Although client info is listed among the deferred properties above, + * the following methods are exceptions to the lazy acquisition behavior and + * force immediate acquisition of the underlying Connection. + * The {@link java.sql.Connection#getClientInfo()} and + * {@link java.sql.Connection#getClientInfo(java.lang.String)} + * methods are read operations whose values cannot be reliably cached due to + * driver defaults, remnants from pooled connections, or external session + * modifications. + * + *

The {@link java.sql.Connection#setClientInfo(java.util.Properties)} + * method also forces immediate acquisition. JDBC driver implementations are + * inconsistent: some treat it as an overwrite, while others treat it as an + * append/merge. To guarantee behavior identical to that of a non-lazy DataSource + * across all drivers, the proxy does not cache or replay it, thereby avoiding any + * risk of semantic mismatch. + * + *

As of 6.1.2, there is also special support for a + * {@link #setReadOnlyDataSource read-only DataSource} to use during a + * read-only transaction, in addition to the regular target DataSource. * *

This DataSource proxy allows to avoid fetching JDBC Connections from * a pool unless actually necessary. JDBC transaction control can happen @@ -86,6 +112,7 @@ import org.springframework.util.Assert; * * @author Juergen Hoeller * @author Sam Brannen + * @author Chengang Guan * @since 1.1.4 * @see DataSourceTransactionManager * @see #setTargetDataSource @@ -311,6 +338,12 @@ public class LazyConnectionDataSourceProxy extends DelegatingDataSource { private @Nullable Boolean autoCommit; + private @Nullable Executor networkTimeoutExecutor; + + private @Nullable Integer networkTimeout; + + private @Nullable Map clientInfo; + private boolean closed = false; private @Nullable Connection target; @@ -434,6 +467,28 @@ public class LazyConnectionDataSourceProxy extends DelegatingDataSource { // Ignore: no warnings to expose yet. return null; } + case "setNetworkTimeout" -> { + this.networkTimeoutExecutor = (Executor) args[0]; + this.networkTimeout = (Integer) args[1]; + return null; + } + case "getNetworkTimeout" -> { + if (this.networkTimeout != null) { + return this.networkTimeout; + } + // Else fetch actual Connection and check there. + } + case "setClientInfo" -> { + if (args.length == 2) { + if (this.clientInfo == null) { + this.clientInfo = new LinkedHashMap<>(); + } + this.clientInfo.put((String) args[0], (String) args[1]); + return null; + } + // Else fetch actual Connection and check there. + // setClientInfo(Properties) will fall-through + } case "close" -> { // Ignore: no target connection yet. this.closed = true; @@ -530,6 +585,14 @@ public class LazyConnectionDataSourceProxy extends DelegatingDataSource { if (this.autoCommit != null && this.autoCommit != defaultAutoCommit()) { target.setAutoCommit(this.autoCommit); } + if (this.networkTimeout != null) { + target.setNetworkTimeout(this.networkTimeoutExecutor, this.networkTimeout); + } + if (this.clientInfo != null) { + for (Map.Entry entry: this.clientInfo.entrySet()) { + target.setClientInfo(entry.getKey(), entry.getValue()); + } + } } catch (Throwable settingsEx) { logger.debug("Failed to apply transaction settings to JDBC Connection", settingsEx); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java index 943a8075aec..47ac4d4bd56 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java @@ -18,11 +18,17 @@ package org.springframework.jdbc.datasource; import java.lang.reflect.Field; import java.sql.Connection; +import java.sql.SQLException; import java.util.Arrays; import java.util.HashSet; +import java.util.Properties; import java.util.Set; +import java.util.concurrent.Executor; import java.util.stream.Stream; +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.util.ReflectionUtils; @@ -32,19 +38,37 @@ import static java.sql.Connection.TRANSACTION_READ_COMMITTED; import static java.sql.Connection.TRANSACTION_READ_UNCOMMITTED; import static java.sql.Connection.TRANSACTION_REPEATABLE_READ; import static java.sql.Connection.TRANSACTION_SERIALIZABLE; +import static java.sql.ResultSet.CLOSE_CURSORS_AT_COMMIT; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Tests for {@link LazyConnectionDataSourceProxy}. * * @author Sam Brannen + * @author Chengang Guan * @since 6.1 */ class LazyConnectionDataSourceProxyTests { private final LazyConnectionDataSourceProxy proxy = new LazyConnectionDataSourceProxy(); + private final LazyConnectionDataSourceProxy lazyProxy = new LazyConnectionDataSourceProxy(); + + + @BeforeEach + void setup() { + lazyProxy.setDefaultAutoCommit(false); + lazyProxy.setDefaultTransactionIsolation(TRANSACTION_READ_UNCOMMITTED); + } @Test void setDefaultTransactionIsolationNameToUnsupportedValues() { @@ -94,6 +118,157 @@ class LazyConnectionDataSourceProxyTests { assertThat(proxy.defaultTransactionIsolation()).isEqualTo(TRANSACTION_SERIALIZABLE); } + @Test + void lazyHandlingCatalog() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection1 = mock(); + Connection physicalConnection2 = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection1).thenReturn(physicalConnection2); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection1 = lazyProxy.getConnection(); + lazyConnection1.setCatalog("catalogName"); + assertThat(lazyConnection1.getCatalog()).isEqualTo("catalogName"); + verify(physicalConnection1, never()).setCatalog("catalogName"); + verify(physicalConnection1, never()).getCatalog(); + establishPhysicalConnection(lazyConnection1); + verify(physicalConnection1).setCatalog("catalogName"); + + Connection lazyConnection2 = lazyProxy.getConnection(); + lazyConnection2.getCatalog(); // establish physical connection immediately + verify(physicalConnection2).getCatalog(); + } + + @Test + void lazyHandlingSchema() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection1 = mock(); + Connection physicalConnection2 = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection1).thenReturn(physicalConnection2); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection1 = lazyProxy.getConnection(); + lazyConnection1.setSchema("schemaName"); + assertThat(lazyConnection1.getSchema()).isEqualTo("schemaName"); + verify(physicalConnection1, never()).setSchema("schemaName"); + verify(physicalConnection1, never()).getSchema(); + establishPhysicalConnection(lazyConnection1); + verify(physicalConnection1).setSchema("schemaName"); + + Connection lazyConnection2 = lazyProxy.getConnection(); + lazyConnection2.getSchema(); // establish physical connection immediately + verify(physicalConnection2).getSchema(); + } + + @Test + void lazyHandlingHoldability() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection1 = mock(); + Connection physicalConnection2 = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection1).thenReturn(physicalConnection2); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection1 = lazyProxy.getConnection(); + lazyConnection1.setHoldability(CLOSE_CURSORS_AT_COMMIT); + assertThat(lazyConnection1.getHoldability()).isEqualTo(CLOSE_CURSORS_AT_COMMIT); + verify(physicalConnection1, never()).setHoldability(CLOSE_CURSORS_AT_COMMIT); + verify(physicalConnection1, never()).getHoldability(); + establishPhysicalConnection(lazyConnection1); + verify(physicalConnection1).setHoldability(CLOSE_CURSORS_AT_COMMIT); + + Connection lazyConnection2 = lazyProxy.getConnection(); + lazyConnection2.getHoldability(); // establish physical connection immediately + verify(physicalConnection2).getHoldability(); + } + + @Test + void lazyHandlingTransactionIsolation() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection = lazyProxy.getConnection(); + lazyConnection.setTransactionIsolation(TRANSACTION_READ_COMMITTED); + assertThat(lazyConnection.getTransactionIsolation()).isEqualTo(TRANSACTION_READ_COMMITTED); + verify(physicalConnection, never()).setTransactionIsolation(TRANSACTION_READ_COMMITTED); + verify(physicalConnection, never()).getTransactionIsolation(); + establishPhysicalConnection(lazyConnection); + verify(physicalConnection).setTransactionIsolation(TRANSACTION_READ_COMMITTED); + } + + @Test + void lazyHandlingAutoCommit() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection = lazyProxy.getConnection(); + lazyConnection.setAutoCommit(true); + assertThat(lazyConnection.getAutoCommit()).isTrue(); + verify(physicalConnection, never()).setAutoCommit(true); + verify(physicalConnection, never()).getAutoCommit(); + establishPhysicalConnection(lazyConnection); + verify(physicalConnection).setAutoCommit(true); + } + + @Test + void lazyHandlingNetworkTimeoutExecutor() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection1 = mock(); + Connection physicalConnection2 = mock(); + Executor executor = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection1).thenReturn(physicalConnection2); + doThrow(SQLException.class).when(physicalConnection2).setNetworkTimeout(eq(null), anyInt()); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection1 = lazyProxy.getConnection(); + lazyConnection1.setNetworkTimeout(executor, 1000); + assertThat(lazyConnection1.getNetworkTimeout()).isEqualTo(1000); + verify(physicalConnection1, never()).setNetworkTimeout(executor, 1000); + verify(physicalConnection1, never()).getNetworkTimeout(); + establishPhysicalConnection(lazyConnection1); + verify(physicalConnection1).setNetworkTimeout(executor, 1000); + + // null executor + Connection lazyConnection2 = lazyProxy.getConnection(); + lazyConnection2.setNetworkTimeout(null, 1000); + assertThatThrownBy(() -> establishPhysicalConnection(lazyConnection2)).isInstanceOf(SQLException.class); + } + + @Test + void lazyHandlingClientInfoForKV() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection = lazyProxy.getConnection(); + lazyConnection.setClientInfo("k1", "v1"); + lazyConnection.setClientInfo("k2", "v2"); + verify(physicalConnection, never()).setClientInfo("k1", "v1"); + verify(physicalConnection, never()).setClientInfo("k2", "v2"); + lazyConnection.getClientInfo("k1"); // establish physical connection immediately + verify(physicalConnection).setClientInfo("k1", "v1"); + verify(physicalConnection).getClientInfo("k1"); + } + + @Test + void nonLazyHandlingClientInfoForProperties() throws SQLException { + DataSource mockDataSource = mock(); + Connection physicalConnection = mock(); + when(mockDataSource.getConnection()).thenReturn(physicalConnection); + lazyProxy.setTargetDataSource(mockDataSource); + + Connection lazyConnection = lazyProxy.getConnection(); + Properties properties = new Properties(); + properties.setProperty("k1", "v1"); + properties.setProperty("k2", "v2"); + lazyConnection.setClientInfo(properties); // establish physical connection immediately + verify(physicalConnection).setClientInfo(properties); + } + private static Stream streamIsolationConstants() { return Arrays.stream(Connection.class.getFields()) @@ -102,4 +277,8 @@ class LazyConnectionDataSourceProxyTests { .filter(name -> name.startsWith("TRANSACTION_")); } + private static void establishPhysicalConnection(Connection lazyConnection) throws SQLException { + lazyConnection.prepareStatement("SELECT 1"); + } + } From 8c1b366bda849b381e236de663ea0bb7bbc8d0cd Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:38:39 +0200 Subject: [PATCH 2/2] Polish contribution See gh-37261 --- .../LazyConnectionDataSourceProxy.java | 2 +- .../LazyConnectionDataSourceProxyTests.java | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java index 34c751d08c6..c81cc964220 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java @@ -38,7 +38,7 @@ import org.springframework.util.Assert; * Proxy for a target DataSource, fetching actual JDBC Connections lazily, * i.e. not until first creation of a Statement. Connection initialization * properties like auto-commit mode, transaction isolation, read-only mode, - * catalog, schema, holdability, client info and network timeout will be kept + * catalog, schema, holdability, client info, and network timeout will be kept * and applied to the actual JDBC Connection as soon as an actual Connection * is fetched (if ever). Consequently, commit and rollback calls will be ignored * if no Statements have been created. diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java index 47ac4d4bd56..cd9f6db99cf 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxyTests.java @@ -40,8 +40,8 @@ import static java.sql.Connection.TRANSACTION_REPEATABLE_READ; import static java.sql.Connection.TRANSACTION_SERIALIZABLE; import static java.sql.ResultSet.CLOSE_CURSORS_AT_COMMIT; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; @@ -119,7 +119,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingCatalog() throws SQLException { + void lazyHandlingOfCatalog() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection1 = mock(); Connection physicalConnection2 = mock(); @@ -140,7 +140,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingSchema() throws SQLException { + void lazyHandlingOfSchema() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection1 = mock(); Connection physicalConnection2 = mock(); @@ -161,7 +161,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingHoldability() throws SQLException { + void lazyHandlingOfHoldability() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection1 = mock(); Connection physicalConnection2 = mock(); @@ -182,7 +182,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingTransactionIsolation() throws SQLException { + void lazyHandlingOfTransactionIsolation() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection = mock(); when(mockDataSource.getConnection()).thenReturn(physicalConnection); @@ -198,7 +198,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingAutoCommit() throws SQLException { + void lazyHandlingOfAutoCommit() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection = mock(); when(mockDataSource.getConnection()).thenReturn(physicalConnection); @@ -214,7 +214,7 @@ class LazyConnectionDataSourceProxyTests { } @Test - void lazyHandlingNetworkTimeoutExecutor() throws SQLException { + void lazyHandlingOfNetworkTimeoutExecutor() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection1 = mock(); Connection physicalConnection2 = mock(); @@ -234,11 +234,12 @@ class LazyConnectionDataSourceProxyTests { // null executor Connection lazyConnection2 = lazyProxy.getConnection(); lazyConnection2.setNetworkTimeout(null, 1000); - assertThatThrownBy(() -> establishPhysicalConnection(lazyConnection2)).isInstanceOf(SQLException.class); + assertThatExceptionOfType(SQLException.class) + .isThrownBy(() -> establishPhysicalConnection(lazyConnection2)); } @Test - void lazyHandlingClientInfoForKV() throws SQLException { + void lazyHandlingOfClientInfoForKeyValuePairs() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection = mock(); when(mockDataSource.getConnection()).thenReturn(physicalConnection); @@ -251,11 +252,12 @@ class LazyConnectionDataSourceProxyTests { verify(physicalConnection, never()).setClientInfo("k2", "v2"); lazyConnection.getClientInfo("k1"); // establish physical connection immediately verify(physicalConnection).setClientInfo("k1", "v1"); + verify(physicalConnection).setClientInfo("k2", "v2"); verify(physicalConnection).getClientInfo("k1"); } @Test - void nonLazyHandlingClientInfoForProperties() throws SQLException { + void nonLazyHandlingOfClientInfoForProperties() throws SQLException { DataSource mockDataSource = mock(); Connection physicalConnection = mock(); when(mockDataSource.getConnection()).thenReturn(physicalConnection);