This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new b8d5405f44 [Fix][Connector-V2][Jdbc] Validate a cached connection
before reusing it (#11678)
b8d5405f44 is described below
commit b8d5405f440b685f842546bd2feb98a37eb3bcf7
Author: Santhosh Kumar Somarapu <[email protected]>
AuthorDate: Sat Aug 22 19:57:36 2026 -0500
[Fix][Connector-V2][Jdbc] Validate a cached connection before reusing it
(#11678)
Signed-off-by: Santhosh Kumar Somarapu <[email protected]>
---
.../SimpleJdbcConnectionPoolProviderProxy.java | 7 +
.../seatunnel/jdbc/sink/ConnectionPoolManager.java | 114 +++++++++-
.../seatunnel/jdbc/sink/JdbcSinkWriter.java | 41 ++--
.../jdbc/sink/ConnectionPoolManagerTest.java | 230 +++++++++++++++++++++
4 files changed, 370 insertions(+), 22 deletions(-)
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/connection/SimpleJdbcConnectionPoolProviderProxy.java
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/connection/SimpleJdbcConnectionPoolProviderProxy.java
index 6a3f024e56..f9c83318b0 100644
---
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/connection/SimpleJdbcConnectionPoolProviderProxy.java
+++
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/connection/SimpleJdbcConnectionPoolProviderProxy.java
@@ -44,6 +44,13 @@ public class SimpleJdbcConnectionPoolProviderProxy
implements JdbcConnectionProv
return poolManager.getConnection(queueIndex);
}
+ /**
+ * Note that this is not a pure predicate. {@code getConnection} validates
the cached connection
+ * and replaces it when it is no longer usable, so calling this can repair
the cached connection
+ * as a side effect and then report it valid. That is intended: for a
connection held per queue
+ * index there is nothing a caller could usefully do with "invalid" other
than ask for a new
+ * one.
+ */
@Override
public boolean isConnectionValid() throws SQLException {
return poolManager.containsConnection(queueIndex)
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManager.java
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManager.java
index 67778b945b..08a49c4c3f 100644
---
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManager.java
+++
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManager.java
@@ -20,28 +20,61 @@ package org.apache.seatunnel.connectors.seatunnel.jdbc.sink;
import org.apache.seatunnel.shade.com.zaxxer.hikari.HikariDataSource;
import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
import java.sql.Connection;
import java.sql.SQLException;
+import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+@Slf4j
@Getter
public class ConnectionPoolManager {
+ private static final long DEFAULT_VALIDATION_TIMEOUT_MILLIS = 5000L;
+
+ private static final long REPLACEMENT_WARN_INTERVAL_NANOS =
TimeUnit.MINUTES.toNanos(1);
+
private final HikariDataSource connectionPool;
private final Map<Integer, Connection> connectionMap;
+ private final AtomicLong replacementsSinceLastWarn = new AtomicLong();
+
+ private final AtomicLong lastReplacementWarnNanos;
+
ConnectionPoolManager(HikariDataSource connectionPool) {
this.connectionPool = connectionPool;
connectionMap = new ConcurrentHashMap<>();
+ // Backdated so the first replacement warns immediately rather than
waiting out an interval.
+ lastReplacementWarnNanos =
+ new AtomicLong(System.nanoTime() -
REPLACEMENT_WARN_INTERVAL_NANOS);
}
+ /**
+ * Returns the connection held for this queue index, replacing it first if
it is no longer
+ * usable.
+ *
+ * <p>A connection is borrowed from the pool once per index and then held
for the lifetime of
+ * the writer, so it never goes back for Hikari's housekeeper to inspect.
On a streaming job the
+ * gap between writes can be hours, and the server or anything on the path
can close the socket
+ * in the meantime. Handing the cached connection back without checking it
means the failure
+ * surfaces as a write error after the record has already been consumed.
+ */
public Connection getConnection(int index) {
- return connectionMap.computeIfAbsent(
+ return connectionMap.compute(
index,
- i -> {
+ (i, cached) -> {
+ if (cached != null && isUsable(cached)) {
+ return cached;
+ }
+ if (cached != null) {
+ logReplacement(i);
+ }
+ closeQuietly(cached);
try {
return connectionPool.getConnection();
} catch (SQLException e) {
@@ -50,6 +83,83 @@ public class ConnectionPoolManager {
});
}
+ /**
+ * Uses the pool's own validation settings rather than a second opinion:
{@code
+ * connectionTestQuery} when one is configured, because it is configured
precisely for drivers
+ * whose {@code isValid} cannot be trusted, and {@code isValid} otherwise.
+ */
+ private boolean isUsable(Connection connection) {
+ try {
+ if (connection.isClosed()) {
+ return false;
+ }
+ int timeoutSeconds =
+ (int)
+ Math.max(
+ 1,
+
TimeUnit.MILLISECONDS.toSeconds(getValidationTimeoutMillis()));
+ String testQuery = connectionPool.getConnectionTestQuery();
+ if (testQuery == null) {
+ return connection.isValid(timeoutSeconds);
+ }
+ try (Statement statement = connection.createStatement()) {
+ statement.setQueryTimeout(timeoutSeconds);
+ statement.execute(testQuery);
+ return true;
+ }
+ } catch (SQLException e) {
+ log.debug("Cached connection for index is no longer usable,
replacing it", e);
+ return false;
+ }
+ }
+
+ /**
+ * Reports a replaced connection at WARN, rate limited to one message per
minute per manager.
+ *
+ * <p>Replacing an occasionally idle connection is the expected case and
is not worth a warning
+ * on every occurrence. A validation that fails systematically is not: a
misconfigured {@code
+ * connectionTestQuery}, a persistent network fault or a server-side
connection limit makes
+ * every call replace the connection, which silently turns the cache off
and churns connections
+ * continuously. Logging only at DEBUG would leave that indistinguishable
from healthy operation
+ * in a default deployment, so the first occurrence and a periodic summary
are surfaced.
+ */
+ private void logReplacement(int index) {
+ long replacements = replacementsSinceLastWarn.incrementAndGet();
+ long now = System.nanoTime();
+ long last = lastReplacementWarnNanos.get();
+
+ if (now - last >= REPLACEMENT_WARN_INTERVAL_NANOS
+ && lastReplacementWarnNanos.compareAndSet(last, now)) {
+ replacementsSinceLastWarn.addAndGet(-replacements);
+ log.warn(
+ "Replaced an unusable pooled connection for queue index
{}. "
+ + "{} replacement(s) since the last such message.
Repeated messages "
+ + "indicate the connection is not surviving
between writes.",
+ index,
+ replacements);
+
+ return;
+ }
+
+ log.debug("Cached connection for queue index {} is no longer usable,
replacing it", index);
+ }
+
+ private long getValidationTimeoutMillis() {
+ long configured = connectionPool.getValidationTimeout();
+ return configured > 0 ? configured : DEFAULT_VALIDATION_TIMEOUT_MILLIS;
+ }
+
+ private void closeQuietly(Connection connection) {
+ if (connection == null) {
+ return;
+ }
+ try {
+ connection.close();
+ } catch (SQLException e) {
+ log.debug("Failed to close an unusable connection, discarding it
anyway", e);
+ }
+ }
+
public boolean containsConnection(int index) {
return connectionMap.containsKey(index);
}
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkWriter.java
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkWriter.java
index 18a5f85118..36cad4e525 100644
---
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkWriter.java
+++
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkWriter.java
@@ -46,6 +46,7 @@ import
org.apache.seatunnel.connectors.seatunnel.jdbc.state.XidInfo;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
+import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.sql.Savepoint;
@@ -354,8 +355,9 @@ public class JdbcSinkWriter extends
AbstractJdbcSinkWriter<ConnectionPoolManager
tryOpen();
outputFormat.flush();
try {
- if (!connectionProvider.getConnection().getAutoCommit()) {
- connectionProvider.getConnection().commit();
+ Connection connection = connectionProvider.getConnection();
+ if (!connection.getAutoCommit()) {
+ connection.commit();
}
} catch (SQLException e) {
throw new JdbcConnectorException(
@@ -388,9 +390,10 @@ public class JdbcSinkWriter extends
AbstractJdbcSinkWriter<ConnectionPoolManager
return false;
}
try {
+ Connection connection = connectionProvider.getConnection();
Savepoint previousSavepoint = lastSuccessfulBatchSavepoint;
- lastSuccessfulBatchSavepoint =
connectionProvider.getConnection().setSavepoint();
- releaseSavepointSilently(previousSavepoint);
+ lastSuccessfulBatchSavepoint = connection.setSavepoint();
+ releaseSavepointSilently(connection, previousSavepoint);
return true;
} catch (SQLException e) {
throw new JdbcConnectorException(
@@ -430,12 +433,12 @@ public class JdbcSinkWriter extends
AbstractJdbcSinkWriter<ConnectionPoolManager
// Releasing an old savepoint is a best-effort cleanup. Some drivers
invalidate savepoints after
// rollback/commit and should not fail the writer just because cleanup is
no longer possible.
- private void releaseSavepointSilently(Savepoint savepoint) {
+ private void releaseSavepointSilently(Connection connection, Savepoint
savepoint) {
if (savepoint == null) {
return;
}
try {
- connectionProvider.getConnection().releaseSavepoint(savepoint);
+ connection.releaseSavepoint(savepoint);
} catch (SQLException e) {
log.debug("Failed to release JDBC savepoint after moving row-error
batch boundary.", e);
}
@@ -491,24 +494,22 @@ public class JdbcSinkWriter extends
AbstractJdbcSinkWriter<ConnectionPoolManager
}
private void rollbackIfNeeded() throws SQLException {
- try {
- if (connectionProvider.getConnection().getAutoCommit()) {
- return;
- }
- if (lastSuccessfulBatchSavepoint != null) {
-
connectionProvider.getConnection().rollback(lastSuccessfulBatchSavepoint);
- } else {
- connectionProvider.getConnection().rollback();
- }
- lastSuccessfulBatchSavepoint = null;
- } catch (SQLException rollbackEx) {
- throw rollbackEx;
+ Connection connection = connectionProvider.getConnection();
+ if (connection.getAutoCommit()) {
+ return;
+ }
+ if (lastSuccessfulBatchSavepoint != null) {
+ connection.rollback(lastSuccessfulBatchSavepoint);
+ } else {
+ connection.rollback();
}
+ lastSuccessfulBatchSavepoint = null;
}
private void commitIfNeeded() throws SQLException {
- if (!connectionProvider.getConnection().getAutoCommit()) {
- connectionProvider.getConnection().commit();
+ Connection connection = connectionProvider.getConnection();
+ if (!connection.getAutoCommit()) {
+ connection.commit();
lastSuccessfulBatchSavepoint = null;
}
}
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManagerTest.java
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManagerTest.java
new file mode 100644
index 0000000000..f0734de246
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/ConnectionPoolManagerTest.java
@@ -0,0 +1,230 @@
+/*
+ * 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
+ *
+ * http://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.apache.seatunnel.connectors.seatunnel.jdbc.sink;
+
+import org.apache.seatunnel.shade.com.zaxxer.hikari.HikariDataSource;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ConnectionPoolManagerTest {
+
+ @Test
+ void reusesAConnectionThatIsStillUsable() throws SQLException {
+ Connection live = mock(Connection.class);
+ when(live.isClosed()).thenReturn(false);
+ when(live.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(live);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(live, manager.getConnection(0));
+ Assertions.assertSame(live, manager.getConnection(0));
+
+ // Borrowed once, then served from the cache.
+ verify(pool, times(1)).getConnection();
+ verify(live, never()).close();
+ }
+
+ @Test
+ void replacesAConnectionThatHasGoneStale() throws SQLException {
+ // A streaming sink can sit idle for hours between writes. The server
closes the socket in
+ // the meantime, and the cached connection is dead by the time the
next record arrives.
+ Connection stale = mock(Connection.class);
+ when(stale.isClosed()).thenReturn(false);
+ when(stale.isValid(anyInt())).thenReturn(false);
+
+ Connection fresh = mock(Connection.class);
+ when(fresh.isClosed()).thenReturn(false);
+ when(fresh.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(stale, fresh);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(stale, manager.getConnection(0));
+ Assertions.assertSame(fresh, manager.getConnection(0));
+
+ verify(pool, times(2)).getConnection();
+ // The dead one is handed back rather than leaked.
+ verify(stale, times(1)).close();
+ }
+
+ @Test
+ void replacesAConnectionThatIsAlreadyClosed() throws SQLException {
+ Connection closed = mock(Connection.class);
+ when(closed.isClosed()).thenReturn(true);
+
+ Connection fresh = mock(Connection.class);
+ when(fresh.isClosed()).thenReturn(false);
+ when(fresh.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(closed, fresh);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(closed, manager.getConnection(0));
+ Assertions.assertSame(fresh, manager.getConnection(0));
+
+ // isValid is never consulted once isClosed has answered.
+ verify(closed, never()).isValid(anyInt());
+ }
+
+ @Test
+ void usesTheConfiguredTestQueryInsteadOfIsValid() throws SQLException {
+ // connectionTestQuery is set for drivers whose isValid cannot be
trusted, so the check has
+ // to honour it rather than form a second opinion.
+ Statement statement = mock(Statement.class);
+ Connection connection = mock(Connection.class);
+ when(connection.isClosed()).thenReturn(false);
+ when(connection.createStatement()).thenReturn(statement);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(connection);
+ when(pool.getConnectionTestQuery()).thenReturn("SELECT 1");
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(connection, manager.getConnection(0));
+ Assertions.assertSame(connection, manager.getConnection(0));
+
+ verify(statement, times(1)).execute("SELECT 1");
+ verify(connection, never()).isValid(anyInt());
+ }
+
+ @Test
+ void replacesTheConnectionWhenTheTestQueryFails() throws SQLException {
+ Statement failing = mock(Statement.class);
+ when(failing.execute(anyString())).thenThrow(new
SQLException("connection is closed"));
+
+ Connection stale = mock(Connection.class);
+ when(stale.isClosed()).thenReturn(false);
+ when(stale.createStatement()).thenReturn(failing);
+
+ Connection fresh = mock(Connection.class);
+ when(fresh.isClosed()).thenReturn(false);
+ when(fresh.createStatement()).thenReturn(mock(Statement.class));
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(stale, fresh);
+ when(pool.getConnectionTestQuery()).thenReturn("SELECT 1");
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(stale, manager.getConnection(0));
+ Assertions.assertSame(fresh, manager.getConnection(0));
+
+ verify(pool, times(2)).getConnection();
+ }
+
+ @Test
+ void passesThePoolsValidationTimeoutToIsValid() throws SQLException {
+ Connection connection = mock(Connection.class);
+ when(connection.isClosed()).thenReturn(false);
+ when(connection.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(connection);
+ when(pool.getValidationTimeout()).thenReturn(8000L);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ manager.getConnection(0);
+ manager.getConnection(0);
+
+ // Seconds, rounded down from the pool's own millisecond setting.
+ verify(connection, times(1)).isValid(8);
+ }
+
+ @Test
+ void fallsBackToFiveSecondsWhenThePoolHasNoValidationTimeout() throws
SQLException {
+ Connection connection = mock(Connection.class);
+ when(connection.isClosed()).thenReturn(false);
+ when(connection.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(connection);
+ // Hikari reports 0 when the validation timeout has not been
configured.
+ when(pool.getValidationTimeout()).thenReturn(0L);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ manager.getConnection(0);
+ manager.getConnection(0);
+
+ verify(connection, times(1)).isValid(5);
+ }
+
+ @Test
+ void neverPassesATimeoutBelowOneSecond() throws SQLException {
+ Connection connection = mock(Connection.class);
+ when(connection.isClosed()).thenReturn(false);
+ when(connection.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(connection);
+ // Sub-second timeouts truncate to zero seconds, which isValid treats
as no timeout at all.
+ when(pool.getValidationTimeout()).thenReturn(250L);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ manager.getConnection(0);
+ manager.getConnection(0);
+
+ verify(connection, times(1)).isValid(1);
+ }
+
+ @Test
+ void keepsConnectionsSeparatePerQueueIndex() throws SQLException {
+ Connection first = mock(Connection.class);
+ when(first.isClosed()).thenReturn(false);
+ when(first.isValid(anyInt())).thenReturn(true);
+
+ Connection second = mock(Connection.class);
+ when(second.isClosed()).thenReturn(false);
+ when(second.isValid(anyInt())).thenReturn(true);
+
+ HikariDataSource pool = mock(HikariDataSource.class);
+ when(pool.getConnection()).thenReturn(first, second);
+
+ ConnectionPoolManager manager = new ConnectionPoolManager(pool);
+
+ Assertions.assertSame(first, manager.getConnection(0));
+ Assertions.assertSame(second, manager.getConnection(1));
+ Assertions.assertSame(first, manager.getConnection(0));
+ Assertions.assertSame(second, manager.getConnection(1));
+
+ verify(pool, times(2)).getConnection();
+ }
+}