This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12420-a4c291d8fd6c7b785b90334d4ec2ee68e026dd7a in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit ccd85d2f5a048fcc4ae3b7f34b147acf5bdb2c42 Author: Jast <[email protected]> AuthorDate: Mon Oct 12 06:07:32 2026 +0000 [Fix][Zeta][Connector-V2] Cancel in-flight JDBC source queries (#12419) (#12420) Co-authored-by: jast <[email protected]> --- .../architecture/api-design/source-architecture.md | 11 ++ .../architecture/api-design/source-architecture.md | 3 + .../apache/seatunnel/api/source/SourceReader.java | 24 ++++ .../seatunnel/jdbc/internal/JdbcInputFormat.java | 95 +++++++++++++- .../seatunnel/jdbc/source/JdbcSourceReader.java | 5 + .../jdbc/internal/JdbcInputFormatTest.java | 140 +++++++++++++++++++++ .../connectors/seatunnel/jdbc/JdbcMysqlIT.java | 84 +++++++++++++ .../test/resources/jdbc_mysql_source_cancel.conf | 42 +++++++ .../engine/server/TaskExecutionService.java | 28 +++++ .../seatunnel/engine/server/execution/Task.java | 3 + .../engine/server/task/SeaTunnelTask.java | 31 ++++- .../engine/server/task/flow/FlowLifeCycle.java | 2 + .../server/task/flow/SourceFlowLifeCycle.java | 11 +- 13 files changed, 475 insertions(+), 4 deletions(-) diff --git a/docs/en/architecture/api-design/source-architecture.md b/docs/en/architecture/api-design/source-architecture.md index 7ae73b1ec5..7ce518770a 100644 --- a/docs/en/architecture/api-design/source-architecture.md +++ b/docs/en/architecture/api-design/source-architecture.md @@ -367,6 +367,13 @@ public interface SourceReader<T, SplitT extends SourceSplit> */ void open() throws Exception; + /** + * Request cancellation of an in-flight source operation (Zeta only). + * Called from an engine thread while open/pollNext/close may run concurrently; + * implementations must be idempotent, thread-safe and lock-free. + */ + default void cancel() {} + /** * Poll next batch of records (non-blocking or timeout) */ @@ -415,6 +422,10 @@ public interface SourceReader<T, SplitT extends SourceSplit> - **Progress Tracking**: Track offset/position within each split - **State Management**: Snapshot split progress for recovery - **Split Management**: Handle split assignment, completion, and removal +- **Job Cancellation**: Override `cancel()` when reads can block in an external system (e.g. a + JDBC query). The engine calls it from a separate thread on job cancellation, so implementations + must be idempotent, thread-safe, and must not acquire locks held by the reader methods. Currently + only the Zeta engine invokes this hook. **Implementation Example**: diff --git a/docs/zh/architecture/api-design/source-architecture.md b/docs/zh/architecture/api-design/source-architecture.md index c8c8ff80e2..8fb4a7eb22 100644 --- a/docs/zh/architecture/api-design/source-architecture.md +++ b/docs/zh/architecture/api-design/source-architecture.md @@ -209,6 +209,9 @@ on addSplitsBack(splits): - `addSplits(splits)`:接收枚举器分配的 splits - `snapshotState(checkpointId)`:返回 split checkpoint state(实际接口返回 `List<SplitT>`) - `handleNoMoreSplits()`:收到无更多 split 的信号 +- `cancel()`:请求中止阻塞在外部系统中的读取操作(目前仅 Zeta 引擎调用)。该方法由引擎线程在 + `open`/`pollNext`/`close` 可能并发执行时调用,实现必须幂等、线程安全且不获取读取器持有的锁, + 并将取消请求视为粘性(阻塞操作开始前到达的取消仍需生效) - `CheckpointListener` 回调:由框架触发 checkpoint 完成/中止通知 **关键职责**: diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceReader.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceReader.java index d095e426ef..f70662e3bb 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceReader.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/source/SourceReader.java @@ -36,6 +36,30 @@ public interface SourceReader<T, SplitT extends SourceSplit> /** Open the source reader. */ void open() throws Exception; + /** + * Request cancellation of an in-flight source operation, such as a query blocked in an external + * system. + * + * <p>Contract for implementations: + * + * <ul> + * <li>Called from an engine thread that is not the task thread, while {@link #open()}, {@link + * #pollNext(Collector)} or {@link #close()} may be running concurrently. + * <li>May be invoked multiple times, before {@link #open()}, between splits, or after {@link + * #close()}; implementations must be idempotent and thread-safe. + * <li>Must not acquire locks that the reader methods hold (e.g. the checkpoint lock), or the + * cancellation would deadlock behind the very operation it is meant to abort. + * <li>Must treat the request as sticky: a cancellation that arrives before the blocking + * operation starts must still take effect. + * </ul> + * + * <p>The default implementation keeps existing source connectors source-compatible. Sources + * that can block in an external client should override this method to abort the external + * operation. Currently only the Zeta engine calls this hook; the Flink and Spark translation + * layers do not. + */ + default void cancel() {} + /** * Called to close the reader, in case it holds on to any resources, like threads or network * connections. diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormat.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormat.java index b837827e5d..992a5c29dc 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormat.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormat.java @@ -62,8 +62,14 @@ public class JdbcInputFormat implements Serializable { private transient String splitTableId; private transient TableSchema splitTableSchema; - private transient PreparedStatement statement; + private transient volatile PreparedStatement statement; private transient ResultSet resultSet; + // Runtime connection state written by the task thread and read by the engine cancel thread; + // cancel() may close it while the task thread is still using it. + private transient volatile Connection activeConnection; + // Sticky cancellation flag set by the engine cancel thread and honoured by cancel() and open(), + // so a cancel that arrives before the statement is published is not silently lost. + private transient volatile boolean cancelled; private volatile boolean hasNext; public JdbcInputFormat(JdbcSourceConfig config, Map<TablePath, CatalogTable> tables) { @@ -91,6 +97,81 @@ public class JdbcInputFormat implements Serializable { public void openInputFormat() {} + /** + * Cancels the JDBC operation currently owned by this input format. + * + * <p>This method is intentionally independent from {@link #close()}: the engine may call it + * while the source task is blocked inside {@code executeQuery()} or {@code ResultSet.next()}. + * Closing the statement and connection is the fallback for drivers that do not implement {@link + * PreparedStatement#cancel()} correctly. + * + * <p>The cancellation is sticky: it is recorded in {@code cancelled} and re-checked in {@link + * #open(JdbcSourceSplit)}, so a cancel that arrives before the statement is published — or + * between splits — is not silently lost. Safe to call multiple times from the engine cancel + * thread; only touches volatile fields and never acquires the checkpoint lock. + */ + public void cancel() { + if (cancelled) { + return; + } + cancelled = true; + abortInFlight(); + } + + /** + * Best-effort abort of the in-flight statement and its connection. Idempotent and safe to run + * concurrently from the task thread (via {@link #ensureNotCancelled()}) and the cancel thread + * (via {@link #cancel()}), because JDBC drivers must tolerate repeated {@code cancel()} and + * {@code close()} calls. + */ + private void abortInFlight() { + PreparedStatement currentStatement = statement; + Connection currentConnection = activeConnection; + if (currentStatement != null) { + if (currentConnection == null) { + try { + currentConnection = currentStatement.getConnection(); + } catch (SQLException e) { + LOG.warn("Get JDBC connection for cancelled statement failed", e); + } + } + try { + currentStatement.cancel(); + } catch (SQLException e) { + LOG.warn("Cancel JDBC statement failed", e); + } + try { + currentStatement.close(); + } catch (SQLException e) { + LOG.warn("Close cancelled JDBC statement failed", e); + } + } + if (currentConnection != null) { + try { + currentConnection.close(); + } catch (SQLException e) { + LOG.warn("Close cancelled JDBC connection failed", e); + } + } + } + + /** + * Honours a sticky cancellation before more work is started. Checked at the start of {@link + * #open(JdbcSourceSplit)} (a cancelled reader must not connect for the next split) and again + * after the statement is published (the usual publish-then-check ordering, so a cancel racing + * with statement creation is always observed by at least one side). + */ + private void ensureNotCancelled() throws IOException { + if (!cancelled) { + return; + } + abortInFlight(); + statement = null; + activeConnection = null; + throw new IOException( + "JDBC source reader has been cancelled, stop opening split " + splitTableId); + } + public void closeInputFormat() throws IOException { try { close(); @@ -109,11 +190,18 @@ public class JdbcInputFormat implements Serializable { * @throws IOException if there's an error during the execution of the query */ public void open(JdbcSourceSplit inputSplit) throws IOException { + ensureNotCancelled(); try { splitTableSchema = tables.get(inputSplit.getTablePath()).getTableSchema(); splitTableId = inputSplit.getTablePath().toString(); statement = chunkSplitter.generateSplitStatement(inputSplit, splitTableSchema); + try { + activeConnection = statement.getConnection(); + } catch (SQLException e) { + LOG.debug("JDBC statement did not expose its connection before execution", e); + } + ensureNotCancelled(); resultSet = statement.executeQuery(); hasNext = resultSet.next(); } catch (SQLException se) { @@ -152,6 +240,9 @@ public class JdbcInputFormat implements Serializable { */ public void close() throws IOException { Connection connection = getStatementConnection(); + if (connection == null) { + connection = activeConnection; + } if (resultSet != null) { try { resultSet.close(); @@ -174,6 +265,7 @@ public class JdbcInputFormat implements Serializable { hasNext = false; splitTableSchema = null; splitTableId = null; + activeConnection = null; finishReadTransaction(connection); } @@ -183,6 +275,7 @@ public class JdbcInputFormat implements Serializable { } try { Connection connection = statement.getConnection(); + activeConnection = connection; if (connection == null) { LOG.warn( "The JDBC source statement returned no connection. " diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/source/JdbcSourceReader.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/source/JdbcSourceReader.java index 40bbe28d97..2ad316d446 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/source/JdbcSourceReader.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/source/JdbcSourceReader.java @@ -57,6 +57,11 @@ public class JdbcSourceReader implements SourceReader<SeaTunnelRow, JdbcSourceSp inputFormat.openInputFormat(); } + @Override + public void cancel() { + inputFormat.cancel(); + } + @Override public void close() throws IOException { inputFormat.closeInputFormat(); diff --git a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormatTest.java b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormatTest.java index 13f5fb72f5..dd692ae9b0 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormatTest.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/JdbcInputFormatTest.java @@ -36,8 +36,18 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -212,6 +222,136 @@ class JdbcInputFormatTest { verify(context.chunkSplitter).close(); } + @Test + void shouldCancelActiveStatementAndConnection() throws Exception { + TestContext context = createContext(true); + context.openEmptySplit(); + + context.inputFormat.cancel(); + context.inputFormat.cancel(); + + verify(context.statement, times(1)).cancel(); + verify(context.statement, times(1)).close(); + verify(context.connection, times(1)).close(); + } + + @Test + void shouldCancelStatementWhileExecuteQueryIsInFlight() throws Exception { + TestContext context = createContext(true); + CountDownLatch executeStarted = new CountDownLatch(1); + CountDownLatch cancelObserved = new CountDownLatch(1); + when(context.chunkSplitter.generateSplitStatement(SPLIT, TABLE_SCHEMA)) + .thenReturn(context.statement); + when(context.statement.getConnection()).thenReturn(context.connection); + when(context.resultSet.next()).thenReturn(false); + doAnswer( + invocation -> { + executeStarted.countDown(); + assertTrue(cancelObserved.await(5, TimeUnit.SECONDS)); + return context.resultSet; + }) + .when(context.statement) + .executeQuery(); + doAnswer( + invocation -> { + cancelObserved.countDown(); + return null; + }) + .when(context.statement) + .cancel(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future<?> openFuture = + executor.submit( + (Callable<Void>) + () -> { + context.inputFormat.open(SPLIT); + return null; + }); + assertTrue(executeStarted.await(5, TimeUnit.SECONDS)); + context.inputFormat.cancel(); + assertTrue(cancelObserved.await(5, TimeUnit.SECONDS)); + openFuture.get(5, TimeUnit.SECONDS); + verify(context.statement).cancel(); + } finally { + executor.shutdownNow(); + } + } + + /** + * Regression test for the lost-cancel window: a cancel recorded before {@code open()} must be + * honoured, so the reader neither connects for the next split nor starts a new query. + */ + @Test + void shouldNotOpenNextSplitAfterCancel() throws Exception { + TestContext context = createContext(true); + + context.inputFormat.cancel(); + + assertThrows(IOException.class, () -> context.inputFormat.open(SPLIT)); + verify(context.chunkSplitter, never()).generateSplitStatement(SPLIT, TABLE_SCHEMA); + verify(context.statement, never()).executeQuery(); + } + + /** + * Regression test for the publish-then-check ordering: a cancel that lands after the statement + * is published but before the query starts must abort the statement instead of being lost. + */ + @Test + void shouldAbortOpenWhenCancelRacesWithStatementCreation() throws Exception { + TestContext context = createContext(true); + AtomicBoolean cancelInjected = new AtomicBoolean(); + when(context.chunkSplitter.generateSplitStatement(SPLIT, TABLE_SCHEMA)) + .thenReturn(context.statement); + doAnswer( + invocation -> { + // Simulates the engine cancel thread winning the race after the + // statement is published but before executeQuery() starts. The guard + // keeps the nested abortInFlight() call from re-entering the cancel. + if (cancelInjected.compareAndSet(false, true)) { + context.inputFormat.cancel(); + } + return context.connection; + }) + .when(context.statement) + .getConnection(); + when(context.resultSet.next()).thenReturn(false); + + assertThrows(IOException.class, () -> context.inputFormat.open(SPLIT)); + + verify(context.statement, never()).executeQuery(); + verify(context.statement, atLeastOnce()).cancel(); + verify(context.statement, atLeastOnce()).close(); + verify(context.connection, atLeastOnce()).close(); + } + + @Test + void shouldStillCloseConnectionWhenStatementCancelFails() throws Exception { + TestContext context = createContext(true); + context.openEmptySplit(); + doThrow(new SQLException("cancel rejected")).when(context.statement).cancel(); + + context.inputFormat.cancel(); + + verify(context.statement).close(); + verify(context.connection).close(); + } + + @Test + void shouldAllowCloseAfterCancel() throws Exception { + TestContext context = createContext(true); + context.openEmptySplit(); + when(context.connection.isClosed()).thenReturn(false); + when(context.connection.getAutoCommit()).thenReturn(false); + + context.inputFormat.cancel(); + context.inputFormat.close(); + + verify(context.connection, times(1)).rollback(); + verify(context.connection, times(1)).setAutoCommit(true); + } + @Test void shouldIgnoreClosedConnection() throws SQLException { Connection connection = mock(Connection.class); diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlIT.java index 60272e2135..1a5263067a 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlIT.java @@ -52,11 +52,14 @@ import org.apache.seatunnel.connectors.seatunnel.jdbc.source.JdbcSourceFactory; import org.apache.seatunnel.connectors.seatunnel.jdbc.source.JdbcSourceSplit; import org.apache.seatunnel.connectors.seatunnel.jdbc.source.JdbcSourceSplitEnumerator; import org.apache.seatunnel.connectors.seatunnel.jdbc.state.JdbcSourceState; +import org.apache.seatunnel.e2e.common.container.EngineType; import org.apache.seatunnel.e2e.common.container.TestContainer; +import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestTemplate; import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; @@ -86,6 +89,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.given; public class JdbcMysqlIT extends AbstractJdbcIT { @@ -113,6 +122,8 @@ public class JdbcMysqlIT extends AbstractJdbcIT { "/jdbc_mysql_source_and_sink_parallel_upper_lower.conf", "/jdbc_mysql_source_and_sink.sql", "/jdbc_mysql_source_and_sink_parallel.sql"); + private static final String CANCEL_CONFIG_FILE = "/jdbc_mysql_source_cancel.conf"; + private static final String CANCEL_QUERY_MARKER = "seatunnel-jdbc-cancel-e2e"; private static final String CREATE_SQL = "CREATE TABLE IF NOT EXISTS %s\n" + "(\n" @@ -472,6 +483,79 @@ public class JdbcMysqlIT extends AbstractJdbcIT { testTinyInt1AsBooleanOrTINYINT(false, BasicType.BYTE_TYPE); } + @TestTemplate + @DisabledOnContainer( + value = {}, + type = {EngineType.SPARK, EngineType.FLINK}, + disabledReason = + "TestContainer.executeJob/cancelJob/getJobStatus are only implemented by the " + + "Zeta engine, and the tested cancel hook is Zeta only") + public void testCancelJdbcSourceQuery(TestContainer container) throws Exception { + // The Zeta client requires --set-job-id to be numeric, and the job status/cancel REST + // calls address the job by the same id. + String jobId = String.valueOf(System.nanoTime()); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future<Container.ExecResult> jobFuture = + executor.submit(() -> container.executeJob(CANCEL_CONFIG_FILE, jobId)); + + try { + given().ignoreExceptions() + .await() + .atMost(2, TimeUnit.MINUTES) + .untilAsserted( + () -> + Assertions.assertEquals( + "RUNNING", container.getJobStatus(jobId))); + given().await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> Assertions.assertTrue(hasRunningCancelQuery())); + + Container.ExecResult cancelResult = container.cancelJob(jobId); + Assertions.assertEquals(0, cancelResult.getExitCode(), cancelResult.getStderr()); + + given().await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> Assertions.assertFalse(hasRunningCancelQuery())); + + given().ignoreExceptions() + .await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted( + () -> + Assertions.assertEquals( + "CANCELED", container.getJobStatus(jobId))); + jobFuture.get(30, TimeUnit.SECONDS); + } finally { + if (!jobFuture.isDone()) { + container.cancelJob(jobId); + } + executor.shutdownNow(); + } + } + + /** + * Checks the MySQL process list for the in-flight source query, identified by the marker + * comment in {@code jdbc_mysql_source_cancel.conf}. Excludes this control connection itself so + * the helper cannot match its own {@code PROCESSLIST} statement. Connection errors are + * propagated so they fail with their message instead of a blind timeout. + */ + private boolean hasRunningCancelQuery() throws SQLException { + String processListQuery = + "SELECT INFO FROM information_schema.PROCESSLIST " + + "WHERE ID <> CONNECTION_ID() AND INFO LIKE '%" + + CANCEL_QUERY_MARKER + + "%'"; + try (Connection controlConnection = + DriverManager.getConnection( + jdbcCase.getJdbcUrl().replace(HOST, dbServer.getHost()), + jdbcCase.getUserName(), + jdbcCase.getPassword()); + Statement statement = controlConnection.createStatement(); + ResultSet resultSet = statement.executeQuery(processListQuery)) { + return resultSet.next(); + } + } + private void testTinyInt1AsBooleanOrTINYINT(boolean intTypeNarrowing, BasicType<?> exceptType) throws SQLException { try (MySqlCatalog catalogWithIntTypeNarrowing = diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_cancel.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_cancel.conf new file mode 100644 index 0000000000..e7dff29197 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_cancel.conf @@ -0,0 +1,42 @@ +# +# 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + jdbc { + url = "jdbc:mysql://mysql-e2e:3306/seatunnel" + driver = "com.mysql.cj.jdbc.Driver" + username = "root" + password = "Abc!@#135_seatunnel" + table_path = "seatunnel.source" + # Single-split read: skips the split metadata queries (min/max) that would otherwise also run + # the sleep() predicate and stall split enumeration for minutes. + enable_concurrent_read = false + where_condition = "where sleep(60) = 0 /* seatunnel-jdbc-cancel-e2e */" + properties { + useSSL = false + } + } +} + +sink { + Console {} +} diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java index d72723cddc..94b52b55e8 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java @@ -94,6 +94,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -1572,6 +1573,33 @@ public class TaskExecutionService implements DynamicMetricsProvider { * Cancels tasks and background work owned by this tracker, regardless of active generation. */ private void cancelAllTask() { + try { + ExecutorService tracingExecutorService = MDCTracer.tracing(executorService); + taskGroup + .getTasks() + .forEach( + task -> + tracingExecutorService.submit( + () -> { + try { + task.cancel(); + } catch (Throwable t) { + logger.warning( + String.format( + "Cancel task %s external resources failed: %s", + task.getTaskID(), + ExceptionUtils.getMessage( + t))); + } + })); + } catch (RejectedExecutionException e) { + // The executor may already be shut down; never let this block the interrupt-based + // cancellation below. + logger.warning( + "Submitting task external resource cancellation failed, the executor may " + + "have been shut down: " + + ExceptionUtils.getMessage(e)); + } try { blockingFutures.forEach(f -> f.cancel(true)); currRunningTaskFuture.values().forEach(f -> f.cancel(true)); diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/Task.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/Task.java index 5a771c66e2..5d413b6b9a 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/Task.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/Task.java @@ -45,6 +45,9 @@ public interface Task return false; } + /** Request cancellation of external blocking resources owned by this task. */ + default void cancel() {} + default void close() throws IOException {} default void setTaskExecutionContext(TaskExecutionContext taskExecutionContext) {} diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SeaTunnelTask.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SeaTunnelTask.java index 6c136d8e66..8fdef7d0e2 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SeaTunnelTask.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/SeaTunnelTask.java @@ -79,6 +79,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -121,7 +122,13 @@ public abstract class SeaTunnelTask extends AbstractTask { protected FlowLifeCycle startFlowLifeCycle; - protected List<FlowLifeCycle> allCycles; + // Volatile and copy-on-write: cancel() iterates this list from the engine cancel thread while + // init() may still be filling it on the task thread. + protected volatile List<FlowLifeCycle> allCycles; + + // Set by cancel() when it ran before the life cycles were built; init() replays the cancel in + // that case so no flow keeps blocking in an external system. + private volatile boolean cancelRequested; protected List<OneInputFlowLifeCycle<Record<?>>> outputs; @@ -182,11 +189,16 @@ public abstract class SeaTunnelTask extends AbstractTask { observabilityEnabled = resolveObservabilityEnabled(); this.currState = SeaTunnelTaskState.INIT; flowFutures = new ArrayList<>(); - allCycles = new ArrayList<>(); + allCycles = new CopyOnWriteArrayList<>(); startFlowLifeCycle = convertFlowToActionLifeCycle(executionFlow); for (FlowLifeCycle cycle : allCycles) { cycle.init(); } + // A cancel that raced with this init() may have missed cycles that were still being built; + // replay it so no reader keeps blocking in an external system after cancellation. + if (cancelRequested) { + cancel(); + } CompletableFuture.allOf(flowFutures.toArray(new CompletableFuture[0])) .whenComplete((s, e) -> closeCalled = true); } @@ -403,6 +415,21 @@ public abstract class SeaTunnelTask extends AbstractTask { protected abstract void collect() throws Exception; + /** + * Cancels external blocking resources owned by every flow life cycle of this task. + * + * <p>Called from the engine cancel thread, potentially while {@link #init()} is still building + * {@code allCycles} on the task thread, so the request is recorded in {@code cancelRequested} + * and replayed at the end of {@link #init()}. Must stay cheap, idempotent and lock-free. + */ + @Override + public void cancel() { + cancelRequested = true; + if (allCycles != null) { + allCycles.forEach(FlowLifeCycle::cancel); + } + } + @Override public Set<URL> getJarsUrl() { return getFlowInfo((action, set) -> set.addAll(action.getJarUrls())); diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/FlowLifeCycle.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/FlowLifeCycle.java index 59ba4db3b1..230c232ff7 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/FlowLifeCycle.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/FlowLifeCycle.java @@ -25,6 +25,8 @@ public interface FlowLifeCycle { default void open() throws Exception {} + default void cancel() {} + default void close() throws IOException {} default void hook() throws IOException {} diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/SourceFlowLifeCycle.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/SourceFlowLifeCycle.java index f8fdc17567..54c1e9af68 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/SourceFlowLifeCycle.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/task/flow/SourceFlowLifeCycle.java @@ -102,7 +102,9 @@ public class SourceFlowLifeCycle<T, SplitT extends SourceSplit> extends ActionFl private Address enumeratorTaskAddress; - private SourceReader<T, SplitT> reader; + // Volatile: cancel() reads it from the engine cancel thread while the task thread may still be + // creating the reader in init(). + private volatile SourceReader<T, SplitT> reader; private transient Serializer<SplitT> splitSerializer; @@ -185,6 +187,13 @@ public class SourceFlowLifeCycle<T, SplitT extends SourceSplit> extends ActionFl register(); } + @Override + public void cancel() { + if (reader != null) { + reader.cancel(); + } + } + /** * Timer callback invoked by the {@code timerFlushWorker} thread pool. *
