This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 0278a6a747 [Fix][E2E] Fix task recovery hangs and PostgreSQL CDC LSN
handling (#12454)
0278a6a747 is described below
commit 0278a6a74746856488dd27713736f008e3115c11
Author: SEZ <[email protected]>
AuthorDate: Fri Oct 9 09:26:17 2026 +0000
[Fix][E2E] Fix task recovery hangs and PostgreSQL CDC LSN handling (#12454)
---
docs/en/connectors/source/PostgreSQL-CDC.md | 7 +
docs/en/engines/zeta/user-command.md | 5 +
docs/zh/connectors/source/PostgreSQL-CDC.md | 6 +
docs/zh/engines/zeta/user-command.md | 3 +
.../postgresql/PostgresOffsetContext.java | 54 +-
.../PostgresStreamingChangeEventSource.java | 552 +++++++++++++++++++++
.../postgresql/connection/WalPositionLocator.java | 230 +++++++++
.../reader/PostgresSourceFetchTaskContext.java | 8 +-
.../cdc/postgres/source/offset/LsnOffsetTest.java | 114 +++++
.../seatunnel/cdc/postgres/PostgresCDCIT.java | 41 +-
.../seatunnel/e2e/connector/paypal/PayPalIT.java | 4 +-
.../e2e/CheckpointCoordinatorFailoverIT.java | 2 +
.../e2e/transform/TestFilterRowKindIT.java | 1 +
...filter_row_kind_exclude_insert_multi_table.conf | 5 +-
.../engine/server/TaskExecutionService.java | 14 +-
.../engine/server/dag/physical/PhysicalVertex.java | 8 +
.../engine/server/TaskExecutionServiceTest.java | 140 ++++++
.../dag/physical/StateTransitionCleanupTest.java | 60 +++
18 files changed, 1224 insertions(+), 30 deletions(-)
diff --git a/docs/en/connectors/source/PostgreSQL-CDC.md
b/docs/en/connectors/source/PostgreSQL-CDC.md
index b5b45d83bf..ac6557a509 100644
--- a/docs/en/connectors/source/PostgreSQL-CDC.md
+++ b/docs/en/connectors/source/PostgreSQL-CDC.md
@@ -386,6 +386,13 @@ uses its `confirmed_flush_lsn` as the startup offset.
Unused replication slots hold WAL segments on disk, which can cause unbounded
WAL growth. When a
CDC job is permanently decommissioned, drop the unused replication slot
manually on PostgreSQL.
+### How are transaction boundaries handled during savepoint recovery?
+
+Recovery distinguishes a saved transaction commit from a processed row,
because a commit and the
+next transaction's first row can share the same LSN. Rows committed while the
job is stopped remain
+eligible for replay. Existing savepoints without the saved operation type
remain readable; recovery
+may replay a row at an ambiguous boundary to avoid losing it.
+
### Why does PostgreSQL CDC fall behind?
Replication lag can occur when the logical decoding plugin is slow or when the
WAL sender is under load. Monitor `pg_replication_slots` for
`confirmed_flush_lsn` drift. Ensure the CDC job consumes events continuously
and that network latency between SeaTunnel and PostgreSQL is low.
diff --git a/docs/en/engines/zeta/user-command.md
b/docs/en/engines/zeta/user-command.md
index 8ecd35b706..3ddb44734f 100644
--- a/docs/en/engines/zeta/user-command.md
+++ b/docs/en/engines/zeta/user-command.md
@@ -50,6 +50,11 @@ Usage: seatunnel.sh [options]
sh bin/seatunnel.sh --config $SEATUNNEL_HOME/config/v2.batch.config.template
```
+Without `--async`, the client waits for the final job result. If a job keeps
failing during
+task startup or automatic recovery, for example because a Paimon source lacks
`SELECT` permission, the job
+returns `FAILED` after exhausting the configured retries and the client exits
with a nonzero
+status.
+
The **--async** parameter allows the job to run in the background. When the
job is submitted, the client will exit.
```shell
diff --git a/docs/zh/connectors/source/PostgreSQL-CDC.md
b/docs/zh/connectors/source/PostgreSQL-CDC.md
index 732d6657f8..cc3459a631 100644
--- a/docs/zh/connectors/source/PostgreSQL-CDC.md
+++ b/docs/zh/connectors/source/PostgreSQL-CDC.md
@@ -376,6 +376,12 @@ SeaTunnel 在任务启动时会创建或复用 `slot.name` 指定的复制槽。
作为启动偏移量。未使用的复制槽会持续占用磁盘上的 WAL 段,导致 WAL 持续增长。当 CDC
任务永久下线时,应在 PostgreSQL 侧手动删除不再使用的复制槽。
+### 从保存点恢复时如何处理事务边界?
+
+恢复时会区分保存的事务提交记录与已处理的数据行,因为事务提交和下一事务的第一条数据行
+可能具有相同的 LSN。任务停止期间提交的数据仍可被重放。不包含操作类型的旧保存点仍可读取;
+为避免丢失数据,恢复时可能重放无法明确区分的边界记录。
+
### PostgreSQL CDC 为什么会滞后?
滞后可能由逻辑解码插件处理慢或 WAL sender 负载过高引起。可通过监控 `pg_replication_slots` 中的
`confirmed_flush_lsn` 漂移情况来排查。确保 CDC 任务持续消费事件,并保持 SeaTunnel 与 PostgreSQL
之间的网络低延迟。
diff --git a/docs/zh/engines/zeta/user-command.md
b/docs/zh/engines/zeta/user-command.md
index d5d9e917c0..87f66b5493 100644
--- a/docs/zh/engines/zeta/user-command.md
+++ b/docs/zh/engines/zeta/user-command.md
@@ -66,6 +66,9 @@ Usage: seatunnel.sh [options]
bin/seatunnel.sh --config $SEATUNNEL_HOME/config/v2.batch.config.template
```
+未使用 `--async` 时,客户端会等待作业的最终结果。如果作业在任务启动或自动恢复期间持续失败,例如
+Paimon source 缺少 `SELECT` 权限,达到配置的重试上限后,作业会返回 `FAILED`,客户端以非零状态退出。
+
**--async** 参数可以让作业在后台运行,当作业提交后,客户端会退出。
```shell
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresOffsetContext.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresOffsetContext.java
index bb7d1d4487..599180154b 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresOffsetContext.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresOffsetContext.java
@@ -27,6 +27,7 @@ import org.slf4j.LoggerFactory;
import io.debezium.connector.SnapshotRecord;
import io.debezium.connector.postgresql.connection.Lsn;
import io.debezium.connector.postgresql.connection.PostgresConnection;
+import
io.debezium.connector.postgresql.connection.ReplicationMessage.Operation;
import io.debezium.connector.postgresql.spi.OffsetState;
import
io.debezium.pipeline.source.snapshot.incremental.IncrementalSnapshotContext;
import
io.debezium.pipeline.source.snapshot.incremental.SignalBasedIncrementalSnapshotContext;
@@ -54,12 +55,14 @@ public class PostgresOffsetContext implements OffsetContext
{
public static final String LAST_COMPLETELY_PROCESSED_LSN_KEY = "lsn_proc";
public static final String LAST_COMMIT_LSN_KEY = "lsn_commit";
+ public static final String LAST_PROCESSED_MESSAGE_TYPE_KEY = "messageType";
private final Schema sourceInfoSchema;
private final SourceInfo sourceInfo;
private boolean lastSnapshotRecord;
private Lsn lastCompletelyProcessedLsn;
private Lsn lastCommitLsn;
+ private Operation lastProcessedMessageType;
private Lsn streamingStoppingLsn = null;
private final TransactionContext transactionContext;
private final IncrementalSnapshotContext<TableId>
incrementalSnapshotContext;
@@ -120,6 +123,9 @@ public class PostgresOffsetContext implements OffsetContext
{
if (lastCommitLsn != null) {
result.put(LAST_COMMIT_LSN_KEY, lastCommitLsn.asLong());
}
+ if (lastProcessedMessageType != null) {
+ result.put(LAST_PROCESSED_MESSAGE_TYPE_KEY,
lastProcessedMessageType.name());
+ }
return sourceInfo.isSnapshot()
? result
:
incrementalSnapshotContext.store(transactionContext.store(result));
@@ -163,7 +169,20 @@ public class PostgresOffsetContext implements
OffsetContext {
Long txId,
Long xmin,
TableId tableId) {
+ updateWalPosition(lsn, lastCompletelyProcessedLsn, commitTime, txId,
xmin, tableId, null);
+ }
+
+ /** Tracks the operation as well as its LSN so recovery can distinguish a
commit boundary. */
+ public void updateWalPosition(
+ Lsn lsn,
+ Lsn lastCompletelyProcessedLsn,
+ Instant commitTime,
+ Long txId,
+ Long xmin,
+ TableId tableId,
+ Operation messageType) {
this.lastCompletelyProcessedLsn = lastCompletelyProcessedLsn;
+ this.lastProcessedMessageType = messageType;
sourceInfo.update(lsn, commitTime, txId, xmin, tableId);
}
@@ -176,6 +195,9 @@ public class PostgresOffsetContext implements OffsetContext
{
public void updateCommitPosition(Lsn lsn, Lsn lastCompletelyProcessedLsn) {
this.lastCompletelyProcessedLsn = lastCompletelyProcessedLsn;
this.lastCommitLsn = lsn;
+ // Without transaction metadata, COMMIT bypasses updateWalPosition.
Its operation must
+ // still match lsn_proc in the heartbeat offset that a checkpoint can
persist.
+ this.lastProcessedMessageType = Operation.COMMIT;
sourceInfo.updateLastCommit(lsn);
}
@@ -199,6 +221,10 @@ public class PostgresOffsetContext implements
OffsetContext {
return lastCommitLsn;
}
+ Operation lastProcessedMessageType() {
+ return lastProcessedMessageType;
+ }
+
/**
* Returns the LSN that the streaming phase should stream events up to or
null if a stopping
* point is not set. If set during the streaming phase, any event with an
LSN less than the
@@ -264,17 +290,23 @@ public class PostgresOffsetContext implements
OffsetContext {
((Map<String, Object>) offset)
.getOrDefault(
SourceInfo.LAST_SNAPSHOT_RECORD_KEY, Boolean.FALSE);
- return new PostgresOffsetContext(
- connectorConfig,
- lsn,
- lastCompletelyProcessedLsn,
- lastCommitLsn,
- txId,
- useconds,
- snapshot,
- lastSnapshotRecord,
- TransactionContext.load(offset),
- SignalBasedIncrementalSnapshotContext.load(offset, false));
+ PostgresOffsetContext context =
+ new PostgresOffsetContext(
+ connectorConfig,
+ lsn,
+ lastCompletelyProcessedLsn,
+ lastCommitLsn,
+ txId,
+ useconds,
+ snapshot,
+ lastSnapshotRecord,
+ TransactionContext.load(offset),
+ SignalBasedIncrementalSnapshotContext.load(offset,
false));
+ Object messageType = offset.get(LAST_PROCESSED_MESSAGE_TYPE_KEY);
+ if (messageType != null) {
+ context.lastProcessedMessageType =
Operation.valueOf(messageType.toString());
+ }
+ return context;
}
}
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
new file mode 100644
index 0000000000..e60e8cc195
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/PostgresStreamingChangeEventSource.java
@@ -0,0 +1,552 @@
+/*
+ * 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 io.debezium.connector.postgresql;
+
+import org.apache.kafka.connect.errors.ConnectException;
+
+import org.postgresql.core.BaseConnection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.debezium.DebeziumException;
+import io.debezium.connector.postgresql.connection.LogicalDecodingMessage;
+import io.debezium.connector.postgresql.connection.Lsn;
+import io.debezium.connector.postgresql.connection.PostgresConnection;
+import io.debezium.connector.postgresql.connection.ReplicationConnection;
+import
io.debezium.connector.postgresql.connection.ReplicationMessage.Operation;
+import io.debezium.connector.postgresql.connection.ReplicationStream;
+import io.debezium.connector.postgresql.connection.WalPositionLocator;
+import io.debezium.connector.postgresql.spi.Snapshotter;
+import io.debezium.heartbeat.Heartbeat;
+import io.debezium.pipeline.ErrorHandler;
+import io.debezium.pipeline.source.spi.StreamingChangeEventSource;
+import io.debezium.relational.TableId;
+import io.debezium.util.Clock;
+import io.debezium.util.DelayStrategy;
+import io.debezium.util.ElapsedTimeStrategy;
+import io.debezium.util.Threads;
+
+import java.sql.SQLException;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Debezium 1.9.8 streaming implementation with the DBZ-5915/DBZ-6204 resume
operation passed
+ * through the offset context. Other streaming and offset-flush behavior is
unchanged.
+ *
+ * <p>Copyright Debezium Authors. Adapted from Debezium 1.9.8.Final.
+ *
+ * @author Horia Chiorean ([email protected]), Jiri Pechanec
+ */
+public class PostgresStreamingChangeEventSource
+ implements StreamingChangeEventSource<PostgresPartition,
PostgresOffsetContext> {
+
+ private static final String KEEP_ALIVE_THREAD_NAME = "keep-alive";
+
+ /**
+ * Number of received events without sending anything to Kafka which will
trigger a "WAL backlog
+ * growing" warning.
+ */
+ private static final int GROWING_WAL_WARNING_LOG_INTERVAL = 10_000;
+
+ private static final Logger LOGGER =
+ LoggerFactory.getLogger(PostgresStreamingChangeEventSource.class);
+
+ // PGOUTPUT decoder sends the messages with larger time gaps than other
decoders
+ // We thus try to read the message multiple times before we make poll pause
+ private static final int THROTTLE_NO_MESSAGE_BEFORE_PAUSE = 5;
+
+ private final PostgresConnection connection;
+ private final PostgresEventDispatcher<TableId> dispatcher;
+ private final ErrorHandler errorHandler;
+ private final Clock clock;
+ private final PostgresSchema schema;
+ private final PostgresConnectorConfig connectorConfig;
+ private final PostgresTaskContext taskContext;
+ private final ReplicationConnection replicationConnection;
+ private final AtomicReference<ReplicationStream> replicationStream = new
AtomicReference<>();
+ private final Snapshotter snapshotter;
+ private final DelayStrategy pauseNoMessage;
+ private final ElapsedTimeStrategy connectionProbeTimer;
+
+ // Offset committing is an asynchronous operation.
+ // When connector is restarted we cannot be sure about timing of recovery,
offset committing
+ // etc.
+ // as this is driven by Kafka Connect. This might be a root cause of
DBZ-5163.
+ // This flag will ensure that LSN is flushed only if we are really in
message processing mode.
+ private volatile boolean lsnFlushingAllowed = false;
+
+ /**
+ * The minimum of (number of event received since the last event sent to
Kafka, number of event
+ * received since last WAL growing warning issued).
+ */
+ private long numberOfEventsSinceLastEventSentOrWalGrowingWarning = 0;
+
+ private Lsn lastCompletelyProcessedLsn;
+
+ public PostgresStreamingChangeEventSource(
+ PostgresConnectorConfig connectorConfig,
+ Snapshotter snapshotter,
+ PostgresConnection connection,
+ PostgresEventDispatcher<TableId> dispatcher,
+ ErrorHandler errorHandler,
+ Clock clock,
+ PostgresSchema schema,
+ PostgresTaskContext taskContext,
+ ReplicationConnection replicationConnection) {
+ this.connectorConfig = connectorConfig;
+ this.connection = connection;
+ this.dispatcher = dispatcher;
+ this.errorHandler = errorHandler;
+ this.clock = clock;
+ this.schema = schema;
+ pauseNoMessage =
+
DelayStrategy.constant(taskContext.getConfig().getPollInterval().toMillis());
+ this.taskContext = taskContext;
+ this.snapshotter = snapshotter;
+ this.replicationConnection = replicationConnection;
+ this.connectionProbeTimer =
+ ElapsedTimeStrategy.constant(
+ Clock.system(),
connectorConfig.statusUpdateInterval());
+ }
+
+ @Override
+ public void init() {
+ // refresh the schema so we have a latest view of the DB tables
+ try {
+ taskContext.refreshSchema(connection, true);
+ } catch (SQLException e) {
+ throw new DebeziumException("Error while executing initial schema
load", e);
+ }
+ }
+
+ @Override
+ public void execute(
+ ChangeEventSourceContext context,
+ PostgresPartition partition,
+ PostgresOffsetContext offsetContext)
+ throws InterruptedException {
+ if (!snapshotter.shouldStream()) {
+ LOGGER.info("Streaming is not enabled in correct configuration");
+ return;
+ }
+
+ lsnFlushingAllowed = false;
+
+ // replication slot could exist at the time of starting Debezium so we
will stream from the
+ // position in the slot
+ // instead of the last position in the database
+ boolean hasStartLsnStoredInContext = offsetContext != null;
+
+ if (!hasStartLsnStoredInContext) {
+ offsetContext =
+ PostgresOffsetContext.initialContext(connectorConfig,
connection, clock);
+ }
+
+ try {
+ final WalPositionLocator walPosition;
+
+ if (hasStartLsnStoredInContext) {
+ // start streaming from the last recorded position in the
offset
+ final Lsn lsn =
+ offsetContext.lastCompletelyProcessedLsn() != null
+ ? offsetContext.lastCompletelyProcessedLsn()
+ : offsetContext.lsn();
+ LOGGER.info("Retrieved latest position from stored offset
'{}'", lsn);
+ walPosition =
+ new WalPositionLocator(
+ offsetContext.lastCommitLsn(),
+ lsn,
+ offsetContext.lastProcessedMessageType());
+ replicationStream.compareAndSet(
+ null, replicationConnection.startStreaming(lsn,
walPosition));
+ } else {
+ LOGGER.info(
+ "No previous LSN found in Kafka, streaming from the
latest xlogpos or flushed LSN...");
+ walPosition = new WalPositionLocator();
+ replicationStream.compareAndSet(
+ null,
replicationConnection.startStreaming(walPosition));
+ }
+ // for large dbs, the refresh of schema can take too much time
+ // such that the connection times out. We must enable keep
+ // alive to ensure that it doesn't time out
+ ReplicationStream stream = this.replicationStream.get();
+ stream.startKeepAlive(
+ Threads.newSingleThreadExecutor(
+ PostgresConnector.class,
+ connectorConfig.getLogicalName(),
+ KEEP_ALIVE_THREAD_NAME));
+
+ init();
+
+ // If we need to do a pre-snapshot streaming catch up, we should
allow the snapshot
+ // transaction to persist
+ // but normally we want to start streaming without any open
transactions.
+ if (!isInPreSnapshotCatchUpStreaming(offsetContext)) {
+ connection.commit();
+ }
+
+ this.lastCompletelyProcessedLsn =
replicationStream.get().startLsn();
+
+ if (walPosition.searchingEnabled()) {
+ searchWalPosition(context, stream, walPosition);
+ try {
+ if (!isInPreSnapshotCatchUpStreaming(offsetContext)) {
+ connection.commit();
+ }
+ } catch (Exception e) {
+ LOGGER.info("Commit failed while preparing for reconnect",
e);
+ }
+ walPosition.enableFiltering();
+ stream.stopKeepAlive();
+ replicationConnection.reconnect();
+ replicationStream.set(
+ replicationConnection.startStreaming(
+ walPosition.getLastEventStoredLsn(),
walPosition));
+ stream = this.replicationStream.get();
+ stream.startKeepAlive(
+ Threads.newSingleThreadExecutor(
+ PostgresConnector.class,
+ connectorConfig.getLogicalName(),
+ KEEP_ALIVE_THREAD_NAME));
+ }
+ processMessages(context, partition, offsetContext, stream);
+ } catch (Throwable e) {
+ errorHandler.setProducerThrowable(e);
+ } finally {
+ if (replicationConnection != null) {
+ LOGGER.debug("stopping streaming...");
+ // stop the keep alive thread, this also shuts down the
+ // executor pool
+ ReplicationStream stream = replicationStream.get();
+ if (stream != null) {
+ stream.stopKeepAlive();
+ }
+ // TODO author=Horia Chiorean date=08/11/2016
description=Ideally we'd close the
+ // stream, but it's not reliable atm (see javadoc)
+ // replicationStream.close();
+ // close the connection - this should also disconnect the
current stream even if
+ // it's blocking
+ try {
+ if (!isInPreSnapshotCatchUpStreaming(offsetContext)) {
+ connection.commit();
+ }
+ replicationConnection.close();
+ } catch (Exception e) {
+ LOGGER.debug("Exception while closing the connection", e);
+ }
+ replicationStream.set(null);
+ }
+ }
+ }
+
+ private void processMessages(
+ ChangeEventSourceContext context,
+ PostgresPartition partition,
+ PostgresOffsetContext offsetContext,
+ final ReplicationStream stream)
+ throws SQLException, InterruptedException {
+ LOGGER.info("Processing messages");
+ int noMessageIterations = 0;
+ while (context.isRunning()
+ && (offsetContext.getStreamingStoppingLsn() == null
+ || (lastCompletelyProcessedLsn.compareTo(
+
offsetContext.getStreamingStoppingLsn())
+ < 0))) {
+
+ boolean receivedMessage =
+ stream.readPending(
+ message -> {
+ final Lsn lsn = stream.lastReceivedLsn();
+
+ if (message.isLastEventForLsn()) {
+ lastCompletelyProcessedLsn = lsn;
+ }
+
+ // Tx BEGIN/END event
+ if (message.isTransactionalMessage()) {
+ if
(!connectorConfig.shouldProvideTransactionMetadata()) {
+ LOGGER.trace("Received transactional
message {}", message);
+ // Don't skip on BEGIN message as it
would flush LSN for the
+ // whole transaction
+ // too early
+ if (message.getOperation() ==
Operation.COMMIT) {
+ commitMessage(partition,
offsetContext, lsn);
+ }
+ return;
+ }
+
+ offsetContext.updateWalPosition(
+ lsn,
+ lastCompletelyProcessedLsn,
+ message.getCommitTime(),
+ toLong(message.getTransactionId()),
+
taskContext.getSlotXmin(connection),
+ null,
+ message.getOperation());
+ if (message.getOperation() ==
Operation.BEGIN) {
+
dispatcher.dispatchTransactionStartedEvent(
+ partition,
+
toString(message.getTransactionId()),
+ offsetContext);
+ } else if (message.getOperation() ==
Operation.COMMIT) {
+ commitMessage(partition,
offsetContext, lsn);
+
dispatcher.dispatchTransactionCommittedEvent(
+ partition, offsetContext);
+ }
+ maybeWarnAboutGrowingWalBacklog(true);
+ } else if (message.getOperation() ==
Operation.MESSAGE) {
+ offsetContext.updateWalPosition(
+ lsn,
+ lastCompletelyProcessedLsn,
+ message.getCommitTime(),
+ toLong(message.getTransactionId()),
+
taskContext.getSlotXmin(connection),
+ null,
+ message.getOperation());
+
+ // non-transactional message that will not
be followed by a
+ // COMMIT message
+ if (message.isLastEventForLsn()) {
+ commitMessage(partition,
offsetContext, lsn);
+ }
+
+ dispatcher.dispatchLogicalDecodingMessage(
+ partition,
+ offsetContext,
+
clock.currentTimeAsInstant().toEpochMilli(),
+ (LogicalDecodingMessage) message);
+
+ maybeWarnAboutGrowingWalBacklog(true);
+ }
+ // DML event
+ else {
+ TableId tableId = null;
+ if (message.getOperation() !=
Operation.NOOP) {
+ tableId =
PostgresSchema.parse(message.getTable());
+ Objects.requireNonNull(tableId);
+ }
+
+ offsetContext.updateWalPosition(
+ lsn,
+ lastCompletelyProcessedLsn,
+ message.getCommitTime(),
+ toLong(message.getTransactionId()),
+
taskContext.getSlotXmin(connection),
+ tableId,
+ message.getOperation());
+
+ boolean dispatched =
+ message.getOperation() !=
Operation.NOOP
+ &&
dispatcher.dispatchDataChangeEvent(
+ partition,
+ tableId,
+ new
PostgresChangeRecordEmitter(
+ partition,
+
offsetContext,
+ clock,
+
connectorConfig,
+ schema,
+ connection,
+ tableId,
+ message));
+
+
maybeWarnAboutGrowingWalBacklog(dispatched);
+ }
+ });
+
+ probeConnectionIfNeeded();
+
+ if (receivedMessage) {
+ noMessageIterations = 0;
+ lsnFlushingAllowed = true;
+ } else {
+ if (offsetContext.hasCompletelyProcessedPosition()) {
+ dispatcher.dispatchHeartbeatEvent(partition,
offsetContext);
+ }
+ noMessageIterations++;
+ if (noMessageIterations >= THROTTLE_NO_MESSAGE_BEFORE_PAUSE) {
+ noMessageIterations = 0;
+ pauseNoMessage.sleepWhen(true);
+ }
+ }
+ if (!isInPreSnapshotCatchUpStreaming(offsetContext)) {
+ // During catch up streaming, the streaming phase needs to
hold a transaction open
+ // so that
+ // the phase can stream event up to a specific lsn and the
snapshot that occurs
+ // after the catch up
+ // streaming will not lose the current view of data. Since we
need to hold the
+ // transaction open
+ // for the snapshot, this block must not commit during catch
up streaming.
+ connection.commit();
+ }
+ }
+ }
+
+ private void searchWalPosition(
+ ChangeEventSourceContext context,
+ final ReplicationStream stream,
+ final WalPositionLocator walPosition)
+ throws SQLException, InterruptedException {
+ AtomicReference<Lsn> resumeLsn = new AtomicReference<>();
+ int noMessageIterations = 0;
+
+ LOGGER.info("Searching for WAL resume position");
+ while (context.isRunning() && resumeLsn.get() == null) {
+
+ boolean receivedMessage =
+ stream.readPending(
+ message -> {
+ final Lsn lsn = stream.lastReceivedLsn();
+ resumeLsn.set(walPosition.resumeFromLsn(lsn,
message).orElse(null));
+ });
+
+ if (receivedMessage) {
+ noMessageIterations = 0;
+ } else {
+ noMessageIterations++;
+ if (noMessageIterations >= THROTTLE_NO_MESSAGE_BEFORE_PAUSE) {
+ noMessageIterations = 0;
+ pauseNoMessage.sleepWhen(true);
+ }
+ }
+
+ probeConnectionIfNeeded();
+ }
+ LOGGER.info("WAL resume position '{}' discovered", resumeLsn.get());
+ }
+
+ private void probeConnectionIfNeeded() throws SQLException {
+ if (connectionProbeTimer.hasElapsed()) {
+ connection.prepareQuery("SELECT 1");
+ connection.commit();
+ }
+ }
+
+ private void commitMessage(
+ PostgresPartition partition, PostgresOffsetContext offsetContext,
final Lsn lsn)
+ throws SQLException, InterruptedException {
+ lastCompletelyProcessedLsn = lsn;
+ offsetContext.updateCommitPosition(lsn, lastCompletelyProcessedLsn);
+ maybeWarnAboutGrowingWalBacklog(false);
+ dispatcher.dispatchHeartbeatEvent(partition, offsetContext);
+ }
+
+ /**
+ * If we receive change events but all of them get filtered out, we cannot
commit any new offset
+ * with Apache Kafka. This in turn means no LSN is ever acknowledged with
the replication slot,
+ * causing any ever growing WAL backlog.
+ *
+ * <p>This situation typically occurs if there are changes on the database
server, (e.g. in an
+ * excluded database), but none of them is in table.include.list. To
prevent this, heartbeats
+ * can be used, as they will allow us to commit offsets also when not
propagating any "real"
+ * change event.
+ *
+ * <p>The purpose of this method is to detect this situation and log a
warning every {@link
+ * #GROWING_WAL_WARNING_LOG_INTERVAL} filtered events.
+ *
+ * @param dispatched Whether an event was sent to the broker or not
+ */
+ private void maybeWarnAboutGrowingWalBacklog(boolean dispatched) {
+ if (dispatched) {
+ numberOfEventsSinceLastEventSentOrWalGrowingWarning = 0;
+ } else {
+ numberOfEventsSinceLastEventSentOrWalGrowingWarning++;
+ }
+
+ if (numberOfEventsSinceLastEventSentOrWalGrowingWarning >
GROWING_WAL_WARNING_LOG_INTERVAL
+ && !dispatcher.heartbeatsEnabled()) {
+ LOGGER.warn(
+ "Received {} events which were all filtered out, so no
offset could be committed. "
+ + "This prevents the replication slot from
acknowledging the processed WAL offsets, "
+ + "causing a growing backlog of non-removeable WAL
segments on the database server. "
+ + "Consider to either adjust your filter
configuration or enable heartbeat events "
+ + "(via the {} option) to avoid this situation.",
+ numberOfEventsSinceLastEventSentOrWalGrowingWarning,
+ Heartbeat.HEARTBEAT_INTERVAL_PROPERTY_NAME);
+
+ numberOfEventsSinceLastEventSentOrWalGrowingWarning = 0;
+ }
+ }
+
+ @Override
+ public void commitOffset(Map<String, ?> offset) {
+ try {
+ ReplicationStream replicationStream = this.replicationStream.get();
+ final Lsn commitLsn =
+ Lsn.valueOf((Long)
offset.get(PostgresOffsetContext.LAST_COMMIT_LSN_KEY));
+ final Lsn changeLsn =
+ Lsn.valueOf(
+ (Long)
+ offset.get(
+ PostgresOffsetContext
+
.LAST_COMPLETELY_PROCESSED_LSN_KEY));
+ final Lsn lsn = (commitLsn != null) ? commitLsn : changeLsn;
+
+ if (replicationStream != null && lsn != null) {
+ if (!lsnFlushingAllowed) {
+ LOGGER.info(
+ "Received offset commit request on '{}', but
ignoring it. LSN flushing is not allowed yet",
+ lsn);
+ return;
+ }
+
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Flushing LSN to server: {}", lsn);
+ }
+ // tell the server the point up to which we've processed data,
so it can be free to
+ // recycle WAL segments
+ replicationStream.flushLsn(lsn);
+ } else {
+ LOGGER.debug("Streaming has already stopped, ignoring commit
callback...");
+ }
+ } catch (SQLException e) {
+ throw new ConnectException(e);
+ }
+ }
+
+ /**
+ * Returns whether the current streaming phase is running a catch up
streaming phase that runs
+ * before a snapshot. This is useful for transaction management.
+ *
+ * <p>During pre-snapshot catch up streaming, we open the snapshot
transaction early and hold
+ * the transaction open throughout the pre snapshot catch up streaming
phase so that we know
+ * where to stop streaming and can start the snapshot phase at a
consistent location. This is
+ * opposed the regular streaming, where we do not a lingering open
transaction.
+ *
+ * @return true if the current streaming phase is performing catch up
streaming
+ */
+ private boolean isInPreSnapshotCatchUpStreaming(PostgresOffsetContext
offsetContext) {
+ return offsetContext.getStreamingStoppingLsn() != null;
+ }
+
+ private Long toLong(OptionalLong l) {
+ return l.isPresent() ? Long.valueOf(l.getAsLong()) : null;
+ }
+
+ private String toString(OptionalLong l) {
+ return l.isPresent() ? String.valueOf(l.getAsLong()) : null;
+ }
+
+ @FunctionalInterface
+ public static interface PgConnectionSupplier {
+ BaseConnection get() throws SQLException;
+ }
+}
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/connection/WalPositionLocator.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/connection/WalPositionLocator.java
new file mode 100644
index 0000000000..a4518bc830
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/io/debezium/connector/postgresql/connection/WalPositionLocator.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 io.debezium.connector.postgresql.connection;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.debezium.DebeziumException;
+import
io.debezium.connector.postgresql.connection.ReplicationMessage.Operation;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * This class is responsible for finding out a LSN from which Debezium should
resume streaming after
+ * connector restarts. The LSNs are not guaranteed to be ordered in the WAL.
LSN of commits are
+ * ordered and LSNs inside a transaction are ordered. It is thus necessary to
find out the beginning
+ * of the unprocessed transaction and the LSN from which the streaming should
start. Backports
+ * DBZ-5915 and DBZ-6204 to the bundled Debezium 1.9.8 implementation.
+ *
+ * <p>Copyright Debezium Authors. Adapted from Debezium 1.9.8.Final.
+ *
+ * @author Jiri Pechanec
+ */
+public class WalPositionLocator {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(WalPositionLocator.class);
+
+ private final Lsn lastCommitStoredLsn;
+ private final Lsn lastEventStoredLsn;
+ private final Operation lastProcessedMessageType;
+ private Lsn txStartLsn = null;
+ private Lsn lsnAfterLastEventStoredLsn = null;
+ private Lsn firstLsnReceived = null;
+ private boolean passMessages = true;
+ private Lsn startStreamingLsn = null;
+ private boolean storeLsnAfterLastEventStoredLsn = false;
+ private Set<Lsn> lsnSeen = new HashSet<>(1_000);
+
+ public WalPositionLocator(Lsn lastCommitStoredLsn, Lsn lastEventStoredLsn)
{
+ this(lastCommitStoredLsn, lastEventStoredLsn, null);
+ }
+
+ public WalPositionLocator(
+ Lsn lastCommitStoredLsn, Lsn lastEventStoredLsn, Operation
lastProcessedMessageType) {
+ this.lastCommitStoredLsn = lastCommitStoredLsn;
+ this.lastEventStoredLsn = lastEventStoredLsn;
+ this.lastProcessedMessageType = lastProcessedMessageType;
+
+ LOGGER.info(
+ "Looking for WAL restart position for last commit LSN '{}' and
last change LSN '{}'",
+ lastCommitStoredLsn,
+ lastEventStoredLsn);
+ }
+
+ public WalPositionLocator() {
+ this.lastCommitStoredLsn = null;
+ this.lastEventStoredLsn = null;
+ this.lastProcessedMessageType = null;
+
+ LOGGER.info("WAL position will not be searched");
+ }
+
+ /**
+ * @return the first LSN from which processing should be started or empty
if the position has
+ * not been found yet
+ */
+ public Optional<Lsn> resumeFromLsn(Lsn currentLsn, ReplicationMessage
message) {
+ LOGGER.trace("Processing LSN '{}', operation '{}'", currentLsn,
message.getOperation());
+
+ lsnSeen.add(currentLsn);
+
+ if (firstLsnReceived == null) {
+ firstLsnReceived = currentLsn;
+ LOGGER.info("First LSN '{}' received", firstLsnReceived);
+ }
+ if (storeLsnAfterLastEventStoredLsn) {
+ // Event that immediately follows the last event seen
+ // We can resume streaming from it
+ if (currentLsn.equals(lastEventStoredLsn)) {
+ // BEGIN and first message after change have the same LSN
+ if (txStartLsn != null
+ && (lastProcessedMessageType == null
+ || lastProcessedMessageType == Operation.BEGIN
+ || lastProcessedMessageType ==
Operation.COMMIT)) {
+ // The previous COMMIT can share its LSN with this BEGIN
and INSERT.
+ // Replay this transaction unless the stored operation was
already a row.
+ startStreamingLsn = txStartLsn;
+ return Optional.of(startStreamingLsn);
+ }
+ return Optional.empty();
+ }
+ lsnAfterLastEventStoredLsn = currentLsn;
+ storeLsnAfterLastEventStoredLsn = false;
+ LOGGER.info(
+ "LSN after last stored change LSN '{}' received",
lsnAfterLastEventStoredLsn);
+ startStreamingLsn = lsnAfterLastEventStoredLsn;
+ return Optional.of(startStreamingLsn);
+ }
+ if (currentLsn.equals(lastEventStoredLsn)) {
+ storeLsnAfterLastEventStoredLsn = true;
+ }
+
+ if (lastCommitStoredLsn == null) {
+ startStreamingLsn = firstLsnReceived;
+ return Optional.of(startStreamingLsn);
+ }
+
+ switch (message.getOperation()) {
+ case BEGIN:
+ txStartLsn = currentLsn;
+ break;
+ case COMMIT:
+ if (currentLsn.compareTo(lastCommitStoredLsn) > 0) {
+ LOGGER.info(
+ "Received COMMIT LSN '{}' larger than than last
stored commit LSN '{}'",
+ currentLsn,
+ lastCommitStoredLsn);
+ if (lsnAfterLastEventStoredLsn != null) {
+ startStreamingLsn = lsnAfterLastEventStoredLsn;
+ LOGGER.info(
+ "Will restart from LSN '{}' that follows the
lastest stored",
+ startStreamingLsn);
+ return Optional.of(startStreamingLsn);
+ } else if (txStartLsn != null) {
+ // Transaction that has not been processed and at the
same
+ // time the event following the last seen was not met
so
+ // replaying from the start of transaction
+ startStreamingLsn = txStartLsn;
+ LOGGER.info(
+ "Will restart from LSN '{}' that is start of
the first unprocessed transaction",
+ startStreamingLsn);
+ return Optional.of(startStreamingLsn);
+ } else {
+ // Transaction that has not been processed and at the
same
+ // time the event following the last seen was not met
and
+ // transaction start event has not been met so
replaying
+ // from the very first event
+ startStreamingLsn = firstLsnReceived;
+ LOGGER.info(
+ "Will restart from LSN '{}' that is the first
LSN available",
+ startStreamingLsn);
+ return Optional.of(startStreamingLsn);
+ }
+ }
+ break;
+ default:
+ break;
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Decides whether the message with given LSN should be removed or not
based on previously
+ * located LSN point.
+ *
+ * @param lsn
+ * @return true if the message should be skipped, false otherwise
+ */
+ public boolean skipMessage(Lsn lsn) {
+ if (passMessages) {
+ return false;
+ }
+ if (startStreamingLsn == null || startStreamingLsn.equals(lsn)) {
+ LOGGER.info("Message with LSN '{}' arrived, switching off the
filtering", lsn);
+ passMessages = true;
+ lsnSeen = new HashSet<>(); // Empty the Map as it might be large
and is no longer needed
+ return false;
+ }
+ if (lsn.isValid() && !lsnSeen.contains(lsn)) {
+ throw new DebeziumException(
+ String.format(
+ "Message with LSN '%s' not present among LSNs seen
in the location phase '%s'. This is unexpected and can lead to an infinite loop
or a data loss.",
+ lsn, lsnSeen));
+ }
+ LOGGER.debug("Message with LSN '{}' filtered", lsn);
+ return true;
+ }
+
+ /** Enables filtering of message LSNs based on calculated position. */
+ public void enableFiltering() {
+ passMessages = false;
+ }
+
+ /** @return true if searching of WAL position should be executed */
+ public boolean searchingEnabled() {
+ return lastEventStoredLsn != null;
+ }
+
+ public Lsn getLastEventStoredLsn() {
+ return lastEventStoredLsn;
+ }
+
+ @Override
+ public String toString() {
+ return "WalPositionLocator [lastCommitStoredLsn="
+ + lastCommitStoredLsn
+ + ", lastEventStoredLsn="
+ + lastEventStoredLsn
+ + ", txStartLsn="
+ + txStartLsn
+ + ", lsnAfterLastEventStoredLsn="
+ + lsnAfterLastEventStoredLsn
+ + ", firstLsnReceived="
+ + firstLsnReceived
+ + ", passMessages="
+ + passMessages
+ + ", startStreamingLsn="
+ + startStreamingLsn
+ + ", storeLsnAfterLastEventStoredLsn="
+ + storeLsnAfterLastEventStoredLsn
+ + "]";
+ }
+}
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/reader/PostgresSourceFetchTaskContext.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/reader/PostgresSourceFetchTaskContext.java
index 71b707d4db..468550b006 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/reader/PostgresSourceFetchTaskContext.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/reader/PostgresSourceFetchTaskContext.java
@@ -460,12 +460,16 @@ public class PostgresSourceFetchTaskContext extends
JdbcSourceFetchTaskContext {
Map<String, String> offsetStrMap =
Objects.requireNonNull(offset, "offset is null for the
sourceSplitBase")
.getOffset();
- // all the keys happen to be long type for
PostgresOffsetContext.Loader.load
+ // Numeric offsets are stored as strings in split state; the resume
operation is an enum.
Map<String, Object> offsetMap = new HashMap<>();
for (String key : offsetStrMap.keySet()) {
String value = offsetStrMap.get(key);
if (value != null) {
- offsetMap.put(key, Long.parseLong(value));
+ offsetMap.put(
+ key,
+
PostgresOffsetContext.LAST_PROCESSED_MESSAGE_TYPE_KEY.equals(key)
+ ? value
+ : Long.parseLong(value));
}
}
return loader.load(offsetMap);
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/offset/LsnOffsetTest.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/offset/LsnOffsetTest.java
index f0ca36e6da..d1c2b21c77 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/offset/LsnOffsetTest.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-postgres/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/source/offset/LsnOffsetTest.java
@@ -20,12 +20,23 @@ package
org.apache.seatunnel.connectors.seatunnel.cdc.postgres.source.offset;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import io.debezium.config.Configuration;
+import io.debezium.connector.postgresql.PostgresConnectorConfig;
import io.debezium.connector.postgresql.PostgresOffsetContext;
import io.debezium.connector.postgresql.SourceInfo;
import io.debezium.connector.postgresql.connection.Lsn;
+import io.debezium.connector.postgresql.connection.ReplicationMessage;
+import
io.debezium.connector.postgresql.connection.ReplicationMessage.Operation;
+import io.debezium.connector.postgresql.connection.WalPositionLocator;
+import io.debezium.relational.TableId;
+import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class LsnOffsetTest {
@@ -65,4 +76,107 @@ class LsnOffsetTest {
Lsn lsnCommit = offset.getLsnCommit();
Assertions.assertEquals(Lsn.valueOf(12345L), lsnCommit);
}
+
+ @Test
+ void testRestoreCommitDoesNotSkipInsertAtSameLsn() {
+ assertFirstInsertReplayed(Operation.COMMIT);
+ }
+
+ @Test
+ void testRestoreBeginDoesNotSkipFirstInsert() {
+ assertFirstInsertReplayed(Operation.BEGIN);
+ }
+
+ @Test
+ void testLegacyOffsetWithoutOperationReplaysAmbiguousBoundary() {
+ assertFirstInsertReplayed(null);
+ }
+
+ @Test
+ void testRestoreProcessedInsertResumesAtNextRecord() {
+ Lsn stored = Lsn.valueOf(123L);
+ Lsn next = Lsn.valueOf(124L);
+ WalPositionLocator locator =
+ new WalPositionLocator(Lsn.valueOf(100L), stored,
Operation.INSERT);
+ Assertions.assertFalse(locator.resumeFromLsn(stored,
message(Operation.BEGIN)).isPresent());
+ Assertions.assertFalse(
+ locator.resumeFromLsn(stored,
message(Operation.INSERT)).isPresent());
+ Assertions.assertEquals(
+ Optional.of(next), locator.resumeFromLsn(next,
message(Operation.UPDATE)));
+ locator.enableFiltering();
+ Assertions.assertTrue(locator.skipMessage(stored));
+ Assertions.assertFalse(locator.skipMessage(next));
+ }
+
+ @Test
+ void testOffsetOperationSurvivesCheckpointAndChangesAtCommit() {
+ PostgresOffsetContext.Loader loader = offsetLoader();
+ PostgresOffsetContext context = loader.load(legacyOffset());
+ Assertions.assertFalse(
+ context.getOffset()
+
.containsKey(PostgresOffsetContext.LAST_PROCESSED_MESSAGE_TYPE_KEY));
+ context.updateWalPosition(
+ Lsn.valueOf(123L),
+ Lsn.valueOf(123L),
+ Instant.EPOCH,
+ 7L,
+ null,
+ TableId.parse("postgres.public.test"),
+ Operation.INSERT);
+ Assertions.assertEquals(
+ "INSERT",
+ loader.load(context.getOffset())
+ .getOffset()
+
.get(PostgresOffsetContext.LAST_PROCESSED_MESSAGE_TYPE_KEY));
+
+ // The default non-transactional-metadata path updates the commit
independently.
+ context.updateCommitPosition(Lsn.valueOf(124L), Lsn.valueOf(124L));
+ PostgresOffsetContext restored = loader.load(context.getOffset());
+ Assertions.assertEquals(
+ "COMMIT",
+
restored.getOffset().get(PostgresOffsetContext.LAST_PROCESSED_MESSAGE_TYPE_KEY));
+ Assertions.assertEquals(
+ 124L,
+
restored.getOffset().get(PostgresOffsetContext.LAST_COMPLETELY_PROCESSED_LSN_KEY));
+ }
+
+ private void assertFirstInsertReplayed(Operation previousOperation) {
+ Lsn boundary = Lsn.valueOf(123L);
+ WalPositionLocator locator =
+ previousOperation == null
+ ? new WalPositionLocator(boundary, boundary)
+ : new WalPositionLocator(boundary, boundary,
previousOperation);
+ Assertions.assertFalse(
+ locator.resumeFromLsn(boundary,
message(Operation.BEGIN)).isPresent());
+ Assertions.assertEquals(
+ Optional.of(boundary), locator.resumeFromLsn(boundary,
message(Operation.INSERT)));
+ locator.enableFiltering();
+ Assertions.assertFalse(locator.skipMessage(boundary));
+ }
+
+ private ReplicationMessage message(Operation operation) {
+ ReplicationMessage message = mock(ReplicationMessage.class);
+ when(message.getOperation()).thenReturn(operation);
+ return message;
+ }
+
+ private PostgresOffsetContext.Loader offsetLoader() {
+ return new PostgresOffsetContext.Loader(
+ new PostgresConnectorConfig(
+ Configuration.create()
+ .with("database.server.name", "offset-test")
+ .with("database.hostname", "localhost")
+ .with("database.user", "test")
+ .with("database.dbname", "postgres")
+ .build()));
+ }
+
+ private Map<String, Object> legacyOffset() {
+ Map<String, Object> offset = new HashMap<>();
+ offset.put(SourceInfo.LSN_KEY, 123L);
+ offset.put(SourceInfo.TIMESTAMP_USEC_KEY, 0L);
+ offset.put(PostgresOffsetContext.LAST_COMPLETELY_PROCESSED_LSN_KEY,
123L);
+ offset.put(PostgresOffsetContext.LAST_COMMIT_LSN_KEY, 100L);
+ return offset;
+ }
}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-postgres-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/PostgresCDCIT.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-postgres-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/PostgresCDCIT.java
index beac962e97..4123adbac8 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-postgres-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/PostgresCDCIT.java
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-postgres-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/postgres/PostgresCDCIT.java
@@ -484,16 +484,10 @@ public class PostgresCDCIT extends TestSuiteBase
implements TestResource {
}
Assertions.assertEquals(1, seedRows.size());
});
- String postSeedLsn = getCurrentWalLsn();
- await().atMost(30000, TimeUnit.MILLISECONDS)
- .untilAsserted(
- () ->
- Assertions.assertTrue(
- isLsnGreaterThanOrEqual(
-
getReplicationSlotCommittedLsn(
- committedSlotName),
- postSeedLsn)));
- container.cancelJob(String.valueOf(seedJobId));
+ // Use a completed checkpoint as the committed-offset seed. The
global WAL also
+ // contains filtered sink writes and is not itself a source
checkpoint boundary.
+ Assertions.assertEquals(
+ 0,
container.savepointJob(String.valueOf(seedJobId)).getExitCode());
seedJob.get(30, TimeUnit.SECONDS);
await().atMost(30000, TimeUnit.MILLISECONDS)
.untilAsserted(
@@ -589,6 +583,17 @@ public class PostgresCDCIT extends TestSuiteBase
implements TestResource {
+ " where id = 14"));
});
+ // Drain the earlier writes before saving. With a quiet WAL, the
next transaction's
+ // BEGIN/INSERT can share the saved COMMIT LSN (the DBZ-6204
recovery boundary).
+ String quietBoundaryLsn = getCurrentWalLsn();
+ await().atMost(60000, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertTrue(
+ isLsnGreaterThanOrEqual(
+
getReplicationSlotCommittedLsn(
+ committedSlotName),
+ quietBoundaryLsn)));
Assertions.assertEquals(
0,
container.savepointJob(String.valueOf(committedOffsetJobId)).getExitCode());
committedOffsetJob.get(30, TimeUnit.SECONDS);
@@ -601,6 +606,8 @@ public class PostgresCDCIT extends TestSuiteBase implements
TestResource {
Assertions.assertFalse(
isReplicationSlotActive(committedSlotName)));
+ // Changes committed while stopped must be replayed even at the
saved commit LSN.
+ insertSourceTableRow(POSTGRESQL_SCHEMA, SOURCE_TABLE_1, 15);
committedOffsetJob =
CompletableFuture.runAsync(
() -> {
@@ -618,9 +625,8 @@ public class PostgresCDCIT extends TestSuiteBase implements
TestResource {
// Restoring the checkpoint and reconnecting the existing
replication slot can take
// longer on shared GitHub runners than the initial CDC startup.
waitForReplicationSlotActive(committedSlotName);
- // Insert only after the restored replication connection is active
so this CDC record
- // is not written before the restored slot can consume it.
- insertSourceTableRow(POSTGRESQL_SCHEMA, SOURCE_TABLE_1, 15);
+ // Also verify that live changes continue after recovery.
+ insertSourceTableRow(POSTGRESQL_SCHEMA, SOURCE_TABLE_1, 16);
await().atMost(RESTORE_ASSERT_TIMEOUT_MILLIS,
TimeUnit.MILLISECONDS)
.untilAsserted(
() -> {
@@ -634,6 +640,15 @@ public class PostgresCDCIT extends TestSuiteBase
implements TestResource {
+ SINK_TABLE_1
+ " where id =
15")
.size());
+ Assertions.assertEquals(
+ 1,
+ query(
+ "select * from "
+ +
POSTGRESQL_SCHEMA
+ + "."
+ + SINK_TABLE_1
+ + " where id =
16")
+ .size());
});
} finally {
try {
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java
index 95b27bb4ce..e324cf0916 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java
@@ -75,6 +75,7 @@ public class PayPalIT extends TestSuiteBase implements
TestResource {
}
}
+ /** Verifies that a bodyless OAuth retry and token expiry refresh preserve
all records. */
@TestTemplate
public void readsAllRecordsThroughOAuthExpiryRefresh(TestContainer
container) throws Exception {
client.reset();
@@ -90,6 +91,7 @@ public class PayPalIT extends TestSuiteBase implements
TestResource {
"mock-client:mock-secret"
.getBytes(StandardCharsets.UTF_8)))
.withBody("grant_type=client_credentials");
+ client.when(oauth,
Times.exactly(1)).respond(HttpResponse.response().withStatusCode(503));
client.when(oauth, Times.exactly(1))
.respond(
HttpResponse.response()
@@ -117,7 +119,7 @@ public class PayPalIT extends TestSuiteBase implements
TestResource {
.withBody(report(2, row("T0006"))));
Container.ExecResult result =
container.executeJob("/paypal_to_assert.conf");
Assertions.assertEquals(0, result.getExitCode(), result.getStderr());
- client.verify(oauth, VerificationTimes.exactly(2));
+ client.verify(oauth, VerificationTimes.exactly(3));
client.verify(first, VerificationTimes.exactly(1));
client.verify(last, VerificationTimes.exactly(1));
client.verify(
diff --git
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java
index d2256b3305..3cf9cd0980 100644
---
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java
+++
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java
@@ -371,6 +371,8 @@ public class CheckpointCoordinatorFailoverIT {
observedPipelineId.get(), ckIdBefore.get(),
ckIdAfter.get()));
clientJobProxy.cancelJob();
+ // Recovery must also resolve cancellation through the job
completion future.
+ Assertions.assertEquals(JobStatus.CANCELED,
clientJobProxy.waitForJobComplete());
} finally {
if (engineClient != null) {
engineClient.close();
diff --git
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestFilterRowKindIT.java
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestFilterRowKindIT.java
index d923d7137b..ee6f92aacc 100644
---
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestFilterRowKindIT.java
+++
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/java/org/apache/seatunnel/e2e/transform/TestFilterRowKindIT.java
@@ -45,6 +45,7 @@ public class TestFilterRowKindIT extends TestSuiteBase {
Assertions.assertEquals(0, execResult4.getExitCode());
}
+ /** Verifies exact per-table totals with one assertion writer across
engine parallelisms. */
@TestTemplate
public void testFilterRowKindMultiTable(TestContainer container)
throws IOException, InterruptedException {
diff --git
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/filter_row_kind_exclude_insert_multi_table.conf
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/filter_row_kind_exclude_insert_multi_table.conf
index ad6f50177b..ce2d726b20 100644
---
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/filter_row_kind_exclude_insert_multi_table.conf
+++
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-1/src/test/resources/filter_row_kind_exclude_insert_multi_table.conf
@@ -103,6 +103,9 @@ transform {
sink {
Assert {
+ # Check totals for each table after collecting rows from every transform
partition.
+ # See apache/seatunnel#12216 for aggregation across multiple assertion
writers.
+ parallelism = 1
rules =
{
tables_configs = [
@@ -148,4 +151,4 @@ sink {
]
}
}
-}
\ No newline at end of file
+}
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 e2eb7f0fff..d72723cddc 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
@@ -404,7 +404,7 @@ public class TaskExecutionService implements
DynamicMetricsProvider {
MDCExecutorService mdcExecutorService =
MDCTracer.tracing(executorService);
CountDownLatch startedLatch = new CountDownLatch(tasks.size());
- taskGroupExecutionTracker.blockingFutures =
+ List<Future<?>> blockingFutures =
tasks.stream()
.map(
t ->
@@ -425,7 +425,17 @@ public class TaskExecutionService implements
DynamicMetricsProvider {
// on cancellation there is a race where the executor might not have
started
// the worker yet. This would result in taskletDone() never being
called for
// a worker.
- uncheckRun(startedLatch::await);
+ try {
+ uncheckRun(startedLatch::await);
+ } finally {
+ // Early failure cleanup must not cancel a worker before run()
releases startedLatch.
+ // Publish the futures after startup, then honor any failure or
cancellation received
+ // while waiting so already-running tasks are still interrupted.
+ taskGroupExecutionTracker.blockingFutures = blockingFutures;
+ if (taskGroupExecutionTracker.executionCompletedExceptionally()) {
+ taskGroupExecutionTracker.cancelAllTask();
+ }
+ }
}
/**
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/dag/physical/PhysicalVertex.java
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/dag/physical/PhysicalVertex.java
index 273bd587cb..c2a73ef9c1 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/dag/physical/PhysicalVertex.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/dag/physical/PhysicalVertex.java
@@ -406,9 +406,17 @@ public class PhysicalVertex {
}
}
+ /**
+ * Requests cancellation and publishes any terminal result received while
state processing was
+ * stopped for pipeline restore.
+ */
public synchronized void cancel() {
if (!getExecutionState().isEndState()) {
updateTaskState(ExecutionState.CANCELING);
+ } else if (!taskFuture.isDone()) {
+ // A late terminal callback may arrive before this vertex resumes.
Its new completion
+ // future still needs to be resolved without changing the terminal
state.
+ stateProcess();
}
}
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
index e95b23f586..2ce8e0c022 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
@@ -46,6 +46,8 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import com.hazelcast.flakeidgen.FlakeIdGenerator;
@@ -66,9 +68,17 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import static java.util.Collections.emptySet;
import static
org.apache.seatunnel.engine.server.execution.ExecutionState.CANCELED;
@@ -105,6 +115,136 @@ public class TaskExecutionServiceTest extends
AbstractSeaTunnelServerTest {
failure -> {});
}
+ /**
+ * A task can fail or be cancelled while another blocking worker has not
yet been scheduled.
+ * Cleanup must let that worker release the startup latch so deployment
and the final result
+ * both return.
+ */
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testBlockingDeploymentCompletesAfterEarlyFailureOrCancellation(
+ boolean cancelDuringStartup) throws Exception {
+ TaskExecutionService service = server.getTaskExecutionService();
+ ExecutorService originalExecutor = getField(service,
"executorService");
+ CountDownLatch failFirstTask = new CountDownLatch(1);
+ CountDownLatch firstTaskClosed = new CountDownLatch(1);
+ CountDownLatch startSecondWorker = new CountDownLatch(1);
+ CountDownLatch workersSubmitted = new CountDownLatch(2);
+ AtomicInteger workerNumber = new AtomicInteger();
+ AtomicReference<Thread> deploymentThread = new AtomicReference<>();
+ ExecutorService workers =
+ new ThreadPoolExecutor(
+ 0,
+ Integer.MAX_VALUE,
+ 60L,
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ runnable -> {
+ int number = workerNumber.incrementAndGet();
+ Thread thread =
+ new Thread(
+ () -> {
+ try {
+ if (number == 2) {
+
startSecondWorker.await();
+ }
+ runnable.run();
+ } catch (InterruptedException
e) {
+
Thread.currentThread().interrupt();
+ }
+ },
+ "delayed-blocking-worker-" +
number);
+ thread.setDaemon(true);
+ return thread;
+ }) {
+ @Override
+ public void execute(Runnable command) {
+ super.execute(command);
+ workersSubmitted.countDown();
+ }
+ };
+ ExecutorService deployer = Executors.newSingleThreadExecutor();
+ TaskGroupLocation location = newTaskGroupLocation();
+ String failureMessage = "Source permission denied during
initialization";
+ Task failingTask =
+ new TestTask(new AtomicBoolean(true), 0, false) {
+ @Override
+ public void init() throws Exception {
+ failFirstTask.await();
+ throw new IllegalStateException(failureMessage);
+ }
+
+ @Override
+ public void close() {
+ firstTaskClosed.countDown();
+ }
+ };
+ Task delayedTask = new TestTask(new AtomicBoolean(true), 0, false);
+ TaskGroup group =
+ new TaskGroupDefaultImpl(
+ location,
+ "early-startup-failure",
+ Lists.newArrayList(failingTask, delayedTask));
+ ConcurrentHashMap<Long, ClassLoader> classLoaders = new
ConcurrentHashMap<>();
+ for (Task task : group.getTasks()) {
+ classLoaders.put(task.getTaskID(),
Thread.currentThread().getContextClassLoader());
+ }
+
+ ReflectionUtils.setField(service, "executorService", workers);
+ Future<PassiveCompletableFuture<TaskExecutionState>> deployment = null;
+ try {
+ Future<PassiveCompletableFuture<TaskExecutionState>>
submittedDeployment =
+ deployer.submit(
+ () -> {
+ deploymentThread.set(Thread.currentThread());
+ return service.deployLocalTask(
+ FLAKE_ID_GENERATOR.newId(),
+ group,
+ classLoaders,
+ new ConcurrentHashMap<>(),
+ () -> {},
+ failure -> {});
+ });
+ deployment = submittedDeployment;
+ assertTrue(workersSubmitted.await(10, TimeUnit.SECONDS));
+ // Both executor submissions have returned, so an unfinished
deployment can only
+ // be waiting for the delayed worker's startup signal, not
submitting a worker.
+ await().atMost(10, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ Thread thread = deploymentThread.get();
+ return !submittedDeployment.isDone()
+ && thread.getState() ==
Thread.State.WAITING;
+ });
+ if (cancelDuringStartup) {
+ service.cancelTaskGroup(location);
+ } else {
+ failFirstTask.countDown();
+ assertTrue(firstTaskClosed.await(10, TimeUnit.SECONDS));
+ }
+ startSecondWorker.countDown();
+
+ TaskExecutionState result =
+ deployment.get(10, TimeUnit.SECONDS).get(10,
TimeUnit.SECONDS);
+ assertEquals(cancelDuringStartup ? CANCELED : FAILED,
result.getExecutionState());
+ if (!cancelDuringStartup) {
+ assertTrue(result.getThrowableMsg().contains(failureMessage));
+ }
+ } finally {
+ failFirstTask.countDown();
+ startSecondWorker.countDown();
+ if (deployment != null) {
+ deployment.cancel(true);
+ }
+ service.cancelTaskGroup(location);
+ workers.shutdownNow();
+ deployer.shutdownNow();
+ workers.awaitTermination(10, TimeUnit.SECONDS);
+ deployer.awaitTermination(10, TimeUnit.SECONDS);
+ ReflectionUtils.setField(service, "executorService",
originalExecutor);
+ }
+ }
+
@Test
public void testCancel() {
TaskExecutionService taskExecutionService =
server.getTaskExecutionService();
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/dag/physical/StateTransitionCleanupTest.java
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/dag/physical/StateTransitionCleanupTest.java
index 7a1a339a0e..2bcf9a53db 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/dag/physical/StateTransitionCleanupTest.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/dag/physical/StateTransitionCleanupTest.java
@@ -23,15 +23,19 @@ import org.apache.seatunnel.engine.common.Constant;
import org.apache.seatunnel.engine.common.config.EngineConfig;
import org.apache.seatunnel.engine.common.config.JobConfig;
import org.apache.seatunnel.engine.common.job.JobStatus;
+import org.apache.seatunnel.engine.common.utils.PassiveCompletableFuture;
import org.apache.seatunnel.engine.core.dag.logical.LogicalDag;
import org.apache.seatunnel.engine.core.job.JobImmutableInformation;
import org.apache.seatunnel.engine.core.job.PipelineStatus;
import org.apache.seatunnel.engine.server.AbstractSeaTunnelServerTest;
import org.apache.seatunnel.engine.server.TestUtils;
import org.apache.seatunnel.engine.server.execution.ExecutionState;
+import org.apache.seatunnel.engine.server.execution.TaskExecutionState;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junitpioneer.jupiter.SetEnvironmentVariable;
import com.hazelcast.map.IMap;
@@ -46,6 +50,62 @@ import static
org.apache.seatunnel.engine.core.classloader.DefaultClassLoaderSer
@SetEnvironmentVariable(key = SKIP_CHECK_JAR, value = "true")
class StateTransitionCleanupTest extends AbstractSeaTunnelServerTest {
+ /**
+ * A terminal callback can arrive after reset while the vertex is stopped.
Resuming pipeline
+ * cancellation must complete the new future even though no further task
transition is needed.
+ */
+ @ParameterizedTest
+ @CsvSource({
+ "FAILED, true",
+ "CANCELED, true",
+ "FINISHED, true",
+ "FAILED, false",
+ "CANCELED, false",
+ "FINISHED, false"
+ })
+ void testRestoredCancellationCompletesLateTerminalState(
+ ExecutionState terminalState, boolean callbackBeforeInit) throws
Exception {
+ long jobId =
instance.getFlakeIdGenerator(Constant.SEATUNNEL_ID_GENERATOR_NAME).newId();
+ PlanWithStateMaps planWithStateMaps = createPhysicalPlan(jobId);
+ PhysicalVertex vertex =
+ planWithStateMaps
+ .physicalPlan
+ .getPipelineList()
+ .get(0)
+ .getPhysicalVertexList()
+ .get(0);
+
+ vertex.startPhysicalVertex();
+ vertex.updateTaskState(ExecutionState.CANCELED);
+ vertex.reset();
+ String error =
+ terminalState == ExecutionState.FAILED ? "Paimon SELECT
permission denied" : null;
+ TaskExecutionState lateResult =
+ new TaskExecutionState(vertex.getTaskGroupLocation(),
terminalState, error);
+ if (callbackBeforeInit) {
+ vertex.updateStateByExecutionService(lateResult);
+ }
+ PassiveCompletableFuture<TaskExecutionState> restoredFuture =
vertex.initStateFuture();
+
+ if (!callbackBeforeInit) {
+ vertex.updateStateByExecutionService(lateResult);
+ }
+ Assertions.assertFalse(restoredFuture.isDone());
+
+ vertex.startPhysicalVertex();
+ vertex.cancel();
+
+ Assertions.assertTrue(
+ restoredFuture.isDone(),
+ "Resumed cancellation must publish the terminal task result");
+ TaskExecutionState result = restoredFuture.get();
+ Assertions.assertEquals(terminalState, result.getExecutionState());
+ Assertions.assertEquals(error, result.getThrowableMsg());
+ Assertions.assertEquals(
+ terminalState,
+
planWithStateMaps.runningJobState.get(vertex.getTaskGroupLocation()));
+ }
+
@Test
void testPhysicalVertexIgnoresLateTransitionWhenTaskStateAlreadyTerminal()
throws Exception {
long jobId =
instance.getFlakeIdGenerator(Constant.SEATUNNEL_ID_GENERATOR_NAME).newId();