-
Notifications
You must be signed in to change notification settings - Fork 38.9k
Description
Description:
I discovered a state inconsistency issue in LazyConnectionDataSourceProxy where connection settings (such as transaction isolation level or auto-commit) are not reapplied after an initial failure. This leads to the reuse of "partially initialized" connections with incorrect configurations during retries.
Root Cause Analysis:
In LazyConnectionInvocationHandler.getTargetConnection(), the sequence of "connection acquisition" and "configuration application" lacks atomicity.
if (this.target == null) {
// Step 1: Assign connection immediately
this.target = dataSource.getConnection();
// Step 2: Apply settings (Failure point)
if (this.transactionIsolation != null && ...) {
// If this throws SQLException, 'this.target' remains assigned but dirty.
this.target.setTransactionIsolation(this.transactionIsolation);
}
}
else {
// On retry: logic enters here because 'this.target' is not null.
// The configuration logic is skipped, and the dirty connection is returned.
return this.target;
}
return this.target;
}
Scenario:
Client requests TRANSACTION_SERIALIZABLE isolation level.
Proxy stores this in memory.
Client triggers connection fetch (e.g., createStatement()).
Proxy fetches physical connection: this.target = dataSource.getConnection() (Success).
Proxy attempts to apply settings: this.target.setTransactionIsolation(...) (Fails with SQLException).
Exception propagates to the client.
Client retries (e.g., Spring Retry, Loop): clientConn.createStatement().
Proxy checks if (this.target == null) -> False (Already assigned).
Result: The initialization logic is skipped, and the dirty connection is returned. The query executes with the default isolation (e.g., READ_COMMITTED) instead of SERIALIZABLE.
Impact:
Data Consistency Risk: Queries execute with incorrect isolation levels or auto-commit modes, potentially leading to race conditions, phantom reads, or unintended commits.
Silent Failure: No exception is thrown on retry, making this bug difficult to detect in production.
Reproduction:
Here is a JUnit 5 + Mockito test case demonstrating the issue.
@Test
@DisplayName("Bug reproduction: Connection reused without reapplying settings after initialization failure")
void verifyStateCorruptionOnInitializationFailure() throws SQLException {
// 1. Mock setup
DataSource targetDataSource = mock(DataSource.class);
Connection physicalConnection = mock(Connection.class);
// Physical connection acquisition always succeeds
given(targetDataSource.getConnection()).willReturn(physicalConnection);
// Mock default values (triggers internal checkDefaultConnectionProperties)
given(physicalConnection.getAutoCommit()).willReturn(true);
given(physicalConnection.getTransactionIsolation())
.willReturn(Connection.TRANSACTION_READ_COMMITTED);
// Mock createStatement to return a dummy object (to pass null-checks later)
given(physicalConnection.createStatement()).willReturn(mock(Statement.class));
// [Key] setTransactionIsolation always throws exception
int requiredIsolation = Connection.TRANSACTION_SERIALIZABLE;
doThrow(new SQLException("Simulated Network Error: Isolation Set Failed"))
.when(physicalConnection).setTransactionIsolation(requiredIsolation);
LazyConnectionDataSourceProxy proxyDataSource = new LazyConnectionDataSourceProxy(targetDataSource);
// Step 1: First attempt - fails
Connection clientConn = proxyDataSource.getConnection();
clientConn.setTransactionIsolation(requiredIsolation);
assertThatThrownBy(() -> clientConn.createStatement())
.isInstanceOf(SQLException.class)
.hasMessageContaining("Simulated Network Error");
// Step 2: Retry - succeeds silently without reapplying settings (BUG)
Statement stmt = clientConn.createStatement();
// Step 3: Verification
// Physical connection fetched twice (1. checkDefaultConnectionProperties + 2. first attempt)
// If fixed, it should be fetched again or re-initialized on retry.
verify(targetDataSource, times(2)).getConnection();
// [Key verification] setTransactionIsolation called only once (during the failed attempt).
// It was NOT retried in Step 2.
verify(physicalConnection, times(1)).setTransactionIsolation(requiredIsolation);
// Statement created successfully with WRONG isolation level
assertThat(stmt).isNotNull();
}
Suggested Fix:
We should ensure atomicity during connection initialization. A robust approach would be to use a local variable for initialization and assign it to this.target only after all settings are successfully applied.
private Connection getTargetConnection(Method operation) throws SQLException {
if (this.target == null) {
// Use a local variable to ensure atomicity
Connection target = dataSource.getConnection();
try {
// Apply settings to the local variable
if (this.transactionIsolation != null && ...) {
target.setTransactionIsolation(this.transactionIsolation);
}
if (this.autoCommit != null && ...) {
target.setAutoCommit(this.autoCommit);
}
// ... apply other settings ...
// Assign to member variable only after successful initialization
this.target = target;
}
catch (SQLException ex) {
// Cleanup the physical connection immediately
try {
target.close();
}
catch (SQLException closeEx) {
// Log if necessary
}
throw ex;
}
}
return this.target;
}