This is an automated email from the ASF dual-hosted git repository.
zhangshenghang 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 5b0f8a5f69 [Fix][Connector-V2] Fix JDBC sink reconnect for nested
batch exceptions (#10793)
5b0f8a5f69 is described below
commit 5b0f8a5f69388788e91ca9af8833588f6cb25972
Author: David Zollo <[email protected]>
AuthorDate: Tue Jul 21 23:10:50 2026 +0800
[Fix][Connector-V2] Fix JDBC sink reconnect for nested batch exceptions
(#10793)
---
.../seatunnel/jdbc/internal/JdbcOutputFormat.java | 71 ++++++++-
.../internal/JdbcOutputFormatReconnectTest.java | 175 +++++++++++++++++++++
2 files changed, 245 insertions(+), 1 deletion(-)
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormat.java
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormat.java
index d13462f07b..b709302836 100644
---
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormat.java
+++
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormat.java
@@ -30,6 +30,9 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.Serializable;
import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
import java.util.function.Supplier;
import static
org.apache.seatunnel.shade.com.google.common.base.Preconditions.checkNotNull;
@@ -137,7 +140,7 @@ public class JdbcOutputFormat<I, E extends
JdbcBatchStatementExecutor<I>> implem
CommonErrorCodeDeprecated.FLUSH_DATA_FAILED, e);
}
try {
- if (!connectionProvider.isConnectionValid()) {
+ if (shouldRefreshExecutor(findSqlExceptions(e))) {
updateExecutor(true);
}
} catch (Exception exception) {
@@ -228,6 +231,72 @@ public class JdbcOutputFormat<I, E extends
JdbcBatchStatementExecutor<I>> implem
: connectionProvider.getConnection());
}
+ private boolean shouldRefreshExecutor(List<SQLException> sqlExceptions)
throws SQLException {
+ // BatchUpdateException often stores the vendor SQLException in
nextException.
+ return hasConnectionErrorSqlState(sqlExceptions)
+ || isStatementClosed(sqlExceptions)
+ || !connectionProvider.isConnectionValid();
+ }
+
+ private boolean hasConnectionErrorSqlState(List<SQLException>
sqlExceptions) {
+ for (SQLException sqlException : sqlExceptions) {
+ String sqlState = sqlException.getSQLState();
+ if (sqlState != null && sqlState.startsWith("08")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private List<SQLException> findSqlExceptions(Throwable throwable) {
+ List<SQLException> sqlExceptions = new ArrayList<>();
+ while (throwable != null) {
+ if (throwable instanceof SQLException) {
+ collectSqlExceptionChain((SQLException) throwable,
sqlExceptions);
+ }
+ throwable = throwable.getCause();
+ }
+ return sqlExceptions;
+ }
+
+ private void collectSqlExceptionChain(
+ SQLException sqlException, List<SQLException> sqlExceptions) {
+ SQLException current = sqlException;
+ while (current != null) {
+ sqlExceptions.add(current);
+ current = current.getNextException();
+ }
+ }
+
+ private boolean isStatementClosed(List<SQLException> sqlExceptions) {
+ for (SQLException sqlException : sqlExceptions) {
+ if (isStatementClosed(sqlException)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isStatementClosed(SQLException sqlException) {
+ String exceptionClassName =
+
sqlException.getClass().getSimpleName().toLowerCase(Locale.ROOT);
+ if (exceptionClassName.contains("statementisclosedexception")) {
+ return true;
+ }
+
+ String message = sqlException.getMessage();
+ if (message == null) {
+ return false;
+ }
+
+ String normalizedMessage = message.toLowerCase(Locale.ROOT);
+ // SQL Server may report a closed statement handle without using
"closed".
+ return normalizedMessage.contains("statement closed")
+ || normalizedMessage.contains("statement is closed")
+ || normalizedMessage.contains("statement handle is not
executing")
+ || normalizedMessage.contains("statement handle is closed");
+ }
+
/**
* A factory for creating {@link JdbcBatchStatementExecutor} instance.
*
diff --git
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormatReconnectTest.java
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormatReconnectTest.java
new file mode 100644
index 0000000000..c197d499d3
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcOutputFormatReconnectTest.java
@@ -0,0 +1,175 @@
+/*
+ * 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.internal;
+
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import
org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcConnectionConfig;
+import
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.connection.JdbcConnectionProvider;
+import
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.executor.JdbcBatchStatementExecutor;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.sql.BatchUpdateException;
+import java.sql.Connection;
+import java.sql.SQLException;
+
+/** Tests JDBC output retry decisions for nested SQLExceptions thrown by batch
execution. */
+public class JdbcOutputFormatReconnectTest {
+
+ @Test
+ public void
testFlushRetryShouldReconnectWhenBatchNextExceptionHasConnectionSqlState()
+ throws Exception {
+ JdbcConnectionProvider provider =
Mockito.mock(JdbcConnectionProvider.class);
+ Connection connection = Mockito.mock(Connection.class);
+
Mockito.when(provider.getOrEstablishConnection()).thenReturn(connection);
+ Mockito.when(provider.getConnection()).thenReturn(connection);
+ Mockito.when(provider.reestablishConnection()).thenReturn(connection);
+
+ TrackingJdbcBatchExecutor executor =
+ new TrackingJdbcBatchExecutor(
+ batchException(
+ "batch failed",
+ "HY000",
+ new SQLException("connection dropped",
"08006")));
+
+ JdbcOutputFormat<SeaTunnelRow, TrackingJdbcBatchExecutor> outputFormat
=
+ new JdbcOutputFormat<>(provider, buildConnectionConfig(), ()
-> executor);
+ outputFormat.open();
+ outputFormat.writeRecord(new SeaTunnelRow(new Object[] {"AA"}));
+
+ outputFormat.flush();
+
+ Assertions.assertEquals(2, executor.prepareStatementsCalls);
+ Assertions.assertEquals(2, executor.executeBatchCalls);
+ Assertions.assertEquals(1, executor.closeStatementsCalls);
+ Mockito.verify(provider).reestablishConnection();
+ Mockito.verify(provider, Mockito.never()).isConnectionValid();
+ }
+
+ @Test
+ public void
testFlushRetryShouldReconnectWhenBatchNextExceptionIsStatementClosed()
+ throws Exception {
+ JdbcConnectionProvider provider =
Mockito.mock(JdbcConnectionProvider.class);
+ Connection connection = Mockito.mock(Connection.class);
+
Mockito.when(provider.getOrEstablishConnection()).thenReturn(connection);
+ Mockito.when(provider.getConnection()).thenReturn(connection);
+ Mockito.when(provider.reestablishConnection()).thenReturn(connection);
+
+ TrackingJdbcBatchExecutor executor =
+ new TrackingJdbcBatchExecutor(
+ batchException(
+ "batch failed",
+ "HY000",
+ new SQLException("No operations allowed after
statement closed.")));
+
+ JdbcOutputFormat<SeaTunnelRow, TrackingJdbcBatchExecutor> outputFormat
=
+ new JdbcOutputFormat<>(provider, buildConnectionConfig(), ()
-> executor);
+ outputFormat.open();
+ outputFormat.writeRecord(new SeaTunnelRow(new Object[] {"AA"}));
+
+ outputFormat.flush();
+
+ Assertions.assertEquals(2, executor.prepareStatementsCalls);
+ Assertions.assertEquals(2, executor.executeBatchCalls);
+ Assertions.assertEquals(1, executor.closeStatementsCalls);
+ Mockito.verify(provider).reestablishConnection();
+ Mockito.verify(provider, Mockito.never()).isConnectionValid();
+ }
+
+ @Test
+ public void
testFlushRetryShouldReconnectWhenSqlServerStatementHandleIsNotExecuting()
+ throws Exception {
+ JdbcConnectionProvider provider =
Mockito.mock(JdbcConnectionProvider.class);
+ Connection connection = Mockito.mock(Connection.class);
+
Mockito.when(provider.getOrEstablishConnection()).thenReturn(connection);
+ Mockito.when(provider.getConnection()).thenReturn(connection);
+ Mockito.when(provider.reestablishConnection()).thenReturn(connection);
+
+ TrackingJdbcBatchExecutor executor =
+ new TrackingJdbcBatchExecutor(
+ batchException(
+ "batch failed",
+ "HY000",
+ new SQLException("Statement handle is not
executing.")));
+
+ JdbcOutputFormat<SeaTunnelRow, TrackingJdbcBatchExecutor> outputFormat
=
+ new JdbcOutputFormat<>(provider, buildConnectionConfig(), ()
-> executor);
+ outputFormat.open();
+ outputFormat.writeRecord(new SeaTunnelRow(new Object[] {"AA"}));
+
+ outputFormat.flush();
+
+ Assertions.assertEquals(2, executor.prepareStatementsCalls);
+ Assertions.assertEquals(2, executor.executeBatchCalls);
+ Assertions.assertEquals(1, executor.closeStatementsCalls);
+ Mockito.verify(provider).reestablishConnection();
+ Mockito.verify(provider, Mockito.never()).isConnectionValid();
+ }
+
+ private JdbcConnectionConfig buildConnectionConfig() {
+ return JdbcConnectionConfig.builder()
+ .url("jdbc:postgresql://localhost:5432/test")
+ .maxRetries(1)
+ .batchSize(1024)
+ .build();
+ }
+
+ private static BatchUpdateException batchException(
+ String message, String sqlState, SQLException nextException) {
+ BatchUpdateException exception = new BatchUpdateException(message,
sqlState, new int[0]);
+ exception.setNextException(nextException);
+ return exception;
+ }
+
+ private static class TrackingJdbcBatchExecutor
+ implements JdbcBatchStatementExecutor<SeaTunnelRow> {
+ private final SQLException firstFailure;
+ private boolean failedOnce;
+ private int prepareStatementsCalls;
+ private int executeBatchCalls;
+ private int closeStatementsCalls;
+
+ private TrackingJdbcBatchExecutor(SQLException firstFailure) {
+ this.firstFailure = firstFailure;
+ }
+
+ @Override
+ public void prepareStatements(Connection connection) {
+ prepareStatementsCalls++;
+ }
+
+ @Override
+ public void addToBatch(SeaTunnelRow record) {}
+
+ @Override
+ public void executeBatch() throws SQLException {
+ executeBatchCalls++;
+ if (!failedOnce) {
+ failedOnce = true;
+ throw firstFailure;
+ }
+ }
+
+ @Override
+ public void closeStatements() {
+ closeStatementsCalls++;
+ }
+ }
+}