This is an automated email from the ASF dual-hosted git repository.

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 089fe6a7ff [Fix][Connector-v2][CDC] Fix job hang caused by checkpoint 
failure during schema evolution under multi-parallelism on Flink1.13 (#10951)
089fe6a7ff is described below

commit 089fe6a7ff7fc20f82b24c43fe413ecd088b94a7
Author: cloverdew <[email protected]>
AuthorDate: Sat Jun 13 17:48:53 2026 +0800

    [Fix][Connector-v2][CDC] Fix job hang caused by checkpoint failure during 
schema evolution under multi-parallelism on Flink1.13 (#10951)
---
 .../flink/execution/SourceExecuteProcessor.java    |  71 ++++++++++
 ...or.java => AbstractSourceExecuteProcessor.java} |  64 ++++++++-
 .../flink/execution/SourceExecuteProcessor.java    | 156 ++------------------
 ...mysqlcdc_to_mysql_with_flink_schema_change.conf |   2 +-
 ...ysql_with_flink_schema_change_exactly_once.conf |   2 +-
 .../translation/flink/schema/SchemaOperator13.java | 106 ++++++++++++++
 .../translation/flink/schema/SchemaOperator.java   | 157 +++++++++++++++++----
 .../schema/coordinator/LocalSchemaCoordinator.java |  78 +++++++---
 .../flink/source/FlinkSourceReader.java            |  23 ++-
 .../flink/source/FlinkSourceReaderContext.java     |   4 +
 .../flink/schema/SchemaOperatorTest.java           |  76 ++++++++++
 .../flink/sink/FlinkSinkWriterTest.java            |  66 ++++++++-
 12 files changed, 594 insertions(+), 211 deletions(-)

diff --git 
a/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-13-starter/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-13-starter/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
new file mode 100644
index 0000000000..9812274917
--- /dev/null
+++ 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-13-starter/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.core.starter.flink.execution;
+
+import org.apache.seatunnel.shade.com.typesafe.config.Config;
+
+import org.apache.seatunnel.api.common.JobContext;
+import org.apache.seatunnel.api.source.SupportSchemaEvolution;
+import org.apache.seatunnel.translation.flink.schema.SchemaOperator;
+import org.apache.seatunnel.translation.flink.schema.SchemaOperator13;
+
+import java.net.URL;
+import java.util.List;
+
+/**
+ * Flink 1.13-specific source execution processor. Shadows the common {@code 
SourceExecuteProcessor}
+ * at runtime (same package, same class name) to provide two Flink 
1.13-specific behaviours without
+ * using reflection:
+ *
+ * <ol>
+ *   <li>{@link #createSchemaOperator} returns {@link SchemaOperator13}, which 
registers the
+ *       checkpoint-stall fallback timer via the strongly-typed {@code
+ *       ProcessingTimeService.registerTimer} API instead of a background 
{@code
+ *       ScheduledExecutorService} + reflection.
+ *   <li>{@link #supportsSinkFunctionFinish} hard-codes {@code false}: Flink 
1.13's {@code
+ *       SinkFunction} does not expose a {@code finish()} method, so this fact 
is known at compile
+ *       time and no reflection is needed.
+ * </ol>
+ */
+@SuppressWarnings("unchecked,rawtypes")
+public class SourceExecuteProcessor extends AbstractSourceExecuteProcessor {
+
+    public SourceExecuteProcessor(
+            List<URL> jarPaths,
+            Config envConfig,
+            List<? extends Config> pluginConfigs,
+            JobContext jobContext) {
+        super(jarPaths, envConfig, pluginConfigs, jobContext);
+    }
+
+    @Override
+    protected SchemaOperator createSchemaOperator(
+            String jobId, SupportSchemaEvolution source, Config pluginConfig) {
+        return new SchemaOperator13(jobId, source, pluginConfig);
+    }
+
+    /**
+     * Flink 1.13's {@code SinkFunction} does not have a {@code finish()} 
method, so source
+     * keep-alive must always be enabled when schema evolution is active. 
Returns {@code false}
+     * directly rather than using reflection.
+     */
+    @Override
+    protected boolean supportsSinkFunctionFinish() {
+        return false;
+    }
+}
diff --git 
a/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/AbstractSourceExecuteProcessor.java
similarity index 74%
copy from 
seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
copy to 
seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/AbstractSourceExecuteProcessor.java
index 94393b0ec4..158570238a 100644
--- 
a/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
+++ 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/AbstractSourceExecuteProcessor.java
@@ -19,6 +19,7 @@ package org.apache.seatunnel.core.starter.flink.execution;
 
 import org.apache.seatunnel.shade.com.google.common.collect.Lists;
 import org.apache.seatunnel.shade.com.typesafe.config.Config;
+import org.apache.seatunnel.shade.com.typesafe.config.ConfigValueFactory;
 
 import org.apache.seatunnel.api.common.JobContext;
 import org.apache.seatunnel.api.common.PluginIdentifier;
@@ -61,9 +62,12 @@ import static 
org.apache.seatunnel.api.table.factory.FactoryUtil.ensureJobModeMa
 import static org.apache.seatunnel.common.constants.JobMode.STREAMING;
 
 @SuppressWarnings("unchecked,rawtypes")
-public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<SourceTableInfo> {
+public abstract class AbstractSourceExecuteProcessor
+        extends FlinkAbstractPluginExecuteProcessor<SourceTableInfo> {
 
-    public SourceExecuteProcessor(
+    private static final String SOURCE_KEEP_ALIVE_CONFIG = 
"schema-changes.source-keep-alive";
+
+    protected AbstractSourceExecuteProcessor(
             List<URL> jarPaths,
             Config envConfig,
             List<? extends Config> pluginConfigs,
@@ -80,11 +84,13 @@ public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<
             SourceTableInfo sourceTableInfo = plugins.get(i);
             SeaTunnelSource internalSource = sourceTableInfo.getSource();
             Config pluginConfig = pluginConfigs.get(i);
-            FlinkSource flinkSource = new FlinkSource<>(internalSource, 
envConfig);
 
             DataStreamSource<SeaTunnelRow> sourceStream =
                     executionEnvironment.fromSource(
-                            flinkSource,
+                            new FlinkSource<>(
+                                    internalSource,
+                                    enableSourceKeepAliveIfNeeded(
+                                            internalSource, pluginConfig, 
envConfig)),
                             WatermarkStrategy.noWatermarks(),
                             String.format("%s-Source", 
internalSource.getPluginName()));
 
@@ -116,7 +122,7 @@ public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<
                         sourceStream.transform(
                                 "schema-evolution",
                                 TypeInformation.of(SeaTunnelRow.class),
-                                new SchemaOperator(
+                                createSchemaOperator(
                                         jobContext.getJobId(),
                                         (SupportSchemaEvolution) 
sourceTableInfo.getSource(),
                                         pluginConfig));
@@ -139,6 +145,54 @@ public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<
         return sources;
     }
 
+    private Config enableSourceKeepAliveIfNeeded(
+            SeaTunnelSource source, Config pluginConfig, Config 
currentEnvConfig) {
+        boolean isStreaming =
+                currentEnvConfig.hasPath("job.mode")
+                        && STREAMING
+                                .toString()
+                                
.equalsIgnoreCase(currentEnvConfig.getString("job.mode"));
+        boolean enableSchemaChange =
+                pluginConfig.hasPath("schema-changes.enabled")
+                        && pluginConfig.getBoolean("schema-changes.enabled");
+        boolean shouldEnableKeepAlive =
+                isStreaming
+                        && enableSchemaChange
+                        && source instanceof SupportSchemaEvolution
+                        && !supportsSinkFunctionFinish();
+        if (!shouldEnableKeepAlive) {
+            return currentEnvConfig;
+        }
+        return currentEnvConfig.withValue(
+                SOURCE_KEEP_ALIVE_CONFIG, ConfigValueFactory.fromAnyRef(true));
+    }
+
+    /**
+     * Returns the {@link SchemaOperator} instance to attach after 
schema-evolution-capable sources.
+     * Subclasses may override to return a version-specific operator (e.g. 
{@code SchemaOperator13}
+     * for Flink 1.13) that uses the public {@code ProcessingTimeService} API 
instead of reflection.
+     */
+    protected SchemaOperator createSchemaOperator(
+            String jobId, SupportSchemaEvolution source, Config pluginConfig) {
+        return new SchemaOperator(jobId, source, pluginConfig);
+    }
+
+    /**
+     * Returns {@code true} if the current Flink runtime's {@code 
SinkFunction} exposes a {@code
+     * finish()} method (introduced in Flink 1.14). When {@code false}, source 
keep-alive is enabled
+     * so pending schema changes can still be applied after all source 
subtasks finish. Subclasses
+     * may override with a hard-coded value to avoid reflection.
+     */
+    protected boolean supportsSinkFunctionFinish() {
+        for (java.lang.reflect.Method method :
+                
org.apache.flink.streaming.api.functions.sink.SinkFunction.class.getMethods()) {
+            if ("finish".equals(method.getName()) && 
method.getParameterCount() == 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @Override
     protected List<SourceTableInfo> initializePlugins(
             List<URL> jarPaths, List<? extends Config> pluginConfigs) {
diff --git 
a/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
index 94393b0ec4..6cd1efcf20 100644
--- 
a/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
+++ 
b/seatunnel-core/seatunnel-flink-starter/seatunnel-flink-starter-common/src/main/java/org/apache/seatunnel/core/starter/flink/execution/SourceExecuteProcessor.java
@@ -17,51 +17,22 @@
 
 package org.apache.seatunnel.core.starter.flink.execution;
 
-import org.apache.seatunnel.shade.com.google.common.collect.Lists;
 import org.apache.seatunnel.shade.com.typesafe.config.Config;
 
 import org.apache.seatunnel.api.common.JobContext;
-import org.apache.seatunnel.api.common.PluginIdentifier;
-import org.apache.seatunnel.api.configuration.ReadonlyConfig;
-import org.apache.seatunnel.api.options.EnvCommonOptions;
-import org.apache.seatunnel.api.source.SeaTunnelSource;
-import org.apache.seatunnel.api.source.SourceSplit;
-import org.apache.seatunnel.api.source.SupportSchemaEvolution;
-import org.apache.seatunnel.api.table.catalog.CatalogTable;
-import org.apache.seatunnel.api.table.factory.FactoryUtil;
-import org.apache.seatunnel.api.table.factory.TableSourceFactory;
-import org.apache.seatunnel.api.table.type.SeaTunnelRow;
-import org.apache.seatunnel.common.constants.EngineType;
-import org.apache.seatunnel.common.constants.PluginType;
-import org.apache.seatunnel.core.starter.execution.SourceTableInfo;
-import 
org.apache.seatunnel.plugin.discovery.seatunnel.SeaTunnelFactoryDiscovery;
-import 
org.apache.seatunnel.plugin.discovery.seatunnel.SeaTunnelSourcePluginDiscovery;
-import org.apache.seatunnel.translation.flink.schema.SchemaOperator;
-import org.apache.seatunnel.translation.flink.source.FlinkSource;
 
-import org.apache.flink.api.common.eventtime.WatermarkStrategy;
-import org.apache.flink.api.common.typeinfo.TypeInformation;
-import org.apache.flink.streaming.api.datastream.DataStream;
-import org.apache.flink.streaming.api.datastream.DataStreamSource;
-import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
-
-import scala.Tuple2;
-
-import java.io.Serializable;
 import java.net.URL;
-import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
-import java.util.function.Function;
-
-import static 
org.apache.seatunnel.api.options.ConnectorCommonOptions.PLUGIN_NAME;
-import static 
org.apache.seatunnel.api.options.ConnectorCommonOptions.PLUGIN_OUTPUT;
-import static 
org.apache.seatunnel.api.table.factory.FactoryUtil.ensureJobModeMatch;
-import static org.apache.seatunnel.common.constants.JobMode.STREAMING;
 
+/**
+ * Default (Flink 1.15+) source execution processor. Delegates entirely to 
{@link
+ * AbstractSourceExecuteProcessor}. For Flink 1.13, this class is shadowed at 
runtime by the version
+ * in {@code seatunnel-flink-13-starter}, which overrides {@link 
#createSchemaOperator} and {@link
+ * #supportsSinkFunctionFinish} with strongly-typed Flink 1.13 implementations 
that avoid
+ * reflection.
+ */
 @SuppressWarnings("unchecked,rawtypes")
-public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<SourceTableInfo> {
+public class SourceExecuteProcessor extends AbstractSourceExecuteProcessor {
 
     public SourceExecuteProcessor(
             List<URL> jarPaths,
@@ -70,115 +41,4 @@ public class SourceExecuteProcessor extends 
FlinkAbstractPluginExecuteProcessor<
             JobContext jobContext) {
         super(jarPaths, envConfig, pluginConfigs, jobContext);
     }
-
-    @Override
-    public List<DataStreamTableInfo> execute(List<DataStreamTableInfo> 
upstreamDataStreams) {
-        StreamExecutionEnvironment executionEnvironment =
-                flinkRuntimeEnvironment.getStreamExecutionEnvironment();
-        List<DataStreamTableInfo> sources = new ArrayList<>();
-        for (int i = 0; i < plugins.size(); i++) {
-            SourceTableInfo sourceTableInfo = plugins.get(i);
-            SeaTunnelSource internalSource = sourceTableInfo.getSource();
-            Config pluginConfig = pluginConfigs.get(i);
-            FlinkSource flinkSource = new FlinkSource<>(internalSource, 
envConfig);
-
-            DataStreamSource<SeaTunnelRow> sourceStream =
-                    executionEnvironment.fromSource(
-                            flinkSource,
-                            WatermarkStrategy.noWatermarks(),
-                            String.format("%s-Source", 
internalSource.getPluginName()));
-
-            if (pluginConfig.hasPath(EnvCommonOptions.PARALLELISM.key())) {
-                int parallelism = 
pluginConfig.getInt(EnvCommonOptions.PARALLELISM.key());
-                sourceStream.setParallelism(parallelism);
-            }
-
-            boolean isStreaming =
-                    envConfig.hasPath("job.mode")
-                            && STREAMING
-                                    .toString()
-                                    
.equalsIgnoreCase(envConfig.getString("job.mode"));
-
-            boolean enableSchemaChange = false;
-            for (Config cfg : pluginConfigs) {
-                if (cfg.hasPath("schema-changes.enabled")
-                        && cfg.getBoolean("schema-changes.enabled")) {
-                    enableSchemaChange = true;
-                    break;
-                }
-            }
-            // add schema evolution functionality to cdc source
-            DataStream<SeaTunnelRow> evolvedStream = null;
-            if (isStreaming
-                    && enableSchemaChange
-                    && sourceTableInfo.getSource() instanceof 
SupportSchemaEvolution) {
-                evolvedStream =
-                        sourceStream.transform(
-                                "schema-evolution",
-                                TypeInformation.of(SeaTunnelRow.class),
-                                new SchemaOperator(
-                                        jobContext.getJobId(),
-                                        (SupportSchemaEvolution) 
sourceTableInfo.getSource(),
-                                        pluginConfig));
-            }
-
-            if (evolvedStream != null) {
-                sources.add(
-                        new DataStreamTableInfo(
-                                evolvedStream,
-                                sourceTableInfo.getCatalogTables(),
-                                
ReadonlyConfig.fromConfig(pluginConfig).get(PLUGIN_OUTPUT)));
-            } else {
-                sources.add(
-                        new DataStreamTableInfo(
-                                sourceStream,
-                                sourceTableInfo.getCatalogTables(),
-                                
ReadonlyConfig.fromConfig(pluginConfig).get(PLUGIN_OUTPUT)));
-            }
-        }
-        return sources;
-    }
-
-    @Override
-    protected List<SourceTableInfo> initializePlugins(
-            List<URL> jarPaths, List<? extends Config> pluginConfigs) {
-        SeaTunnelFactoryDiscovery factoryDiscovery =
-                new SeaTunnelFactoryDiscovery(TableSourceFactory.class, 
ADD_URL_TO_CLASSLOADER);
-        SeaTunnelSourcePluginDiscovery sourcePluginDiscovery =
-                new SeaTunnelSourcePluginDiscovery(ADD_URL_TO_CLASSLOADER);
-        Function<PluginIdentifier, SeaTunnelSource> fallbackCreateSource =
-                sourcePluginDiscovery::createPluginInstance;
-
-        List<SourceTableInfo> sources = new ArrayList<>();
-        Set<URL> jars = new HashSet<>();
-        for (Config sourceConfig : pluginConfigs) {
-            PluginIdentifier pluginIdentifier =
-                    PluginIdentifier.of(
-                            EngineType.SEATUNNEL.getEngine(),
-                            PluginType.SOURCE.getType(),
-                            sourceConfig.getString(PLUGIN_NAME.key()));
-            jars.addAll(
-                    sourcePluginDiscovery.getPluginJarAndDependencyPaths(
-                            Lists.newArrayList(pluginIdentifier)));
-
-            Tuple2<SeaTunnelSource<Object, SourceSplit, Serializable>, 
List<CatalogTable>> source =
-                    FactoryUtil.createAndPrepareSource(
-                            ReadonlyConfig.fromConfig(sourceConfig),
-                            classLoader,
-                            pluginIdentifier.getPluginName(),
-                            fallbackCreateSource,
-                            (TableSourceFactory)
-                                    factoryDiscovery
-                                            
.createOptionalPluginInstance(pluginIdentifier)
-                                            .orElse(null),
-                            null);
-
-            source._1().setJobContext(jobContext);
-            ensureJobModeMatch(jobContext, source._1());
-
-            sources.add(new SourceTableInfo(source._1(), source._2()));
-        }
-        jarPaths.addAll(jars);
-        return sources;
-    }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change.conf
index 9dd595f547..7ffbf1c1c6 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change.conf
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change.conf
@@ -20,7 +20,7 @@
 
 env {
   # You can set engine configuration here
-  parallelism = 1
+  parallelism = 5
   job.mode = "STREAMING"
   checkpoint.interval = 5000
   read_limit.bytes_per_second=7000000
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
index 09c88f3120..69816a41b9 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_flink_schema_change_exactly_once.conf
@@ -20,7 +20,7 @@
 
 env {
   # You can set engine configuration here
-  parallelism = 1
+  parallelism = 5
   job.mode = "STREAMING"
   checkpoint.interval = 5000
   read_limit.bytes_per_second=7000000
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-13/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator13.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-13/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator13.java
new file mode 100644
index 0000000000..3430c46b61
--- /dev/null
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-13/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator13.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.translation.flink.schema;
+
+import org.apache.seatunnel.shade.com.typesafe.config.Config;
+
+import org.apache.seatunnel.api.source.SupportSchemaEvolution;
+
+import org.apache.flink.streaming.runtime.tasks.ProcessingTimeCallback;
+import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Flink 1.13-specific extension of {@link SchemaOperator} that resolves two 
issues present when the
+ * fallback timer is placed in the common module:
+ *
+ * <ol>
+ *   <li><b>No reflection</b>: {@link ProcessingTimeService} and {@link 
ProcessingTimeCallback} are
+ *       imported directly as strongly-typed Flink 1.13 APIs. There is no risk 
of silent breakage
+ *       from method renames in future Flink versions.
+ *   <li><b>No dead flag path</b>: the timer callback fires on the Flink 
<em>task thread</em> via
+ *       {@code ProcessingTimeService.registerTimer}, so {@link 
#handleFallbackTimerOnTaskThread()}
+ *       is always reachable even when no more source data arrives and {@code 
processElement} is
+ *       never called again. This is the exact scenario this workaround 
targets on Flink 1.13.
+ * </ol>
+ *
+ * <p>The base {@link SchemaOperator} carries none of this timer 
infrastructure; Flink 1.15 and
+ * later use that base class directly because checkpointing behaves correctly 
there.
+ */
+@Slf4j
+public class SchemaOperator13 extends SchemaOperator {
+
+    /**
+     * Guards against double-registration. All accesses happen on the Flink 
task thread
+     * (processElement, timer callbacks, notifyCheckpointComplete)
+     */
+    private boolean fallbackTimerPending = false;
+
+    public SchemaOperator13(String jobId, SupportSchemaEvolution source, 
Config pluginConfig) {
+        super(jobId, source, pluginConfig);
+    }
+
+    /**
+     * Registers a processing-time timer that will call {@link 
#handleFallbackTimerOnTaskThread()}
+     * on the Flink task thread after {@link #CHECKPOINT_STALL_TIMEOUT_MS} 
milliseconds.
+     *
+     * <p>Using {@link ProcessingTimeService#registerTimer} instead of a 
background {@code
+     * ScheduledExecutorService} achieves two goals:
+     *
+     * <ul>
+     *   <li>The callback is delivered on the task thread, so {@code 
output.collect} and operator
+     *       state are accessed safely without additional synchronisation.
+     *   <li>No daemon thread overhead is introduced for Flink 1.14+ users who 
use the common
+     *       module's no-op default.
+     * </ul>
+     *
+     * <p>If a timer is already pending this call is a no-op to prevent 
duplicate firings.
+     */
+    @Override
+    protected void scheduleFallbackTimer() {
+        if (fallbackTimerPending) {
+            return;
+        }
+        fallbackTimerPending = true;
+
+        ProcessingTimeService pts = getProcessingTimeService();
+        long fireAt = pts.getCurrentProcessingTime() + 
CHECKPOINT_STALL_TIMEOUT_MS;
+
+        pts.registerTimer(
+                fireAt,
+                (ProcessingTimeCallback)
+                        timestamp -> {
+                            fallbackTimerPending = false;
+                            try {
+                                handleFallbackTimerOnTaskThread();
+                            } catch (InterruptedException e) {
+                                Thread.currentThread().interrupt();
+                                log.error(
+                                        "Fallback schema-change timer 
interrupted for job {}",
+                                        jobId,
+                                        e);
+                            }
+                        });
+
+        log.debug(
+                "Registered Flink processing-time fallback timer to fire in 
{}ms for job {}",
+                CHECKPOINT_STALL_TIMEOUT_MS,
+                jobId);
+    }
+}
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
index 0f993dcda0..0ce26072b3 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/SchemaOperator.java
@@ -1,12 +1,12 @@
 /*
  * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
+ * 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
+ * the License. You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ * 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,
@@ -54,7 +54,7 @@ import java.util.concurrent.ConcurrentHashMap;
 /**
  * Operator placed after the source to handle schema evolution.
  *
- * <p>schema change events are NOT processed synchronously in {@link 
#processElement}. Instead, they
+ * <p>Schema change events are NOT processed synchronously in {@link 
#processElement}. Instead, they
  * are buffered and deferred until an additional checkpoint cycle has 
completed after the first
  * checkpoint that observed the pending DDL. This wait ensures that when the 
sink executes ALTER
  * TABLE, all XA transactions from prior checkpoint cycles have been fully 
committed by the {@code
@@ -64,6 +64,13 @@ import java.util.concurrent.ConcurrentHashMap;
  *
  * <p>Per checkpoint cycle, at most ONE schema change is applied. If multiple 
DDLs arrive between
  * two checkpoints, they are processed across successive checkpoint cycles.
+ *
+ * <p>Flink 1.13 cannot continue checkpointing after some source subtasks have 
finished. When
+ * high-parallelism CDC jobs hit that condition, pending schema changes would 
otherwise stay blocked
+ * forever. Subclasses may override {@link #scheduleFallbackTimer()} to 
register a version-specific
+ * timer that detects the stall and re-enters the task thread via {@link
+ * #handleFallbackTimerOnTaskThread()} so the deferred DDL can still be 
applied safely. The base
+ * implementation is a no-op, keeping the common module free of 
version-specific overhead.
  */
 @Slf4j
 public class SchemaOperator extends AbstractStreamOperator<SeaTunnelRow>
@@ -73,8 +80,14 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
     private static final long SCHEMA_CHANGE_TIMEOUT_MS = 300_000L;
     private static final int CHECKPOINT_WAIT_ROUNDS = 1;
 
+    /** Exposed to subclasses so version-specific fallback timers can use the 
same threshold. */
+    protected static final long CHECKPOINT_STALL_TIMEOUT_MS = 15_000L;
+
     private final Map<TableIdentifier, CatalogTable> localSchemaState;
-    private String jobId;
+
+    /** Exposed to subclasses for logging only. */
+    protected String jobId;
+
     private final SupportSchemaEvolution source;
     private final Config pluginConfig;
     private volatile Long lastProcessedEventTime;
@@ -83,6 +96,13 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
     private volatile boolean schemaChangePending = false;
     private long firstSeenCheckpointId = -1L;
 
+    /**
+     * Timestamp of the most recently completed checkpoint. Updated in {@link
+     * #notifyCheckpointComplete} and read by {@link 
#handleFallbackTimerOnTaskThread} to detect
+     * whether checkpoints have stalled.
+     */
+    protected volatile long lastCheckpointCompletedMs = -1L;
+
     private transient ListState<SchemaStateEntry> localSchemaStateStore;
     private transient ListState<Long> lastProcessedEventTimeState;
     private transient ListState<Boolean> schemaChangePendingState;
@@ -116,7 +136,8 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
     }
 
     @Override
-    public void processElement(StreamRecord<SeaTunnelRow> streamRecord) {
+    public void processElement(StreamRecord<SeaTunnelRow> streamRecord)
+            throws InterruptedException {
         SeaTunnelRow element = streamRecord.getValue();
 
         if (!isSchemaEvolutionEnabled(pluginConfig)) {
@@ -129,7 +150,7 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
                 && element.getOptions() != null) {
             Object object = element.getOptions().get("schema_change_event");
             if (object instanceof SchemaChangeEvent) {
-                handleSchemaChangeDetected((SchemaChangeEvent) object, 
streamRecord.getTimestamp());
+                handleSchemaChangeDetected((SchemaChangeEvent) object);
                 return;
             }
         }
@@ -143,7 +164,7 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
         output.collect(streamRecord);
     }
 
-    private void handleSchemaChangeDetected(SchemaChangeEvent event, long 
timestamp) {
+    private void handleSchemaChangeDetected(SchemaChangeEvent event) {
         List<SchemaChangeType> supportedTypes = source.supports();
         if (supportedTypes == null || supportedTypes.isEmpty()) {
             log.info("Source does not support any schema change types, 
skipping");
@@ -166,6 +187,7 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
 
         pendingQueue.add(BufferedRecord.schemaChange(event));
         schemaChangePending = true;
+        scheduleFallbackTimer();
     }
 
     private void enqueueDataRecord(SeaTunnelRow row, long timestamp) {
@@ -197,12 +219,12 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
      * ensure safety:
      *
      * <ul>
-     *   <li><b>first time seeing the DDL: record {@link 
#firstSeenCheckpointId} but do NOT
-     *       broadcast the DDL yet. At this point the {@code 
FlinkGlobalCommitter} may still be
-     *       running {@code XA COMMIT} for this checkpoint's prepared 
transactions, holding MDL
-     *       locks on the sink table.
-     *   <li><b>{@code checkpointId >= firstSeenCheckpointId + 
CHECKPOINT_WAIT_ROUNDS} : the XA
-     *       COMMIT from the earlier checkpoint cycle is guaranteed to have 
finished (at least one
+     *   <li>First time seeing the DDL: record {@link #firstSeenCheckpointId} 
but do NOT broadcast
+     *       the DDL yet. At this point the {@code FlinkGlobalCommitter} may 
still be running {@code
+     *       XA COMMIT} for this checkpoint's prepared transactions, holding 
MDL locks on the sink
+     *       table.
+     *   <li>{@code checkpointId >= firstSeenCheckpointId + 
CHECKPOINT_WAIT_ROUNDS}: the XA COMMIT
+     *       from the earlier checkpoint cycle is guaranteed to have finished 
(at least one
      *       additional checkpoint cycle has completed, which implies the 
committer ran). The sink's
      *       ALTER TABLE will not encounter MDL lock, it is now safe to 
broadcast the DDL.
      * </ul>
@@ -210,20 +232,14 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
     @Override
     public void notifyCheckpointComplete(long checkpointId) throws Exception {
         super.notifyCheckpointComplete(checkpointId);
+        lastCheckpointCompletedMs = System.currentTimeMillis();
 
         if (!schemaChangePending || pendingQueue.isEmpty()) {
             return;
         }
 
-        BufferedRecord head = pendingQueue.peek();
-        while (head != null && !head.isSchemaChange) {
-            output.collect(new StreamRecord<>(head.row, head.timestamp));
-            pendingQueue.poll();
-            head = pendingQueue.peek();
-        }
+        BufferedRecord head = advancePastDataRecords();
         if (head == null) {
-            schemaChangePending = false;
-            firstSeenCheckpointId = -1L;
             return;
         }
 
@@ -266,6 +282,96 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
                 tableId,
                 eventTime);
 
+        applyNextPendingSchemaChange();
+    }
+
+    /**
+     * Handles a checkpoint-stall fallback on the task thread. Must be called 
from the Flink task
+     * thread (e.g. via {@code ProcessingTimeService.registerTimer} callback) 
to keep {@code
+     * output.collect} and operator state accesses thread-safe.
+     *
+     * <p>Safety fence: the DDL is applied only when at least one checkpoint 
has already completed
+     * after the schema event ({@code firstSeenCheckpointId >= 0}). This 
preserves the guarantee
+     * that XA transactions from the earlier checkpoint cycle have finished 
before ALTER TABLE runs.
+     * If that fence has not been crossed yet, the fallback reschedules itself 
by calling {@link
+     * #scheduleFallbackTimer()} and returns without applying anything.
+     */
+    protected void handleFallbackTimerOnTaskThread() throws 
InterruptedException {
+        if (!schemaChangePending || pendingQueue.isEmpty()) {
+            return;
+        }
+
+        if (lastCheckpointCompletedMs > 0
+                && System.currentTimeMillis() - lastCheckpointCompletedMs
+                        < CHECKPOINT_STALL_TIMEOUT_MS) {
+            scheduleFallbackTimer();
+            return;
+        }
+
+        BufferedRecord head = advancePastDataRecords();
+        if (head == null) {
+            return;
+        }
+
+        if (firstSeenCheckpointId < 0) {
+            log.info(
+                    "Fallback timer fired but no checkpoint has completed 
after schema event "
+                            + "for table {} (epoch {}). Rescheduling fallback 
to preserve "
+                            + "checkpoint-completion safety fence.",
+                    head.schemaEvent.tableIdentifier(),
+                    head.schemaEvent.getCreatedTime());
+            scheduleFallbackTimer();
+            return;
+        }
+
+        log.warn(
+                "Checkpoint stall detected after first post-DDL checkpoint {}. 
"
+                        + "Applying deferred DDL for table {} (epoch {}) via 
fallback timer. "
+                        + "Note: data committed via normal Flink checkpoint 
lifecycle may be "
+                        + "delayed until checkpoints resume.",
+                firstSeenCheckpointId,
+                head.schemaEvent.tableIdentifier(),
+                head.schemaEvent.getCreatedTime());
+
+        applyNextPendingSchemaChange();
+    }
+
+    /**
+     * Schedules a fallback timer that will call {@link 
#handleFallbackTimerOnTaskThread()} if
+     * checkpoints stall before the pending schema change can be applied.
+     *
+     * <p>The base implementation is a no-op: version-specific subclasses 
(e.g. {@code
+     * SchemaOperator13}) override this to register a timer via {@code 
ProcessingTimeService},
+     * keeping the common module free of version-specific timer infrastructure 
and reflection.
+     */
+    protected void scheduleFallbackTimer() {
+        // no-op by default; overridden in version-specific subclasses
+    }
+
+    private BufferedRecord advancePastDataRecords() {
+        BufferedRecord head = pendingQueue.peek();
+        while (head != null && !head.isSchemaChange) {
+            output.collect(new StreamRecord<>(head.row, head.timestamp));
+            pendingQueue.poll();
+            head = pendingQueue.peek();
+        }
+        if (head == null) {
+            schemaChangePending = false;
+            firstSeenCheckpointId = -1L;
+        }
+        return head;
+    }
+
+    private void applyNextPendingSchemaChange() throws InterruptedException {
+        BufferedRecord head = pendingQueue.peek();
+        if (head == null || !head.isSchemaChange) {
+            return;
+        }
+
+        SchemaChangeEvent event = head.schemaEvent;
+        TableIdentifier tableId = event.tableIdentifier();
+        long eventTime = event.getCreatedTime();
+
         if (lastProcessedEventTime != null && eventTime <= 
lastProcessedEventTime) {
             log.warn(
                     "Skipping outdated schema change event (epoch {} <= last 
processed {})",
@@ -294,7 +400,6 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
                 "Schema change for table {} (epoch {}) confirmed by all sink 
subtasks.",
                 tableId,
                 eventTime);
-
         pendingQueue.poll();
         firstSeenCheckpointId = -1L;
 
@@ -323,6 +428,7 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
                         "Released {} buffered data records. Another schema 
change pending, "
                                 + "waiting for next checkpoint.",
                         released);
+                scheduleFallbackTimer();
                 return;
             }
             pendingQueue.poll();
@@ -474,11 +580,6 @@ public class SchemaOperator extends 
AbstractStreamOperator<SeaTunnelRow>
         output.collect(new StreamRecord<>(broadcastRow));
     }
 
-    @Override
-    public void close() throws Exception {
-        super.close();
-    }
-
     static class BufferedRecord {
         final boolean isSchemaChange;
         final SeaTunnelRow row;
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
index 8d99f433a3..3cd96d43dc 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/schema/coordinator/LocalSchemaCoordinator.java
@@ -41,6 +41,12 @@ import java.util.concurrent.atomic.AtomicBoolean;
  * Local coordinator for schema change synchronization. This coordinator only 
manages temporary
  * communication between SchemaOperator and sink subtasks. All persistent 
state is managed by
  * BroadcastSchemaSinkOperator in Flink State.
+ *
+ * <p>Schema changes (DDL like ALTER TABLE) are database-level operations that 
only need to be
+ * executed once. In Flink's parallel execution model, SchemaOperator sends 
schema change events via
+ * output.collect() which routes to only ONE downstream subtask based on 
partitioning. Therefore,
+ * this coordinator completes schema change requests when ANY single subtask 
successfully applies
+ * the change, rather than waiting for all subtasks.
  */
 @Slf4j
 public class LocalSchemaCoordinator {
@@ -138,24 +144,27 @@ public class LocalSchemaCoordinator {
                 remaining,
                 jobId);
 
+        // Check if any pending requests can now be completed
+        // (Since we only need 1 ACK for DDL, this typically won't change 
anything,
+        // but we keep it for edge cases where all subtasks close before any 
ACK)
         for (Map.Entry<String, TimestampedPendingRequest> entry : 
pendingRequests.entrySet()) {
             String key = entry.getKey();
             TimestampedPendingRequest request = entry.getValue();
             Set<Integer> applied = receivedAcks.get(key);
 
-            int expectedActive = Math.max(remaining, 1);
-            if (applied != null && applied.size() >= expectedActive) {
+            // If we already have at least 1 ACK, complete the request
+            if (applied != null && !applied.isEmpty()) {
                 if (request.appliedPhaseCompleteAtomic.compareAndSet(false, 
true)) {
-                    boolean allSuccess = request.allSuccess.get();
-                    request.future.complete(allSuccess);
+                    boolean success = request.allSuccess.get();
+                    request.future.complete(success);
                     log.info(
-                            "After subtask {} unregistered, all {} active 
subtasks have applied "
-                                    + "schema change for table {} (epoch {}). 
Completing request with result: {}",
+                            "After subtask {} unregistered, completing schema 
change request for "
+                                    + "table {} (epoch {}) with {} ACK(s). 
Result: {}",
                             subtaskId,
-                            expectedActive,
                             request.tableId,
                             request.epoch,
-                            allSuccess);
+                            applied.size(),
+                            success);
                 }
             }
         }
@@ -218,8 +227,8 @@ public class LocalSchemaCoordinator {
     public boolean requestSchemaChange(TableIdentifier tableId, long epoch, 
long timeoutMs)
             throws InterruptedException, SchemaCoordinationException {
         String key = tableId.toString() + "#" + epoch;
-        int expectedAcks = activeSinkSubtasks.size();
-        if (expectedAcks == 0) {
+        int totalSubtasks = activeSinkSubtasks.size();
+        if (totalSubtasks == 0) {
             log.warn(
                     "No active sink subtasks. Cannot coordinate schema change 
for table {} (epoch {}). "
                             + "Assuming success to avoid deadlock.",
@@ -227,11 +236,25 @@ public class LocalSchemaCoordinator {
                     epoch);
             return true;
         }
+        // Schema changes (DDL) are database-level operations that only need 
to execute once.
+        // Due to Flink's partitioning, only one subtask receives the schema 
change event,
+        // so we only need 1 ACK to confirm the DDL was applied successfully.
+        //
+        // Precondition: sink subtasks that do NOT receive the schema-change 
event directly
+        // (because Flink's partitioning routed it elsewhere) must have their 
local schema
+        // view refreshed through BroadcastSchemaSinkOperator's 
broadcast/state path.
+        // If that broadcast path is incomplete, those subtasks will silently 
apply the old
+        // schema to new-format rows — a data-corruption risk. Any change to 
the broadcast
+        // path must preserve this invariant, and a multi-table (≥2 tables, 
parallelism ≥2)
+        // E2E test should guard it so regressions are caught immediately.
+        int expectedAcks = 1;
         log.info(
-                "Requesting schema change for table {} (epoch {}). Waiting for 
all {} sink subtasks to apply after checkpoint completion.",
+                "Requesting schema change for table {} (epoch {}). Waiting for 
at least {} of {} "
+                        + "sink subtasks to apply the DDL (database-level 
operation).",
                 tableId,
                 epoch,
-                expectedAcks);
+                expectedAcks,
+                totalSubtasks);
 
         long now = System.currentTimeMillis();
         TimestampedPendingRequest request =
@@ -312,31 +335,42 @@ public class LocalSchemaCoordinator {
         }
 
         appliedSubtasks.add(subtaskId);
-        int currentExpected = Math.min(request.expectedAcks, 
activeSinkSubtasks.size());
-        currentExpected = Math.max(currentExpected, 1);
+        // Schema changes only need 1 successful application since they're 
database-level operations
+        int requiredAcks = request.expectedAcks; // This is now 1
         log.info(
-                "Subtask {} applied schema change for table {} (epoch {}), 
success: {}. {}/{} subtasks applied.",
+                "Subtask {} applied schema change for table {} (epoch {}), 
success: {}. "
+                        + "{} subtask(s) applied (need {} for completion).",
                 subtaskId,
                 tableId,
                 epoch,
                 success,
                 appliedSubtasks.size(),
-                currentExpected);
+                requiredAcks);
 
         if (!success) {
             request.allSuccess.set(false);
         }
 
-        if (appliedSubtasks.size() >= currentExpected) {
+        // Complete when we have at least 1 successful ACK (DDL only needs to 
run once)
+        if (appliedSubtasks.size() >= requiredAcks && success) {
             if (request.appliedPhaseCompleteAtomic.compareAndSet(false, true)) 
{
-                boolean allSuccess = request.allSuccess.get();
-                request.future.complete(allSuccess);
+                request.future.complete(true);
                 log.info(
-                        "All {} active subtasks have applied schema change for 
table {} (epoch {}). Completing request with result: {}",
-                        currentExpected,
+                        "Schema change for table {} (epoch {}) successfully 
applied by subtask {}. "
+                                + "DDL execution complete (database-level 
operation).",
+                        tableId,
+                        epoch,
+                        subtaskId);
+            }
+        } else if (appliedSubtasks.size() >= requiredAcks && !success) {
+            // If the only ACK we got was a failure, complete with failure
+            if (request.appliedPhaseCompleteAtomic.compareAndSet(false, true)) 
{
+                request.future.complete(false);
+                log.error(
+                        "Schema change for table {} (epoch {}) failed on 
subtask {}.",
                         tableId,
                         epoch,
-                        allSuccess);
+                        subtaskId);
             }
         }
     }
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReader.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReader.java
index 4c2a7b6d2e..51bc49ad4c 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReader.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReader.java
@@ -49,6 +49,8 @@ import java.util.stream.Collectors;
 public class FlinkSourceReader<SplitT extends SourceSplit>
         implements SourceReader<SeaTunnelRow, SplitWrapper<SplitT>> {
 
+    private static final String SOURCE_KEEP_ALIVE_CONFIG = 
"schema-changes.source-keep-alive";
+
     private final Logger LOGGER = 
LoggerFactory.getLogger(FlinkSourceReader.class);
 
     private final org.apache.seatunnel.api.source.SourceReader<SeaTunnelRow, 
SplitT> sourceReader;
@@ -65,6 +67,8 @@ public class FlinkSourceReader<SplitT extends SourceSplit>
 
     private final ScheduledExecutorService scheduledExecutor;
 
+    private final boolean sourceKeepAliveEnabled;
+
     public FlinkSourceReader(
             org.apache.seatunnel.api.source.SourceReader<SeaTunnelRow, SplitT> 
sourceReader,
             org.apache.seatunnel.api.source.SourceReader.Context context,
@@ -81,6 +85,9 @@ public class FlinkSourceReader<SplitT extends SourceSplit>
         this.sourceReader = sourceReader;
         this.context = context;
         this.flinkRowCollector = new FlinkRowCollector(envConfig, 
context.getMetricsContext());
+        this.sourceKeepAliveEnabled =
+                envConfig.hasPath(SOURCE_KEEP_ALIVE_CONFIG)
+                        && envConfig.getBoolean(SOURCE_KEEP_ALIVE_CONFIG);
     }
 
     @Override
@@ -108,8 +115,11 @@ public class FlinkSourceReader<SplitT extends SourceSplit>
                 return InputStatus.NOTHING_AVAILABLE;
             }
         } else {
-            // reduce CPU idle
-            Thread.sleep(DEFAULT_WAIT_TIME_MILLIS);
+            if (sourceKeepAliveEnabled) {
+                // Flink 1.13 requires idle source subtasks to stay alive so 
checkpoints continue.
+                Thread.sleep(DEFAULT_WAIT_TIME_MILLIS);
+                return InputStatus.NOTHING_AVAILABLE;
+            }
         }
         return inputStatus;
     }
@@ -132,6 +142,12 @@ public class FlinkSourceReader<SplitT extends SourceSplit>
 
     @Override
     public void addSplits(List<SplitWrapper<SplitT>> splits) {
+        if (!splits.isEmpty() && context instanceof FlinkSourceReaderContext) {
+            if (sourceKeepAliveEnabled) {
+                ((FlinkSourceReaderContext) context).resetNoMoreElementEvent();
+                inputStatus = InputStatus.MORE_AVAILABLE;
+            }
+        }
         sourceReader.addSplits(
                 
splits.stream().map(SplitWrapper::getSourceSplit).collect(Collectors.toList()));
     }
@@ -144,7 +160,8 @@ public class FlinkSourceReader<SplitT extends SourceSplit>
     @Override
     public void handleSourceEvents(SourceEvent sourceEvent) {
         if (sourceEvent instanceof NoMoreElementEvent) {
-            inputStatus = InputStatus.END_OF_INPUT;
+            inputStatus =
+                    sourceKeepAliveEnabled ? InputStatus.MORE_AVAILABLE : 
InputStatus.END_OF_INPUT;
         }
         if (sourceEvent instanceof SourceEventWrapper) {
             sourceReader.handleSourceEvent((((SourceEventWrapper) 
sourceEvent).getSourceEvent()));
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReaderContext.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReaderContext.java
index 2b20e0d404..458b240d4d 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReaderContext.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/main/java/org/apache/seatunnel/translation/flink/source/FlinkSourceReaderContext.java
@@ -101,6 +101,10 @@ public class FlinkSourceReaderContext implements 
SourceReader.Context {
         return isSendNoMoreElementEvent.get();
     }
 
+    public void resetNoMoreElementEvent() {
+        isSendNoMoreElementEvent.set(false);
+    }
+
     @Override
     public EventListener getEventListener() {
         return eventListener;
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
index fd3b7d2126..f187355696 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/schema/SchemaOperatorTest.java
@@ -176,6 +176,76 @@ public class SchemaOperatorTest {
         assertTrue(pendingQueue.peek().isSchemaChange);
     }
 
+    /**
+     * Verifies that {@link SchemaOperator#handleFallbackTimerOnTaskThread()} 
correctly respects the
+     * checkpoint-completion safety fence even when called from a 
stall-detection timer.
+     *
+     * <p>The test invokes the handler directly (as if a processing-time timer 
fired) to keep the
+     * unit test independent of Flink's timer infrastructure. In production, 
the handler is called
+     * by {@link SchemaOperator13#scheduleFallbackTimer()} via {@code
+     * ProcessingTimeService.registerTimer}.
+     *
+     * <p>The base {@link SchemaOperator#scheduleFallbackTimer()} is a no-op; 
this test verifies
+     * only the handler logic, not the scheduling mechanism.
+     */
+    @Test
+    void testFallbackTimerRespectsCheckpointSafetyFence() throws Exception {
+        LocalSchemaCoordinator coordinator = 
Mockito.mock(LocalSchemaCoordinator.class);
+        Mockito.when(
+                        coordinator.requestSchemaChange(
+                                Mockito.any(), Mockito.anyLong(), 
Mockito.anyLong()))
+                .thenReturn(true);
+
+        OperatorTestContext context = createOperator(false);
+        setField(context.operator, "coordinator", coordinator);
+
+        AlterTableAddColumnEvent event = createSchemaChangeEvent();
+        SeaTunnelRow row = createDataRow("row-released-after-fallback");
+
+        context.operator.processElement(new 
StreamRecord<>(createSchemaRow(event), 400L));
+        context.operator.processElement(new StreamRecord<>(row, 401L));
+
+        // Simulate timer firing before any checkpoint has completed 
(firstSeenCheckpointId < 0).
+        // The handler must NOT apply the DDL — it must call 
scheduleFallbackTimer() to wait for
+        // the checkpoint-completion safety fence (guards XA/MDL conflicts).
+        invokeNoArgMethod(context.operator, "handleFallbackTimerOnTaskThread");
+
+        assertTrue(context.output.records.isEmpty());
+        assertTrue(getBooleanField(context.operator, "schemaChangePending"));
+        assertEquals(2, getPendingQueue(context.operator).size());
+        assertEquals(-1L, getLongField(context.operator, 
"firstSeenCheckpointId"));
+        Mockito.verifyNoInteractions(coordinator);
+
+        // Complete the first post-DDL checkpoint — sets 
firstSeenCheckpointId, not yet safe to
+        // apply (need one additional round, so notifyCheckpointComplete stops 
here).
+        context.operator.notifyCheckpointComplete(40L);
+
+        assertTrue(context.output.records.isEmpty());
+        assertEquals(40L, getLongField(context.operator, 
"firstSeenCheckpointId"));
+        assertTrue(getBooleanField(context.operator, "schemaChangePending"));
+        Mockito.verifyNoInteractions(coordinator);
+
+        // Simulate checkpoint stall: move lastCheckpointCompletedMs into the 
past beyond
+        // CHECKPOINT_STALL_TIMEOUT_MS (15 s). This mirrors the Flink 1.13 
behaviour where
+        // high-parallelism CDC jobs stop checkpointing after some source 
subtasks finish.
+        setField(
+                context.operator,
+                "lastCheckpointCompletedMs",
+                System.currentTimeMillis() - 20_000L);
+
+        // Simulate timer firing again. firstSeenCheckpointId >= 0 and 
checkpoint has stalled,
+        // so the safety fence is satisfied — the DDL can now be applied.
+        invokeNoArgMethod(context.operator, "handleFallbackTimerOnTaskThread");
+
+        assertEquals(2, context.output.records.size());
+        assertSchemaBroadcast(context.output.records.get(0), event);
+        assertEquals(row, context.output.records.get(1).getValue());
+        assertFalse(getBooleanField(context.operator, "schemaChangePending"));
+        assertTrue(getPendingQueue(context.operator).isEmpty());
+        Mockito.verify(coordinator)
+                .requestSchemaChange(event.tableIdentifier(), 
event.getCreatedTime(), 300_000L);
+    }
+
     private static OperatorTestContext createOperator(boolean restored) throws 
Exception {
         return createOperator(new OperatorStateStoreStub(), restored);
     }
@@ -285,6 +355,12 @@ public class SchemaOperatorTest {
         field.set(target, value);
     }
 
+    private static Object invokeNoArgMethod(Object target, String methodName) 
throws Exception {
+        java.lang.reflect.Method method = 
target.getClass().getDeclaredMethod(methodName);
+        method.setAccessible(true);
+        return method.invoke(target);
+    }
+
     private static Field findField(Class<?> type, String fieldName) throws 
NoSuchFieldException {
         Class<?> current = type;
         while (current != null) {
diff --git 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriterTest.java
 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriterTest.java
index 50be1a39d2..943d48b43d 100644
--- 
a/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriterTest.java
+++ 
b/seatunnel-translation/seatunnel-translation-flink/seatunnel-translation-flink-common/src/test/java/org/apache/seatunnel/translation/flink/sink/FlinkSinkWriterTest.java
@@ -20,6 +20,10 @@ package org.apache.seatunnel.translation.flink.sink;
 import org.apache.seatunnel.api.common.metrics.MetricsContext;
 import org.apache.seatunnel.api.event.EventListener;
 import org.apache.seatunnel.api.sink.SinkWriter;
+import org.apache.seatunnel.api.sink.SupportSchemaEvolutionSinkWriter;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 
 import org.junit.jupiter.api.Assertions;
@@ -28,7 +32,9 @@ import org.junit.jupiter.api.Test;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 
 class FlinkSinkWriterTest {
@@ -83,13 +89,54 @@ class FlinkSinkWriterTest {
         Assertions.assertEquals("state-3", states.get(0).getState());
     }
 
+    @Test
+    void testSchemaChangeEventDoesNotForceCommit() throws Exception {
+        SchemaAwareRecordingSinkWriter delegate = new 
SchemaAwareRecordingSinkWriter();
+        RecordingContext context = new RecordingContext();
+
+        FlinkSinkWriter<SeaTunnelRow, String, String> flinkSinkWriter =
+                new FlinkSinkWriter<>(delegate, 7L, context);
+
+        AlterTableAddColumnEvent event =
+                AlterTableAddColumnEvent.add(
+                        TableIdentifier.of("catalog", "database", "table"),
+                        PhysicalColumn.of(
+                                "added_col",
+                                
org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE,
+                                64L,
+                                true,
+                                null,
+                                null));
+        event.setJobId("job-under-test");
+        SeaTunnelRow schemaEvent = new SeaTunnelRow(0);
+        Map<String, Object> options = new LinkedHashMap<>();
+        options.put("schema_change_event", event);
+        options.put("schema_subtask_id", 0L);
+        schemaEvent.setOptions(options);
+        flinkSinkWriter.write(schemaEvent, null);
+
+        SeaTunnelRow row = new SeaTunnelRow(1);
+        row.setField(0, "value");
+        flinkSinkWriter.write(row, null);
+
+        // Schema change should apply without forcing commit - commits happen 
via normal Flink
+        // lifecycle
+        Assertions.assertEquals(1, delegate.writtenRows.size());
+        Assertions.assertEquals(Collections.emptyList(), 
delegate.prepareCommitCalls);
+        Assertions.assertEquals(1, delegate.appliedSchemaChanges.size());
+        Assertions.assertEquals(event, delegate.appliedSchemaChanges.get(0));
+    }
+
     private static class RecordingSinkWriter implements 
SinkWriter<SeaTunnelRow, String, String> {
 
-        private final List<Long> prepareCommitCalls = new ArrayList<>();
-        private final List<Long> snapshotCalls = new ArrayList<>();
+        protected final List<Long> prepareCommitCalls = new ArrayList<>();
+        protected final List<Long> snapshotCalls = new ArrayList<>();
+        protected final List<SeaTunnelRow> writtenRows = new ArrayList<>();
 
         @Override
-        public void write(SeaTunnelRow element) throws IOException {}
+        public void write(SeaTunnelRow element) throws IOException {
+            writtenRows.add(element);
+        }
 
         @Override
         public Optional<String> prepareCommit() {
@@ -116,6 +163,19 @@ class FlinkSinkWriterTest {
         public void close() throws IOException {}
     }
 
+    private static class SchemaAwareRecordingSinkWriter extends 
RecordingSinkWriter
+            implements SupportSchemaEvolutionSinkWriter {
+
+        private final 
List<org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent>
+                appliedSchemaChanges = new ArrayList<>();
+
+        @Override
+        public void applySchemaChange(
+                org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent 
event) {
+            appliedSchemaChanges.add(event);
+        }
+    }
+
     private static class RecordingContext implements SinkWriter.Context {
 
         @Override

Reply via email to