This is an automated email from the ASF dual-hosted git repository.
RocMarshal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-jdbc.git
The following commit(s) were added to refs/heads/main by this push:
new f8f84a49 [FLINK-39975][jdbc] Recover JdbcSourceSplitReader from a
connection closed during cancellation (#200)
f8f84a49 is described below
commit f8f84a4948e167611e11eaa09c0cc57b25b8cbf4
Author: laughingman7743 <[email protected]>
AuthorDate: Wed Jun 24 00:11:56 2026 +0900
[FLINK-39975][jdbc] Recover JdbcSourceSplitReader from a connection closed
during cancellation (#200)
---
.../source/reader/JdbcSourceSplitReader.java | 62 +++++++++++-
.../source/reader/JdbcSourceSplitReaderTest.java | 109 +++++++++++++++++++++
2 files changed, 170 insertions(+), 1 deletion(-)
diff --git
a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReader.java
b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReader.java
index 2ffca4bc..58928c26 100644
---
a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReader.java
+++
b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReader.java
@@ -67,6 +67,13 @@ public class JdbcSourceSplitReader<T>
private static final Logger LOG =
LoggerFactory.getLogger(JdbcSourceSplitReader.class);
+ /**
+ * Maximum number of times a split is re-opened after the connection was
found to be closed
+ * (e.g. torn down concurrently while the source is being cancelled).
Mirrors the sink default
+ * {@code JdbcExecutionOptions.DEFAULT_MAX_RETRY_TIMES}.
+ */
+ private static final int MAX_CONNECTION_RETRIES = 3;
+
private final Configuration config;
@Nullable private JdbcSourceSplit currentSplit;
private final Queue<JdbcSourceSplit> splits;
@@ -259,7 +266,7 @@ public class JdbcSourceSplitReader<T>
final JdbcSourceSplit nextSplit = splits.poll();
if (nextSplit != null) {
currentSplit = nextSplit;
- openResultSetForSplit(currentSplit);
+ openResultSetForSplitWithReconnect(currentSplit);
return true;
}
return false;
@@ -268,6 +275,59 @@ public class JdbcSourceSplitReader<T>
}
}
+ /**
+ * Opens the result set for the split, re-establishing the connection and
retrying if the
+ * connection was closed underneath us.
+ *
+ * <p>When a bounded query (e.g. {@code LIMIT}) produces enough rows, the
job finishes and the
+ * source is cancelled while this reader may still be opening the next
split. The connection
+ * that the provider validated can then be torn down by the time we
prepare/execute the
+ * statement, surfacing as a closed-connection error (e.g. Derby {@code
08003 "No current
+ * connection"}). That should not fail the whole job, so we re-establish
the connection and
+ * retry, mirroring the retry/reconnect handling in {@code
JdbcOutputFormat#flush()}. We only
+ * retry when the connection is no longer valid; a genuine query error on
a healthy connection
+ * is rethrown immediately, and a truly unreachable database makes
re-establishing fail so real
+ * errors still propagate once the retry budget is exhausted.
+ */
+ private void openResultSetForSplitWithReconnect(JdbcSourceSplit split)
+ throws SQLException, ClassNotFoundException {
+ for (int attempt = 0; attempt <= MAX_CONNECTION_RETRIES; attempt++) {
+ try {
+ openResultSetForSplit(split);
+ return;
+ } catch (SQLException e) {
+ // Only retry when the connection itself is no longer valid
(e.g. torn down while
+ // the source is being cancelled). A genuine query error on a
healthy connection is
+ // rethrown immediately, and a truly unreachable database
keeps failing the re-open,
+ // so real errors still propagate once the retry budget is
exhausted.
+ if (attempt >= MAX_CONNECTION_RETRIES ||
connectionStillValid()) {
+ throw e;
+ }
+ LOG.warn(
+ "Connection was closed while opening split {} (attempt
{}/{}); "
+ + "re-establishing the connection and
retrying.",
+ split.splitId(),
+ attempt + 1,
+ MAX_CONNECTION_RETRIES,
+ e);
+ // Drop the dead connection together with the statement/result
set left on it so
+ // the retried open re-establishes cleanly, without running
the close helpers
+ // (which rethrow as unchecked exceptions) against the closed
connection.
+ resultSet = null;
+ statement = null;
+ connectionProvider.closeConnection();
+ }
+ }
+ }
+
+ private boolean connectionStillValid() {
+ try {
+ return connectionProvider.isConnectionValid();
+ } catch (SQLException ignored) {
+ return false;
+ }
+ }
+
private void getOrEstablishConnection() throws SQLException,
ClassNotFoundException {
connection = connectionProvider.getOrEstablishConnection();
if (autoCommit == null) {
diff --git
a/flink-connector-jdbc-core/src/test/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReaderTest.java
b/flink-connector-jdbc-core/src/test/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReaderTest.java
index b28238f9..a81630f2 100644
---
a/flink-connector-jdbc-core/src/test/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReaderTest.java
+++
b/flink-connector-jdbc-core/src/test/java/org/apache/flink/connector/jdbc/core/datastream/source/reader/JdbcSourceSplitReaderTest.java
@@ -34,6 +34,8 @@ import
org.apache.flink.connector.testutils.source.reader.TestingReaderContext;
import org.junit.jupiter.api.Test;
+import java.sql.Connection;
+import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -144,4 +146,111 @@ class JdbcSourceSplitReaderTest extends JdbcDataTestBase {
assertThat(fetchedRecordsWithSplitIds.nextSplit()).isNull();
splitReader.close();
}
+
+ @Test
+ void testFetchReconnectsWhenConnectionClosedWhileOpeningSplit() throws
Exception {
+ // The provider hands back an already-closed connection the first
time, so the reader's
+ // first use of it fails (mimicking the connection being torn down,
e.g. on source
+ // cancellation, before the reader finishes opening the split). The
reader must
+ // re-establish the connection and read the whole split.
+ CountingConnectionProvider provider = new
CountingConnectionProvider(connectionProvider, 1);
+ try (JdbcSourceSplitReader<TestEntry> splitReader =
newReader(provider, split)) {
+ RecordsWithSplitIds<RecordAndOffset<TestEntry>> fetched =
splitReader.fetch();
+ assertThat(fetched.nextSplit()).isEqualTo("1");
+ List<TestEntry> records = new ArrayList<>();
+ RecordAndOffset<TestEntry> recordAndOffset =
fetched.nextRecordFromSplit();
+ while (recordAndOffset != null) {
+ records.add(recordAndOffset.record);
+ recordAndOffset = fetched.nextRecordFromSplit();
+ }
+ assertThat(records).hasSize(TEST_DATA.length);
+ // The first (closed) connection plus exactly one re-established
one: a reconnect ran.
+ assertThat(provider.establishCount).isEqualTo(2);
+ }
+ }
+
+ @Test
+ void testFetchRethrowsImmediatelyWhenConnectionStaysOpen() throws
Exception {
+ // A query error on a healthy (open) connection must be rethrown
immediately, with no
+ // reconnect attempt.
+ CountingConnectionProvider provider = new
CountingConnectionProvider(connectionProvider, 0);
+ JdbcSourceSplit invalidSplit =
+ new JdbcSourceSplit("1", "select * from NON_EXISTENT_TABLE",
null, null);
+ try (JdbcSourceSplitReader<TestEntry> splitReader =
newReader(provider, invalidSplit)) {
+
assertThatThrownBy(splitReader::fetch).isInstanceOf(RuntimeException.class);
+ assertThat(provider.establishCount).isEqualTo(1);
+ }
+ }
+
+ @Test
+ void testFetchFailsAfterExhaustingReconnectRetries() throws Exception {
+ // If the connection keeps being closed, the reader gives up after the
retry budget
+ // instead of looping forever.
+ CountingConnectionProvider provider =
+ new CountingConnectionProvider(connectionProvider,
Integer.MAX_VALUE);
+ try (JdbcSourceSplitReader<TestEntry> splitReader =
newReader(provider, split)) {
+
assertThatThrownBy(splitReader::fetch).isInstanceOf(RuntimeException.class);
+ // The initial attempt plus MAX_CONNECTION_RETRIES (3) reconnect
attempts.
+ assertThat(provider.establishCount).isEqualTo(4);
+ }
+ }
+
+ private JdbcSourceSplitReader<TestEntry> newReader(
+ JdbcConnectionProvider provider, JdbcSourceSplit sourceSplit) {
+ JdbcSourceSplitReader<TestEntry> reader =
+ new JdbcSourceSplitReader<>(
+ new TestingReaderContext(),
+ new Configuration(),
+ TypeInformation.of(TestEntry.class),
+ provider,
+ DeliveryGuarantee.NONE,
+ extractor);
+ reader.handleSplitsChanges(new
SplitsAddition<>(Collections.singletonList(sourceSplit)));
+ return reader;
+ }
+
+ /**
+ * A {@link JdbcConnectionProvider} that closes the first {@code
closeFirstN} connections it
+ * hands out (returning them already closed) to deterministically
reproduce a connection torn
+ * down underneath the reader, while counting how many connections were
established.
+ */
+ private static class CountingConnectionProvider implements
JdbcConnectionProvider {
+ private final JdbcConnectionProvider delegate;
+ private final int closeFirstN;
+ private int establishCount = 0;
+
+ CountingConnectionProvider(JdbcConnectionProvider delegate, int
closeFirstN) {
+ this.delegate = delegate;
+ this.closeFirstN = closeFirstN;
+ }
+
+ @Override
+ public Connection getOrEstablishConnection() throws SQLException,
ClassNotFoundException {
+ Connection connection = delegate.getOrEstablishConnection();
+ if (++establishCount <= closeFirstN) {
+ connection.close();
+ }
+ return connection;
+ }
+
+ @Override
+ public Connection getConnection() {
+ return delegate.getConnection();
+ }
+
+ @Override
+ public boolean isConnectionValid() throws SQLException {
+ return delegate.isConnectionValid();
+ }
+
+ @Override
+ public void closeConnection() {
+ delegate.closeConnection();
+ }
+
+ @Override
+ public Connection reestablishConnection() throws SQLException,
ClassNotFoundException {
+ return delegate.reestablishConnection();
+ }
+ }
}