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

dybyte 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 53c11a519e [Fix][Zeta] Job stuck permanently after master failover, 
unable to complete (affects BATCH / bounded source / job shutdown phase) 
(#10836)
53c11a519e is described below

commit 53c11a519e35bed2b260be5ad6503ec460e99eb2
Author: zhiwei.niu <[email protected]>
AuthorDate: Fri May 8 02:11:52 2026 +0800

    [Fix][Zeta] Job stuck permanently after master failover, unable to complete 
(affects BATCH / bounded source / job shutdown phase) (#10836)
    
    Co-authored-by: Doyeon Kim <[email protected]>
---
 .../e2e/CheckpointCoordinatorFailoverIT.java       | 377 ++++++++++++++++
 ...fake_to_localfile_master_failover_template.conf | 154 +++++++
 ...fake_to_localfile_master_failover_template.conf | 189 ++++++++
 .../server/checkpoint/CheckpointCoordinator.java   | 234 +++++++---
 .../seatunnel/engine/server/master/JobMaster.java  |   6 +
 .../checkpoint/CheckpointCoordinatorTest.java      | 488 +++++++++++++++++++++
 6 files changed, 1387 insertions(+), 61 deletions(-)

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
new file mode 100644
index 0000000000..a176aed193
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java
@@ -0,0 +1,377 @@
+/*
+ * 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.engine.e2e;
+
+import org.apache.seatunnel.shade.org.apache.commons.lang3.tuple.ImmutablePair;
+
+import org.apache.seatunnel.common.config.Common;
+import org.apache.seatunnel.common.config.DeployMode;
+import org.apache.seatunnel.common.utils.FileUtils;
+import org.apache.seatunnel.engine.client.SeaTunnelClient;
+import org.apache.seatunnel.engine.client.job.ClientJobExecutionEnvironment;
+import org.apache.seatunnel.engine.client.job.ClientJobProxy;
+import org.apache.seatunnel.engine.common.Constant;
+import org.apache.seatunnel.engine.common.config.ConfigProvider;
+import org.apache.seatunnel.engine.common.config.JobConfig;
+import org.apache.seatunnel.engine.common.config.SeaTunnelConfig;
+import org.apache.seatunnel.engine.common.job.JobStatus;
+import org.apache.seatunnel.engine.server.SeaTunnelServerStarter;
+import org.apache.seatunnel.engine.server.checkpoint.IMapCheckpointIDCounter;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.hazelcast.client.config.ClientConfig;
+import com.hazelcast.instance.impl.HazelcastInstanceImpl;
+import com.hazelcast.map.IMap;
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Slf4j
+public class CheckpointCoordinatorFailoverIT {
+
+    private static final String BATCH_TEMPLATE_CONF =
+            "batch_fake_to_localfile_master_failover_template.conf";
+
+    private static final String STREAM_TEMPLATE_CONF =
+            "stream_fake_to_localfile_master_failover_template.conf";
+
+    private static final String DYNAMIC_TEST_CASE_NAME = 
"dynamic_test_case_name";
+
+    /** Must match the parallelism value set in the conf templates 
(env.parallelism). */
+    private static final int SOURCE_PARALLELISM = 5;
+
+    @Test
+    public void testBatchJobCompletesAfterMasterFailover() throws Exception {
+        String testCaseName = "testBatchJobCompletesAfterMasterFailover";
+        String testClusterName =
+                
"CheckpointCoordinatorFailoverIT_testBatchJobCompletesAfterMasterFailover";
+        // Per-source row.num must match 
batch_fake_to_localfile_master_failover_template.conf.
+        // All sources use the same configuration (row.num=500) for stable 
failover timing.
+        long rowNumPerSource = 500;
+        int sourceCount = 5;
+        final long expectedTotalRows = rowNumPerSource * sourceCount * 
SOURCE_PARALLELISM;
+
+        HazelcastInstanceImpl masterNode1 = null;
+        HazelcastInstanceImpl masterNode2 = null;
+        SeaTunnelClient engineClient = null;
+
+        SeaTunnelConfig config1 = ConfigProvider.locateAndGetSeaTunnelConfig();
+        
config1.getHazelcastConfig().setClusterName(TestUtils.getClusterName(testClusterName));
+        config1.getEngineConfig().getHttpConfig().setEnabled(false);
+
+        SeaTunnelConfig config2 = ConfigProvider.locateAndGetSeaTunnelConfig();
+        
config2.getHazelcastConfig().setClusterName(TestUtils.getClusterName(testClusterName));
+        config2.getEngineConfig().getHttpConfig().setEnabled(false);
+
+        try {
+            masterNode1 = 
SeaTunnelServerStarter.createHazelcastInstance(config1);
+            masterNode2 = 
SeaTunnelServerStarter.createHazelcastInstance(config2);
+
+            HazelcastInstanceImpl finalMaster1 = masterNode1;
+            Awaitility.await()
+                    .atMost(10, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            2, 
finalMaster1.getCluster().getMembers().size()));
+
+            Common.setDeployMode(DeployMode.CLUSTER);
+            ImmutablePair<String, String> testResources =
+                    createTestResources(testCaseName, BATCH_TEMPLATE_CONF);
+            JobConfig jobConfig = new JobConfig();
+            jobConfig.setName(testCaseName);
+
+            ClientConfig clientConfig = 
ConfigProvider.locateAndGetClientConfig();
+            
clientConfig.setClusterName(TestUtils.getClusterName(testClusterName));
+            engineClient = new SeaTunnelClient(clientConfig);
+            ClientJobExecutionEnvironment jobExecutionEnv =
+                    engineClient.createExecutionContext(
+                            testResources.getRight(), jobConfig, config1);
+            ClientJobProxy clientJobProxy = jobExecutionEnv.execute();
+
+            long jobId = clientJobProxy.getJobId();
+            long triggerThreshold = expectedTotalRows / 4;
+            Awaitility.await()
+                    .atMost(3, TimeUnit.MINUTES)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertEquals(
+                                        JobStatus.RUNNING, 
clientJobProxy.getJobStatus());
+                                long observedRows =
+                                        
FileUtils.getFileLineNumberFromDir(testResources.getLeft());
+                                Assertions.assertTrue(
+                                        observedRows > triggerThreshold,
+                                        String.format(
+                                                "Waiting for sufficient output 
before failover "
+                                                        + "(rows=%d, 
threshold=%d)",
+                                                observedRows, 
triggerThreshold));
+                            });
+
+            log.info(
+                    "Job {} is RUNNING with over {} rows written. "
+                            + "Triggering master failover by shutting down 
masterNode1.",
+                    jobId,
+                    triggerThreshold);
+
+            masterNode1.shutdown();
+            masterNode1 = null;
+
+            HazelcastInstanceImpl finalMaster2 = masterNode2;
+            Awaitility.await()
+                    .atMost(1, TimeUnit.MINUTES)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            1, 
finalMaster2.getCluster().getMembers().size()));
+
+            Awaitility.await()
+                    .atMost(5, TimeUnit.MINUTES)
+                    .pollInterval(3, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                JobStatus status = 
clientJobProxy.getJobStatus();
+                                Assertions.assertTrue(
+                                        status == JobStatus.RUNNING || status 
== JobStatus.FINISHED,
+                                        "Waiting for job status to recover 
after master failover, "
+                                                + "current status: "
+                                                + status);
+                            });
+            Assertions.assertEquals(JobStatus.FINISHED, 
clientJobProxy.waitForJobComplete());
+
+            long actualRows = 
FileUtils.getFileLineNumberFromDir(testResources.getLeft());
+            Assertions.assertTrue(
+                    actualRows >= expectedTotalRows,
+                    String.format(
+                            "Expected at least %d rows after failover, but got 
%d",
+                            expectedTotalRows, actualRows));
+        } finally {
+            if (engineClient != null) {
+                engineClient.close();
+            }
+            if (masterNode1 != null) {
+                masterNode1.shutdown();
+            }
+            if (masterNode2 != null) {
+                masterNode2.shutdown();
+            }
+        }
+    }
+
+    @Test
+    public void testStreamJobContinuesAfterMasterFailover() throws Exception {
+        String testCaseName = "testStreamJobContinuesAfterMasterFailover";
+        String testClusterName =
+                
"CheckpointCoordinatorFailoverIT_testStreamJobContinuesAfterMasterFailover";
+        // Per-source row.num must match 
stream_fake_to_localfile_master_failover_template.conf.
+        long[] rowNumPerSource = {100, 150, 200, 250, 300};
+        long maxBoundedRows = 0;
+        for (long rows : rowNumPerSource) {
+            maxBoundedRows += rows * SOURCE_PARALLELISM;
+        }
+
+        HazelcastInstanceImpl masterNode1 = null;
+        HazelcastInstanceImpl masterNode2 = null;
+        SeaTunnelClient engineClient = null;
+
+        SeaTunnelConfig config1 = ConfigProvider.locateAndGetSeaTunnelConfig();
+        
config1.getHazelcastConfig().setClusterName(TestUtils.getClusterName(testClusterName));
+        config1.getEngineConfig().getHttpConfig().setEnabled(false);
+
+        SeaTunnelConfig config2 = ConfigProvider.locateAndGetSeaTunnelConfig();
+        
config2.getHazelcastConfig().setClusterName(TestUtils.getClusterName(testClusterName));
+        config2.getEngineConfig().getHttpConfig().setEnabled(false);
+
+        try {
+            masterNode1 = 
SeaTunnelServerStarter.createHazelcastInstance(config1);
+            masterNode2 = 
SeaTunnelServerStarter.createHazelcastInstance(config2);
+
+            HazelcastInstanceImpl finalMaster1 = masterNode1;
+            Awaitility.await()
+                    .atMost(10, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            2, 
finalMaster1.getCluster().getMembers().size()));
+
+            Common.setDeployMode(DeployMode.CLUSTER);
+            ImmutablePair<String, String> testResources =
+                    createTestResources(testCaseName, STREAM_TEMPLATE_CONF);
+            JobConfig jobConfig = new JobConfig();
+            jobConfig.setName(testCaseName);
+
+            ClientConfig clientConfig = 
ConfigProvider.locateAndGetClientConfig();
+            
clientConfig.setClusterName(TestUtils.getClusterName(testClusterName));
+            engineClient = new SeaTunnelClient(clientConfig);
+            ClientJobExecutionEnvironment jobExecutionEnv =
+                    engineClient.createExecutionContext(
+                            testResources.getRight(), jobConfig, config1);
+            ClientJobProxy clientJobProxy = jobExecutionEnv.execute();
+
+            long jobId = clientJobProxy.getJobId();
+
+            // Trigger failover after ~1/4 of the bounded data has been 
written; FakeSource
+            // in STREAMING mode is UNBOUNDED, so total rows are still bounded 
by row.num
+            // per split but the source itself never finishes.
+            long triggerThreshold = maxBoundedRows / 4;
+            Awaitility.await()
+                    .atMost(3, TimeUnit.MINUTES)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertEquals(
+                                        JobStatus.RUNNING, 
clientJobProxy.getJobStatus());
+                                Assertions.assertTrue(
+                                        
FileUtils.getFileLineNumberFromDir(testResources.getLeft())
+                                                > triggerThreshold);
+                            });
+
+            log.info(
+                    "Over {} rows written for streaming job {}. "
+                            + "Triggering master failover by shutting down 
masterNode1.",
+                    triggerThreshold,
+                    jobId);
+
+            masterNode1.shutdown();
+            masterNode1 = null;
+
+            HazelcastInstanceImpl finalMaster2 = masterNode2;
+            Awaitility.await()
+                    .atMost(1, TimeUnit.MINUTES)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            1, 
finalMaster2.getCluster().getMembers().size()));
+
+            Awaitility.await()
+                    .atMost(3, TimeUnit.MINUTES)
+                    .pollInterval(3, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            JobStatus.RUNNING, 
clientJobProxy.getJobStatus()));
+
+            // Verify at least one pipeline's checkpoint id strictly grows on 
the new master.
+            IMap<String, Long> ckIdMap = 
masterNode2.getMap(Constant.IMAP_CHECKPOINT_ID);
+            Map<Integer, Long> checkpointBefore = new HashMap<>();
+            Awaitility.await()
+                    .atMost(30, TimeUnit.SECONDS)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                checkpointBefore.clear();
+                                for (int pipelineId = 1;
+                                        pipelineId <= rowNumPerSource.length;
+                                        pipelineId++) {
+                                    String ckIdKey =
+                                            
IMapCheckpointIDCounter.convertLongIntToBase64(
+                                                    jobId, pipelineId);
+                                    Long value = ckIdMap.get(ckIdKey);
+                                    if (value != null) {
+                                        checkpointBefore.put(pipelineId, 
value);
+                                    }
+                                }
+                                Assertions.assertFalse(
+                                        checkpointBefore.isEmpty(),
+                                        "Waiting for checkpoint ids after 
failover");
+                            });
+
+            AtomicInteger observedPipelineId = new AtomicInteger(-1);
+            AtomicLong ckIdBefore = new AtomicLong(-1);
+            AtomicLong ckIdAfter = new AtomicLong(-1);
+            Awaitility.await()
+                    .atMost(30, TimeUnit.SECONDS)
+                    .pollInterval(1, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                boolean grew = false;
+                                for (Map.Entry<Integer, Long> entry : 
checkpointBefore.entrySet()) {
+                                    int pid = entry.getKey();
+                                    long before = entry.getValue();
+                                    String ckIdKey =
+                                            
IMapCheckpointIDCounter.convertLongIntToBase64(
+                                                    jobId, pid);
+                                    Long current = ckIdMap.get(ckIdKey);
+                                    if (current != null && current > before) {
+                                        observedPipelineId.set(pid);
+                                        ckIdBefore.set(before);
+                                        ckIdAfter.set(current);
+                                        grew = true;
+                                        break;
+                                    }
+                                }
+                                Assertions.assertTrue(
+                                        grew,
+                                        "Checkpoint id should grow after 
failover for at least"
+                                                + " one pipeline");
+                            });
+            Assertions.assertTrue(
+                    ckIdAfter.get() > ckIdBefore.get(),
+                    String.format(
+                            "Checkpoint id must continue to grow on the new 
master for at least"
+                                    + " one pipeline (pipelineId=%d, 
before=%d, after=%d)",
+                            observedPipelineId.get(), ckIdBefore.get(), 
ckIdAfter.get()));
+
+            clientJobProxy.cancelJob();
+        } finally {
+            if (engineClient != null) {
+                engineClient.close();
+            }
+            if (masterNode1 != null) {
+                masterNode1.shutdown();
+            }
+            if (masterNode2 != null) {
+                masterNode2.shutdown();
+            }
+        }
+    }
+
+    private ImmutablePair<String, String> createTestResources(
+            @NonNull String testCaseName, String templateConf) throws 
IOException {
+        Map<String, String> valueMap = new HashMap<>();
+        valueMap.put(DYNAMIC_TEST_CASE_NAME, testCaseName);
+
+        String targetDir = "/tmp/hive/warehouse/" + testCaseName;
+        targetDir = targetDir.replace("/", File.separator);
+        FileUtils.createNewDir(targetDir);
+
+        String targetConfigFilePath =
+                File.separator
+                        + "tmp"
+                        + File.separator
+                        + "test_conf"
+                        + File.separator
+                        + testCaseName
+                        + ".conf";
+        TestUtils.createTestConfigFileFromTemplate(templateConf, valueMap, 
targetConfigFilePath);
+
+        return new ImmutablePair<>(targetDir, targetConfigFilePath);
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/batch_fake_to_localfile_master_failover_template.conf
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/batch_fake_to_localfile_master_failover_template.conf
new file mode 100644
index 0000000000..e839786e59
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/batch_fake_to_localfile_master_failover_template.conf
@@ -0,0 +1,154 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+env {
+  job.mode = BATCH
+  checkpoint.interval = 2000
+  parallelism = 5
+}
+
+source {
+  FakeSource {
+    plugin_output = table1
+    row.num = 500
+    split.num = 5
+    split.read-interval = 500
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table2
+    row.num = 500
+    split.num = 5
+    split.read-interval = 500
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table3
+    row.num = 500
+    split.num = 5
+    split.read-interval = 500
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table4
+    row.num = 500
+    split.num = 5
+    split.read-interval = 500
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table5
+    row.num = 500
+    split.num = 5
+    split.read-interval = 500
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+      }
+    }
+  }
+}
+
+transform {
+}
+
+sink {
+  LocalFile {
+    plugin_input = table1
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table2
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table3
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table4
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table5
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+}
diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_master_failover_template.conf
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_master_failover_template.conf
new file mode 100644
index 0000000000..e754ec3896
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_master_failover_template.conf
@@ -0,0 +1,189 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+env {
+  job.mode = STREAMING
+  checkpoint.interval = 2000
+  parallelism = 5
+}
+
+source {
+  FakeSource {
+    plugin_output = table1
+    row.num = 100
+    split.num = 2
+    split.read-interval = 500
+    map.size = 10
+    array.size = 10
+    bytes.length = 10
+    string.length = 10
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+        c_bigint = bigint
+        c_double = double
+        c_boolean = boolean
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table2
+    row.num = 150
+    split.num = 3
+    split.read-interval = 750
+    map.size = 10
+    array.size = 10
+    bytes.length = 10
+    string.length = 10
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+        c_bigint = bigint
+        c_double = double
+        c_boolean = boolean
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table3
+    row.num = 200
+    split.num = 4
+    split.read-interval = 1000
+    map.size = 10
+    array.size = 10
+    bytes.length = 10
+    string.length = 10
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+        c_bigint = bigint
+        c_double = double
+        c_boolean = boolean
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table4
+    row.num = 250
+    split.num = 5
+    split.read-interval = 1500
+    map.size = 10
+    array.size = 10
+    bytes.length = 10
+    string.length = 10
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+        c_bigint = bigint
+        c_double = double
+        c_boolean = boolean
+      }
+    }
+  }
+
+  FakeSource {
+    plugin_output = table5
+    row.num = 300
+    split.num = 6
+    split.read-interval = 2000
+    map.size = 10
+    array.size = 10
+    bytes.length = 10
+    string.length = 10
+    schema = {
+      fields {
+        c_string = string
+        c_int    = int
+        c_bigint = bigint
+        c_double = double
+        c_boolean = boolean
+      }
+    }
+  }
+}
+
+transform {
+}
+
+sink {
+  LocalFile {
+    plugin_input = table1
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table2
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table3
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table4
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+
+  LocalFile {
+    plugin_input = table5
+    path = "/tmp/hive/warehouse/${dynamic_test_case_name}"
+    field_delimiter = "\t"
+    row_delimiter = "\n"
+    file_name_expression = "${transactionId}_${now}"
+    file_format_type = "text"
+    filename_time_format = "yyyy.MM.dd"
+    is_enable_transaction = true
+    save_mode = "error"
+  }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java
index 26329c334c..9bbc6a39f2 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java
@@ -158,6 +158,8 @@ public class CheckpointCoordinator {
 
     private final String checkpointStateImapKey;
 
+    private final String readyToCloseImapKey;
+
     @SneakyThrows
     public CheckpointCoordinator(
             CheckpointManager manager,
@@ -178,6 +180,7 @@ public class CheckpointCoordinator {
         this.jobId = jobId;
         this.pipelineId = plan.getPipelineId();
         this.checkpointStateImapKey = "checkpoint_state_" + jobId + "_" + 
pipelineId;
+        this.readyToCloseImapKey = checkpointStateImapKey + "_ready_to_close";
         this.runningJobStateIMap = runningJobStateIMap;
         this.plan = plan;
         this.coordinatorConfig = checkpointConfig;
@@ -206,18 +209,18 @@ public class CheckpointCoordinator {
         this.closedIdleTask = new CopyOnWriteArraySet<>();
 
         LOG.info(
-                "Create CheckpointCoordinator for job({}@{}) with plan({})",
-                pipelineId,
+                "Create CheckpointCoordinator, job id: {}, pipeline id: {}, 
plan: {}",
                 jobId,
+                pipelineId,
                 plan);
         if (pipelineState != null) {
             this.latestCompletedCheckpoint =
                     serializer.deserialize(pipelineState.getStates(), 
CompletedCheckpoint.class);
             this.latestCompletedCheckpoint.setRestored(true);
             LOG.info(
-                    "Restore job({}@{}) with checkpoint({}), data: {}",
-                    pipelineId,
+                    "Restore checkpoint, job id: {}, pipeline id: {}, 
checkpoint id: {}, data: {} ",
                     jobId,
+                    pipelineId,
                     latestCompletedCheckpoint.getCheckpointId(),
                     latestCompletedCheckpoint);
         }
@@ -375,7 +378,7 @@ public class CheckpointCoordinator {
         if (completedCheckpoint != null) {
             try {
                 LOG.info(
-                        "start notify checkpoint completed, job id: {}, 
pipeline id: {}, checkpoint id:{}",
+                        "start notify checkpoint completed, job id: {}, 
pipeline id: {}, checkpoint id: {}.",
                         completedCheckpoint.getJobId(),
                         completedCheckpoint.getPipelineId(),
                         completedCheckpoint.getCheckpointId());
@@ -427,18 +430,79 @@ public class CheckpointCoordinator {
 
     protected void readyToClose(TaskLocation taskLocation) {
         readyToCloseStartingTask.add(taskLocation);
+        updateReadyToCloseStartingTask();
         if (readyToCloseStartingTask.size() == 
plan.getStartingSubtasks().size()) {
             tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
         }
     }
 
+    private Set<TaskLocation> loadReadyToCloseStartingTask() {
+        try {
+            Object stored = runningJobStateIMap.get(readyToCloseImapKey);
+            if (stored instanceof Set) {
+                Set<TaskLocation> result = (Set<TaskLocation>) stored;
+                LOG.info(
+                        "Loaded readyToCloseStartingTask from IMap, job id: 
{}, pipeline id: {}, value: {}",
+                        jobId,
+                        pipelineId,
+                        result);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            LOG.error(
+                    "Failed to load readyToCloseStartingTask from IMap, job 
id: {}, pipeline id: {}.",
+                    jobId,
+                    pipelineId,
+                    e);
+            throw new RuntimeException(
+                    "Failed to load readyToCloseStartingTask from IMap, key: "
+                            + readyToCloseImapKey,
+                    e);
+        }
+    }
+
+    private void updateReadyToCloseStartingTask() {
+        try {
+            RetryUtils.retryWithException(
+                    () -> {
+                        runningJobStateIMap.compute(
+                                readyToCloseImapKey,
+                                (k, exist) -> {
+                                    Set<TaskLocation> merged =
+                                            exist instanceof Set
+                                                    ? new 
HashSet<>((Set<TaskLocation>) exist)
+                                                    : new HashSet<>();
+                                    merged.addAll(readyToCloseStartingTask);
+                                    return merged;
+                                });
+                        return null;
+                    },
+                    new RetryUtils.RetryMaterial(
+                            Constant.OPERATION_RETRY_TIME,
+                            true,
+                            ExceptionUtil::isOperationNeedRetryException,
+                            Constant.OPERATION_RETRY_SLEEP));
+        } catch (Exception e) {
+            LOG.error(
+                    "Failed to persist readyToCloseStartingTask to IMap after 
retries, key: {}."
+                            + " Failing the job to avoid an unrecoverable 
stuck state on master failover.",
+                    readyToCloseImapKey,
+                    e);
+            throw new RuntimeException(
+                    "Failed to persist readyToCloseStartingTask to IMap, key: "
+                            + readyToCloseImapKey,
+                    e);
+        }
+    }
+
     protected void readyToCloseIdleTask(TaskLocation taskLocation) {
         if (plan.getStartingSubtasks().contains(taskLocation)) {
             throw new UnsupportedOperationException("Unsupported close 
starting task");
         }
 
         LOG.info(
-                "Received close idle task[{}]({}/{}). {}",
+                "Received close idle task, task id: {}, pipeline id: {}, job 
id: {}, detail: {}",
                 taskLocation.getTaskID(),
                 taskLocation.getPipelineId(),
                 taskLocation.getJobId(),
@@ -447,7 +511,7 @@ public class CheckpointCoordinator {
             if (readyToCloseIdleTask.contains(taskLocation)
                     || closedIdleTask.contains(taskLocation)) {
                 LOG.warn(
-                        "task[{}]({}/{}) already in closed. {}",
+                        "task already in closed, task id: {}, pipeline id: {}, 
job id: {}, detail: {}",
                         taskLocation.getTaskID(),
                         taskLocation.getPipelineId(),
                         taskLocation.getJobId(),
@@ -461,7 +525,7 @@ public class CheckpointCoordinator {
                     // close all subtask in the same task group
                     subTaskList.add(subTask);
                     LOG.info(
-                            "Add task[{}]({}/{}) to prepare close list",
+                            "Add task to prepare close list, task id: {}, 
pipeline id: {}, job id: {}",
                             subTask.getTaskID(),
                             subTask.getPipelineId(),
                             subTask.getJobId());
@@ -477,7 +541,7 @@ public class CheckpointCoordinator {
                 readyToCloseIdleTask.remove(taskLocation);
                 closedIdleTask.add(taskLocation);
                 LOG.info(
-                        "Completed close task[{}]({}/{})",
+                        "Completed close task, task id: {}, pipeline id: {}, 
job id: {}",
                         taskLocation.getTaskID(),
                         taskLocation.getPipelineId(),
                         taskLocation.getJobId());
@@ -486,18 +550,38 @@ public class CheckpointCoordinator {
     }
 
     protected void restoreCoordinator(boolean alreadyStarted) {
-        LOG.info("received restore CheckpointCoordinator with alreadyStarted = 
{}", alreadyStarted);
+        LOG.info("received restore CheckpointCoordinator with alreadyStarted: 
{}", alreadyStarted);
         errorByPhysicalVertex = new AtomicReference<>();
         checkpointCoordinatorFuture = new CompletableFuture<>();
         updateStatus(CheckpointCoordinatorStatus.RUNNING);
+
+        Set<TaskLocation> restoredReadyToClose = 
loadReadyToCloseStartingTask();
+
         
cleanPendingCheckpoint(CheckpointCloseReason.CHECKPOINT_COORDINATOR_RESET);
         shutdown = false;
+
+        if (restoredReadyToClose != null && !restoredReadyToClose.isEmpty()) {
+            readyToCloseStartingTask.addAll(restoredReadyToClose);
+            LOG.info(
+                    "Restored readyToCloseStartingTask, restored count: {}, "
+                            + "total starting subtasks: {}, job id: {}, 
pipeline id: {}",
+                    readyToCloseStartingTask.size(),
+                    plan.getStartingSubtasks().size(),
+                    jobId,
+                    pipelineId);
+        }
+
         if (alreadyStarted) {
             isAllTaskReady.set(true);
             if (!notifyCompleted(latestCompletedCheckpoint)) {
                 return;
             }
-            tryTriggerPendingCheckpoint(CHECKPOINT_TYPE);
+            if (readyToCloseStartingTask.size() == 
plan.getStartingSubtasks().size()) {
+                // All sources already finished before failover; complete the 
job now.
+                
tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+            } else {
+                tryTriggerPendingCheckpoint(CHECKPOINT_TYPE);
+            }
         } else {
             isAllTaskReady.set(false);
         }
@@ -517,13 +601,16 @@ public class CheckpointCoordinator {
             long interval = currentTimestamp - latestTriggerTimestamp.get();
             if (interval <= 0) {
                 LOG.error(
-                        "The time on your server may not be incremental which 
can lead checkpoint to stop. The latestTriggerTimestamp: ({}), but the 
currentTimestamp: ({})",
+                        "The time on your server may not be incremental which 
can lead checkpoint to stop. "
+                                + "The latestTriggerTimestamp: ({}), but the 
currentTimestamp: ({})",
                         latestTriggerTimestamp.get(),
                         currentTimestamp);
             }
             if (interval < coordinatorConfig.getCheckpointInterval()) {
                 LOG.info(
-                        "skip trigger checkpoint because the last trigger 
timestamp is {} and current timestamp is {}, the interval is less than config.",
+                        "skip trigger checkpoint "
+                                + "because the last trigger timestamp is ({}) 
and current timestamp is ({}), "
+                                + "the interval is less than config.",
                         latestTriggerTimestamp.get(),
                         currentTimestamp);
                 scheduleTriggerPendingCheckpoint(
@@ -539,7 +626,9 @@ public class CheckpointCoordinator {
                     long minPauseDelay =
                             coordinatorConfig.getCheckpointMinPause() - 
timeSinceLastCompleted;
                     LOG.info(
-                            "skip trigger checkpoint because the last 
completed timestamp is {} and current timestamp is {}, the time since 
completion ({} ms) is less than min-pause ({} ms).",
+                            "skip trigger checkpoint "
+                                    + "because the last completed timestamp is 
{} and current timestamp is {}, "
+                                    + "the time since completion ({} ms) is 
less than min-pause ({} ms).",
                             lastCompletedTime,
                             currentTimestamp,
                             timeSinceLastCompleted,
@@ -552,13 +641,13 @@ public class CheckpointCoordinator {
         synchronized (lock) {
             if (isCompleted() || isShutdown()) {
                 LOG.warn(
-                        String.format(
-                                "can't trigger checkpoint with type: %s, 
because checkpoint coordinator already have last completed checkpoint: (%s) or 
shutdown (%b).",
-                                checkpointType,
-                                latestCompletedCheckpoint != null
-                                        ? 
latestCompletedCheckpoint.getCheckpointType()
-                                        : "null",
-                                shutdown));
+                        "can't trigger checkpoint with type: {}, because 
checkpoint coordinator"
+                                + " already have last completed checkpoint: 
({}) or shutdown ({}).",
+                        checkpointType,
+                        latestCompletedCheckpoint != null
+                                ? latestCompletedCheckpoint.getCheckpointType()
+                                : "null",
+                        shutdown);
                 return;
             }
 
@@ -601,7 +690,7 @@ public class CheckpointCoordinator {
 
     @SneakyThrows
     public PassiveCompletableFuture<CompletedCheckpoint> startSavepoint() {
-        LOG.info(String.format("Start save point for Job (%s)", jobId));
+        LOG.info("start save point for job id: {}.", jobId);
         if (shutdown || isCompleted()) {
             return completableFutureWithError(
                     CheckpointCloseReason.CHECKPOINT_COORDINATOR_SHUTDOWN);
@@ -628,9 +717,10 @@ public class CheckpointCoordinator {
         }
         savepointPendingCheckpoint = savepoint.join();
         LOG.info(
-                String.format(
-                        "The save point checkpointId is %s",
-                        savepointPendingCheckpoint.getCheckpointId()));
+                "save point checkpoint is created, job id: {}, pipeline id: 
{}, checkpoint id: {}.",
+                jobId,
+                pipelineId,
+                savepointPendingCheckpoint.getCheckpointId());
         return savepointPendingCheckpoint.getCompletableFuture();
     }
 
@@ -645,7 +735,9 @@ public class CheckpointCoordinator {
             CompletableFuture<PendingCheckpoint> pendingCompletableFuture) {
         pendingCompletableFuture.thenAccept(
                 pendingCheckpoint -> {
-                    LOG.info("wait checkpoint completed: {}", 
pendingCheckpoint.getCheckpointId());
+                    LOG.info(
+                            "wait checkpoint id: {} completed.",
+                            pendingCheckpoint.getCheckpointId());
                     PassiveCompletableFuture<CompletedCheckpoint> 
completableFuture =
                             pendingCheckpoint.getCompletableFuture();
                     completableFuture.whenCompleteAsync(
@@ -897,6 +989,12 @@ public class CheckpointCoordinator {
             closedIdleTask.clear();
             pendingCounter.set(0);
             schemaChanging.set(false);
+            // Only remove the persisted ready-to-close state when the 
coordinator truly ends
+            // (completed/failed/cancelled). During a reset (master failover), 
the IMap entry
+            // must be preserved so restoreCoordinator() can recover from it.
+            if (closedReason != 
CheckpointCloseReason.CHECKPOINT_COORDINATOR_RESET) {
+                runningJobStateIMap.remove(readyToCloseImapKey);
+            }
             scheduler.shutdownNow();
             scheduler =
                     Executors.newScheduledThreadPool(
@@ -919,12 +1017,12 @@ public class CheckpointCoordinator {
         final long checkpointId = ackOperation.getBarrier().getId();
         final PendingCheckpoint pendingCheckpoint = 
pendingCheckpoints.get(checkpointId);
         if (pendingCheckpoint == null) {
-            LOG.info("skip already ack checkpoint {}", checkpointId);
+            LOG.info("skip already ack checkpoint id: {}", checkpointId);
             return;
         }
         TaskLocation location = ackOperation.getTaskLocation();
         LOG.debug(
-                "task[{}]({}/{}) ack. {}",
+                "task ack, task id: {}, pipeline id: {}, job id: {}, barrier: 
{}",
                 location.getTaskID(),
                 location.getPipelineId(),
                 location.getJobId(),
@@ -954,10 +1052,11 @@ public class CheckpointCoordinator {
 
     public synchronized void completePendingCheckpoint(CompletedCheckpoint 
completedCheckpoint) {
         LOG.debug(
-                "pending checkpoint({}/{}@{}) completed! cost: {}, trigger: 
{}, completed: {}",
-                completedCheckpoint.getCheckpointId(),
-                completedCheckpoint.getPipelineId(),
+                "pending checkpoint completed, job id: {}, pipeline id: {}, 
checkpoint id: {}, "
+                        + "cost: {}, trigger: {}, completed: {}",
                 completedCheckpoint.getJobId(),
+                completedCheckpoint.getPipelineId(),
+                completedCheckpoint.getCheckpointId(),
                 completedCheckpoint.getCompletedTimestamp()
                         - completedCheckpoint.getCheckpointTimestamp(),
                 completedCheckpoint.getCheckpointTimestamp(),
@@ -997,10 +1096,10 @@ public class CheckpointCoordinator {
             sneakyThrow(e);
         }
         LOG.info(
-                "pending checkpoint({}/{}@{}) notify finished!",
-                completedCheckpoint.getCheckpointId(),
+                "pending checkpoint notify finished, job id: {}, pipeline id: 
{}, checkpoint id: {}!",
+                completedCheckpoint.getJobId(),
                 completedCheckpoint.getPipelineId(),
-                completedCheckpoint.getJobId());
+                completedCheckpoint.getCheckpointId());
         latestCompletedCheckpoint = completedCheckpoint;
         if (checkpointMonitorService != null) {
             long stateSize = 
CheckpointMonitorService.calculateStateSize(completedCheckpoint);
@@ -1105,11 +1204,10 @@ public class CheckpointCoordinator {
             RetryUtils.retryWithException(
                     () -> {
                         LOG.info(
-                                String.format(
-                                        "Turn %s state from %s to %s",
-                                        checkpointStateImapKey,
-                                        
runningJobStateIMap.get(checkpointStateImapKey),
-                                        targetStatus));
+                                "Turn {} state from {} to {}",
+                                checkpointStateImapKey,
+                                
runningJobStateIMap.get(checkpointStateImapKey),
+                                targetStatus);
                         runningJobStateIMap.set(checkpointStateImapKey, 
targetStatus);
                         return null;
                     },
@@ -1120,58 +1218,68 @@ public class CheckpointCoordinator {
                             Constant.OPERATION_RETRY_SLEEP));
         } catch (Exception e) {
             LOG.warn(
-                    String.format(
-                            "Set %s state %s to IMap failed, skip do it",
-                            checkpointStateImapKey, targetStatus));
+                    "Set {} state {} to IMap failed, skip do it",
+                    checkpointStateImapKey,
+                    targetStatus);
         }
     }
 
     protected void scheduleSchemaChangeBeforeCheckpoint() {
         if (schemaChanging.compareAndSet(false, true)) {
             LOG.info(
-                    "stop trigger general-checkpoint({}@{}) because schema 
change in progress.",
-                    pipelineId,
-                    jobId);
-            LOG.info("schedule schema-change-before checkpoint({}@{}).", 
pipelineId, jobId);
+                    "stop trigger general-checkpoint "
+                            + "because schema change in progress, job id: {}, 
pipeline id: {}.",
+                    jobId,
+                    pipelineId);
+            LOG.info(
+                    "schedule schema-change-before checkpoint, job id: {}, 
pipeline id: {}.",
+                    jobId,
+                    pipelineId);
             
scheduleTriggerPendingCheckpoint(CheckpointType.SCHEMA_CHANGE_BEFORE_POINT_TYPE,
 0);
         } else {
             LOG.warn(
-                    "schema-change-before checkpoint({}@{}) is already 
scheduled.",
-                    pipelineId,
-                    jobId);
+                    "schema-change-before checkpoint is already scheduled, job 
id: {}, pipeline id: {}.",
+                    jobId,
+                    pipelineId);
         }
     }
 
     protected void scheduleSchemaChangeAfterCheckpoint() {
         if (schemaChanging.get()) {
-            LOG.info("schedule schema-change-after checkpoint({}@{}).", 
pipelineId, jobId);
+            LOG.info(
+                    "schedule schema-change-after checkpoint, job id: {}, 
pipeline id: {}.",
+                    jobId,
+                    pipelineId);
             
scheduleTriggerPendingCheckpoint(CheckpointType.SCHEMA_CHANGE_AFTER_POINT_TYPE, 
0);
         } else {
             LOG.warn(
-                    "schema-change-after checkpoint({}@{}) is already 
scheduled.",
-                    pipelineId,
-                    jobId);
+                    "schema-change-after checkpoint is already scheduled, job 
id: {}, pipeline id: {}.",
+                    jobId,
+                    pipelineId);
         }
     }
 
     protected void completeSchemaChangeAfterCheckpoint(CompletedCheckpoint 
checkpoint) {
         if (schemaChanging.compareAndSet(true, false)) {
             LOG.info(
-                    "completed schema-change-after checkpoint({}/{}@{}).",
-                    checkpoint.getCheckpointId(),
+                    "completed schema-change-after checkpoint, job id: {}, 
pipeline id: {}, "
+                            + "checkpoint id: {}.",
+                    jobId,
                     pipelineId,
-                    jobId);
+                    checkpoint.getCheckpointId());
             LOG.info(
-                    "recover trigger general-checkpoint({}/{}@{}).",
-                    checkpoint.getCheckpointId(),
+                    "recover trigger general-checkpoint, job id: {}, pipeline 
id: {}, "
+                            + "checkpoint id: {}.",
+                    jobId,
                     pipelineId,
-                    jobId);
+                    checkpoint.getCheckpointId());
             
scheduleTriggerPendingCheckpoint(coordinatorConfig.getCheckpointInterval());
         } else {
             throw new IllegalStateException(
                     String.format(
-                            "schema-change-after checkpoint(%s/%s@%s) is 
already completed.",
-                            checkpoint.getCheckpointId(), pipelineId, jobId));
+                            "schema-change-after checkpoint is already 
completed, "
+                                    + "job id: %s, pipeline id: %s, checkpoint 
id: %s.",
+                            jobId, pipelineId, checkpoint.getCheckpointId()));
         }
     }
 
@@ -1179,6 +1287,10 @@ public class CheckpointCoordinator {
         return checkpointStateImapKey;
     }
 
+    public String getReadyToCloseImapKey() {
+        return readyToCloseImapKey;
+    }
+
     /** Only for test */
     @VisibleForTesting
     public PendingCheckpoint getSavepointPendingCheckpoint() {
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/JobMaster.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/JobMaster.java
index 961832660c..6280225aff 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/JobMaster.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/JobMaster.java
@@ -661,6 +661,12 @@ public class JobMaster {
                                             
.getCheckpointCoordinator(pipeline.getPipelineId())
                                             .getCheckpointStateImapKey();
                             runningJobStateIMap.remove(checkpointStateImapKey);
+
+                            String readyToCloseImapKey =
+                                    checkpointManager
+                                            
.getCheckpointCoordinator(pipeline.getPipelineId())
+                                            .getReadyToCloseImapKey();
+                            runningJobStateIMap.remove(readyToCloseImapKey);
                         });
         runningJobStateIMap.remove(jobId);
         runningJobInfoIMap.remove(jobId);
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorTest.java
index beb0ce381e..2ac5be59f7 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorTest.java
@@ -53,11 +53,14 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 
@@ -375,6 +378,491 @@ public class CheckpointCoordinatorTest
         executor.shutdownNow();
     }
 
+    @Test
+    void testReadyToClosePartialProgressPersistedAndRestoredCorrectly() {
+        ExecutorService executorService = Executors.newCachedThreadPool();
+        try {
+            TaskLocation task1 = new TaskLocation(new TaskGroupLocation(1L, 1, 
1), 1, 1);
+            TaskLocation task2 = new TaskLocation(new TaskGroupLocation(1L, 1, 
2), 2, 2);
+            TaskLocation task3 = new TaskLocation(new TaskGroupLocation(1L, 1, 
3), 3, 3);
+            Set<TaskLocation> allStarting = new HashSet<>(Arrays.asList(task1, 
task2, task3));
+
+            CheckpointConfig checkpointConfig = new CheckpointConfig();
+            checkpointConfig.setStorage(new CheckpointStorageConfig());
+
+            CheckpointPlan plan =
+                    CheckpointPlan.builder()
+                            .pipelineId(1)
+                            .pipelineSubtasks(allStarting)
+                            .startingSubtasks(allStarting)
+                            .build();
+
+            IMap<Object, Object> realIMap =
+                    
nodeEngine.getHazelcastInstance().getMap(IMAP_RUNNING_JOB_STATE);
+            String readyToCloseKey = "checkpoint_state_1_1_ready_to_close";
+            realIMap.remove(readyToCloseKey);
+
+            CheckpointManager mockManager = 
Mockito.mock(CheckpointManager.class);
+            CheckpointStorage mockStorage = 
Mockito.mock(CheckpointStorage.class);
+            CheckpointIDCounter mockIdCounter = 
Mockito.mock(CheckpointIDCounter.class);
+
+            // Phase 1: partial readyToClose (1 of 3)
+            CheckpointCoordinator coord1 =
+                    new CheckpointCoordinator(
+                            mockManager,
+                            mockStorage,
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            mockIdCounter,
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy1 = Mockito.spy(coord1);
+            Mockito.doNothing()
+                    .when(spy1)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            spy1.readyToClose(task1);
+
+            Object stored = realIMap.get(readyToCloseKey);
+            Assertions.assertInstanceOf(Set.class, stored);
+            Assertions.assertEquals(
+                    1,
+                    ((Set<?>) stored).size(),
+                    "After 1 of 3 readyToClose, IMap should contain exactly 1 
task");
+            Mockito.verify(spy1, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+
+            // Phase 2: restore with partial progress → should trigger normal 
checkpoint
+            CheckpointCoordinator coord2 =
+                    new CheckpointCoordinator(
+                            mockManager,
+                            mockStorage,
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            mockIdCounter,
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy2 = Mockito.spy(coord2);
+            Mockito.doReturn(true).when(spy2).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(spy2)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            spy2.restoreCoordinator(true);
+
+            
Mockito.verify(spy2).tryTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE);
+            Mockito.verify(spy2, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+
+            // Phase 3: all tasks readyToClose (3 of 3)
+            spy1.readyToClose(task2);
+            spy1.readyToClose(task3);
+
+            stored = realIMap.get(readyToCloseKey);
+            Assertions.assertInstanceOf(Set.class, stored);
+            Assertions.assertEquals(
+                    3,
+                    ((Set<?>) stored).size(),
+                    "After 3 of 3 readyToClose, IMap should contain all 3 
tasks");
+            
Mockito.verify(spy1).tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+
+            // Phase 4: restore with full progress → should trigger 
COMPLETED_POINT
+            CheckpointCoordinator coord3 =
+                    new CheckpointCoordinator(
+                            mockManager,
+                            mockStorage,
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            mockIdCounter,
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy3 = Mockito.spy(coord3);
+            Mockito.doReturn(true).when(spy3).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(spy3)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            spy3.restoreCoordinator(true);
+
+            
Mockito.verify(spy3).tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+            Mockito.verify(spy3, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE);
+
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+
+    @Test
+    void 
testRestoreCoordinatorShouldBeIdempotentWithPartialReadyToCloseProgress() {
+        ExecutorService executorService = Executors.newCachedThreadPool();
+        try {
+            TaskLocation task1 = new TaskLocation(new TaskGroupLocation(1L, 1, 
1), 1, 1);
+            TaskLocation task2 = new TaskLocation(new TaskGroupLocation(1L, 1, 
2), 2, 2);
+            TaskLocation task3 = new TaskLocation(new TaskGroupLocation(1L, 1, 
3), 3, 3);
+            Set<TaskLocation> allStarting = new HashSet<>(Arrays.asList(task1, 
task2, task3));
+
+            CheckpointConfig checkpointConfig = new CheckpointConfig();
+            checkpointConfig.setStorage(new CheckpointStorageConfig());
+            CheckpointPlan plan =
+                    CheckpointPlan.builder()
+                            .pipelineId(1)
+                            .pipelineSubtasks(allStarting)
+                            .startingSubtasks(allStarting)
+                            .build();
+
+            IMap<Object, Object> realIMap =
+                    
nodeEngine.getHazelcastInstance().getMap(IMAP_RUNNING_JOB_STATE);
+            String readyToCloseKey = "checkpoint_state_1_1_ready_to_close";
+            realIMap.put(readyToCloseKey, new 
HashSet<>(Collections.singleton(task1)));
+
+            CheckpointCoordinator coordinator =
+                    new CheckpointCoordinator(
+                            Mockito.mock(CheckpointManager.class),
+                            Mockito.mock(CheckpointStorage.class),
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            Mockito.mock(CheckpointIDCounter.class),
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy = Mockito.spy(coordinator);
+            Mockito.doReturn(true).when(spy).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(spy)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            Assertions.assertDoesNotThrow(() -> spy.restoreCoordinator(true));
+            Assertions.assertDoesNotThrow(() -> spy.restoreCoordinator(true));
+
+            Mockito.verify(spy, Mockito.times(2))
+                    
.tryTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE);
+            Mockito.verify(spy, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+
+            Set<TaskLocation> restoredReadyToClose =
+                    (Set<TaskLocation>)
+                            ReflectionUtils.getField(spy, 
"readyToCloseStartingTask")
+                                    .orElse(Collections.emptySet());
+            Assertions.assertEquals(
+                    1,
+                    restoredReadyToClose.size(),
+                    "Repeated restore should not duplicate partial 
readyToClose progress");
+            Assertions.assertTrue(restoredReadyToClose.contains(task1));
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+
+    @Test
+    void 
testRestoreCoordinatorShouldFallbackToCheckpointWhenReadyToCloseKeyMissing() {
+        ExecutorService executorService = Executors.newCachedThreadPool();
+        try {
+            TaskLocation task1 = new TaskLocation(new TaskGroupLocation(1L, 1, 
1), 1, 1);
+            TaskLocation task2 = new TaskLocation(new TaskGroupLocation(1L, 1, 
2), 2, 2);
+            Set<TaskLocation> allStarting = new HashSet<>(Arrays.asList(task1, 
task2));
+
+            CheckpointConfig checkpointConfig = new CheckpointConfig();
+            checkpointConfig.setStorage(new CheckpointStorageConfig());
+            CheckpointPlan plan =
+                    CheckpointPlan.builder()
+                            .pipelineId(1)
+                            .pipelineSubtasks(allStarting)
+                            .startingSubtasks(allStarting)
+                            .build();
+
+            IMap<Object, Object> realIMap =
+                    
nodeEngine.getHazelcastInstance().getMap(IMAP_RUNNING_JOB_STATE);
+            String readyToCloseKey = "checkpoint_state_1_1_ready_to_close";
+            realIMap.remove(readyToCloseKey);
+
+            CheckpointCoordinator coordinator =
+                    new CheckpointCoordinator(
+                            Mockito.mock(CheckpointManager.class),
+                            Mockito.mock(CheckpointStorage.class),
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            Mockito.mock(CheckpointIDCounter.class),
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy = Mockito.spy(coordinator);
+            Mockito.doReturn(true).when(spy).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(spy)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            Assertions.assertDoesNotThrow(() -> spy.restoreCoordinator(true));
+            
Mockito.verify(spy).tryTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE);
+            Mockito.verify(spy, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+
+    @Test
+    void 
testRestoreCoordinatorShouldFallbackToCheckpointWhenReadyToCloseValueCorrupted()
 {
+        ExecutorService executorService = Executors.newCachedThreadPool();
+        try {
+            TaskLocation task1 = new TaskLocation(new TaskGroupLocation(1L, 1, 
1), 1, 1);
+            TaskLocation task2 = new TaskLocation(new TaskGroupLocation(1L, 1, 
2), 2, 2);
+            Set<TaskLocation> allStarting = new HashSet<>(Arrays.asList(task1, 
task2));
+
+            CheckpointConfig checkpointConfig = new CheckpointConfig();
+            checkpointConfig.setStorage(new CheckpointStorageConfig());
+            CheckpointPlan plan =
+                    CheckpointPlan.builder()
+                            .pipelineId(1)
+                            .pipelineSubtasks(allStarting)
+                            .startingSubtasks(allStarting)
+                            .build();
+
+            IMap<Object, Object> realIMap =
+                    
nodeEngine.getHazelcastInstance().getMap(IMAP_RUNNING_JOB_STATE);
+            String readyToCloseKey = "checkpoint_state_1_1_ready_to_close";
+            realIMap.set(readyToCloseKey, "corrupted_ready_to_close_payload");
+
+            CheckpointCoordinator coordinator =
+                    new CheckpointCoordinator(
+                            Mockito.mock(CheckpointManager.class),
+                            Mockito.mock(CheckpointStorage.class),
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            Mockito.mock(CheckpointIDCounter.class),
+                            null,
+                            executorService,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy = Mockito.spy(coordinator);
+            Mockito.doReturn(true).when(spy).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(spy)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            Assertions.assertDoesNotThrow(() -> spy.restoreCoordinator(true));
+            
Mockito.verify(spy).tryTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE);
+            Mockito.verify(spy, Mockito.never())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+
+    @Test
+    void testUpdateReadyToCloseStartingTaskWithTenTaskLocationsConcurrently() 
throws Exception {
+        ExecutorService coordinatorExecutor = Executors.newCachedThreadPool();
+        ExecutorService concurrentExecutor = Executors.newFixedThreadPool(10);
+        try {
+            Set<TaskLocation> allStarting = new HashSet<>();
+            for (int i = 1; i <= 10; i++) {
+                allStarting.add(new TaskLocation(new TaskGroupLocation(1L, 1, 
i), i, i));
+            }
+
+            CheckpointConfig checkpointConfig = new CheckpointConfig();
+            checkpointConfig.setStorage(new CheckpointStorageConfig());
+            CheckpointPlan plan =
+                    CheckpointPlan.builder()
+                            .pipelineId(1)
+                            .pipelineSubtasks(allStarting)
+                            .startingSubtasks(allStarting)
+                            .build();
+
+            IMap<Object, Object> realIMap = Mockito.mock(IMap.class);
+            String readyToCloseKey = "checkpoint_state_1_1_ready_to_close";
+            Map<Object, Object> simulatedImapStorage = new 
ConcurrentHashMap<>();
+            Mockito.when(realIMap.get(Mockito.any()))
+                    .thenAnswer(invocation -> 
simulatedImapStorage.get(invocation.getArgument(0)));
+
+            CountDownLatch firstSnapshotReady = new CountDownLatch(1);
+            CountDownLatch fullSnapshotWritten = new CountDownLatch(1);
+            CountDownLatch allowFirstSnapshotWrite = new CountDownLatch(1);
+            AtomicBoolean blockedFirstSnapshot = new AtomicBoolean(false);
+
+            AtomicInteger computeCallCount = new AtomicInteger(0);
+            AtomicInteger setCallCount = new AtomicInteger(0);
+
+            Mockito.doAnswer(
+                            invocation -> {
+                                Object key = invocation.getArgument(0);
+                                java.util.function.BiFunction<Object, Object, 
Object>
+                                        remappingFunction = 
invocation.getArgument(1);
+                                if (!readyToCloseKey.equals(key)) {
+                                    return simulatedImapStorage.compute(key, 
remappingFunction);
+                                }
+                                computeCallCount.incrementAndGet();
+                                if (blockedFirstSnapshot.compareAndSet(false, 
true)) {
+                                    firstSnapshotReady.countDown();
+                                    if (!allowFirstSnapshotWrite.await(30, 
TimeUnit.SECONDS)) {
+                                        throw new AssertionError(
+                                                "Timed out while waiting to 
release first snapshot write");
+                                    }
+                                }
+                                Object result =
+                                        simulatedImapStorage.compute(key, 
remappingFunction);
+                                @SuppressWarnings("unchecked")
+                                Set<TaskLocation> snapshot = 
(Set<TaskLocation>) result;
+                                if (snapshot.size() == allStarting.size()) {
+                                    fullSnapshotWritten.countDown();
+                                }
+                                return result;
+                            })
+                    .when(realIMap)
+                    .compute(Mockito.any(), Mockito.any());
+
+            Mockito.doAnswer(
+                            invocation -> {
+                                Object key = invocation.getArgument(0);
+                                Object value = invocation.getArgument(1);
+                                if (readyToCloseKey.equals(key)) {
+                                    setCallCount.incrementAndGet();
+                                }
+                                simulatedImapStorage.put(key, value);
+                                return null;
+                            })
+                    .when(realIMap)
+                    .set(Mockito.any(), Mockito.any());
+
+            CheckpointCoordinator coordinator =
+                    new CheckpointCoordinator(
+                            Mockito.mock(CheckpointManager.class),
+                            Mockito.mock(CheckpointStorage.class),
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            Mockito.mock(CheckpointIDCounter.class),
+                            null,
+                            coordinatorExecutor,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator spy = Mockito.spy(coordinator);
+            Mockito.doNothing()
+                    .when(spy)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+
+            CountDownLatch finishLatch = new 
CountDownLatch(allStarting.size());
+            List<Future<?>> futures = new ArrayList<>(allStarting.size());
+            List<TaskLocation> taskLocations = new ArrayList<>(allStarting);
+            TaskLocation firstTask = taskLocations.get(0);
+
+            Future<?> firstFuture =
+                    concurrentExecutor.submit(
+                            () -> {
+                                try {
+                                    spy.readyToClose(firstTask);
+                                } finally {
+                                    finishLatch.countDown();
+                                }
+                            });
+            futures.add(firstFuture);
+
+            Assertions.assertTrue(
+                    firstSnapshotReady.await(30, TimeUnit.SECONDS),
+                    "Should observe the first snapshot write");
+
+            for (int i = 1; i < taskLocations.size(); i++) {
+                TaskLocation taskLocation = taskLocations.get(i);
+                Future<?> future =
+                        concurrentExecutor.submit(
+                                () -> {
+                                    try {
+                                        spy.readyToClose(taskLocation);
+                                    } finally {
+                                        finishLatch.countDown();
+                                    }
+                                });
+                futures.add(future);
+            }
+
+            Assertions.assertTrue(
+                    fullSnapshotWritten.await(30, TimeUnit.SECONDS),
+                    "Should observe full snapshot write before releasing first 
write");
+            allowFirstSnapshotWrite.countDown();
+            Assertions.assertTrue(
+                    finishLatch.await(30, TimeUnit.SECONDS),
+                    "All concurrent readyToClose calls should finish in time");
+            for (Future<?> future : futures) {
+                future.get(30, TimeUnit.SECONDS);
+            }
+
+            Object persistedValue = simulatedImapStorage.get(readyToCloseKey);
+            Assertions.assertInstanceOf(
+                    Set.class, persistedValue, "Persisted ready-to-close state 
should be a Set");
+            Set<TaskLocation> persisted = (Set<TaskLocation>) persistedValue;
+            Assertions.assertEquals(10, persisted.size());
+            Assertions.assertTrue(persisted.containsAll(allStarting));
+            Mockito.verify(spy, Mockito.atLeastOnce())
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+
+            Assertions.assertTrue(
+                    computeCallCount.get() >= 10,
+                    "Production code must use atomic compute() for 
ready-to-close persistence,"
+                            + " actual compute calls: "
+                            + computeCallCount.get());
+            Assertions.assertEquals(
+                    0,
+                    setCallCount.get(),
+                    "Production code must NOT use non-atomic set() for 
ready-to-close persistence;"
+                            + " set() would cause lost-update under 
concurrency");
+
+            // Restore from persisted IMap state and verify merged set is 
fully recoverable.
+            CheckpointCoordinator restoredCoordinator =
+                    new CheckpointCoordinator(
+                            Mockito.mock(CheckpointManager.class),
+                            Mockito.mock(CheckpointStorage.class),
+                            checkpointConfig,
+                            1L,
+                            plan,
+                            Mockito.mock(CheckpointIDCounter.class),
+                            null,
+                            coordinatorExecutor,
+                            realIMap,
+                            false,
+                            null);
+            CheckpointCoordinator restoreSpy = 
Mockito.spy(restoredCoordinator);
+            
Mockito.doReturn(true).when(restoreSpy).notifyCompleted(Mockito.any());
+            Mockito.doNothing()
+                    .when(restoreSpy)
+                    
.tryTriggerPendingCheckpoint(Mockito.any(CheckpointType.class));
+            restoreSpy.restoreCoordinator(true);
+
+            Set<TaskLocation> restoredReadyToClose =
+                    (Set<TaskLocation>)
+                            ReflectionUtils.getField(restoreSpy, 
"readyToCloseStartingTask")
+                                    .orElse(Collections.emptySet());
+            Assertions.assertEquals(
+                    allStarting,
+                    restoredReadyToClose,
+                    "Restored ready-to-close state should recover the full 
merged set");
+            Mockito.verify(restoreSpy)
+                    
.tryTriggerPendingCheckpoint(CheckpointType.COMPLETED_POINT_TYPE);
+        } finally {
+            concurrentExecutor.shutdownNow();
+            coordinatorExecutor.shutdownNow();
+        }
+    }
+
     // ------------------------------------------------------------------
     // Regression tests: notifyCompleted returns false → callers bail out
     // ------------------------------------------------------------------

Reply via email to