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 6ee8f8d936 [Fix][Zeta] Clean failed pipeline metrics without full-map 
cleanup scan (#10757)
6ee8f8d936 is described below

commit 6ee8f8d9368ac77eb3d22961ee914f0d602a6c7a
Author: David Zollo <[email protected]>
AuthorDate: Tue Jul 21 23:11:27 2026 +0800

    [Fix][Zeta] Clean failed pipeline metrics without full-map cleanup scan 
(#10757)
    
    Co-authored-by: Doyeon Kim <[email protected]>
    Co-authored-by: Daniel <[email protected]>
    Co-authored-by: Daniel <[email protected]>
    Co-authored-by: DanielLeens <[email protected]>
---
 .../engine/common/config/EngineConfig.java         |   2 +-
 .../engine/server/CoordinatorService.java          | 177 +++++++++++++------
 .../HazelcastMetricsSnapshotStateStore.java        |  52 ++----
 .../seatunnel/engine/server/master/JobMaster.java  |   8 +-
 .../master/cleanup/PipelineCleanupRecord.java      |  12 +-
 .../CoordinatorServicePipelineCleanupTest.java     | 193 +++++++++++++++++++--
 .../engine/server/master/JobMasterTest.java        |  61 ++++++-
 .../master/cleanup/PipelineCleanupRecordTest.java  |   9 +-
 8 files changed, 406 insertions(+), 108 deletions(-)

diff --git 
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/EngineConfig.java
 
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/EngineConfig.java
index dec7eb8190..82b7592575 100644
--- 
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/EngineConfig.java
+++ 
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/config/EngineConfig.java
@@ -169,7 +169,7 @@ public class EngineConfig {
     public void setJobMetricsPartitionCount(int jobMetricsPartitionCount) {
         checkPositive(
                 jobMetricsPartitionCount,
-                
ServerConfigOptions.MasterServerConfigOptions.JOB_METRICS_PARTITION_COUNT
+                
ServerConfigOptions.MasterServerConfigOptions.JOB_METRICS_PARTITION_COUNT.key()
                         + " must be > 0");
         this.jobMetricsPartitionCount = jobMetricsPartitionCount;
     }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
index df2134cd98..0d33d82757 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
@@ -602,71 +602,94 @@ public class CoordinatorService {
         }
     }
 
-    private void processPendingPipelineCleanup(
+    @VisibleForTesting
+    void processPendingPipelineCleanup(
             PipelineLocation pipelineLocation, PipelineCleanupRecord record) {
         if (pipelineLocation == null || record == null) {
             return;
         }
-        if (!shouldCleanup(record)) {
-            removePendingCleanupRecord(pipelineLocation, record);
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                pendingPipelineCleanupIMap;
+        if (pendingCleanupIMap == null) {
             return;
         }
 
-        PipelineStatus currentStatus = 
getPipelineStatusFromIMap(pipelineLocation);
-        if (currentStatus != null && !currentStatus.isEndState()) {
-            return;
-        }
+        boolean locked = false;
+        try {
+            pendingCleanupIMap.lock(pipelineLocation);
+            locked = true;
 
-        long now = System.currentTimeMillis();
-        PipelineCleanupRecord updated = copy(record);
-        updated.setLastAttemptTimeMillis(now);
-        updated.setAttemptCount(record.getAttemptCount() + 1);
+            // entrySet() is a snapshot. Revalidate it while holding the same 
key lock used by
+            // restore invalidation so an old execution round cannot clean a 
newer round.
+            PipelineCleanupRecord currentRecord = 
pendingCleanupIMap.get(pipelineLocation);
+            if (!record.equals(currentRecord)) {
+                return;
+            }
+            if (!shouldCleanup(record)) {
+                removePendingCleanupRecord(pipelineLocation, record);
+                return;
+            }
 
-        if (!updated.isMetricsImapCleaned() && 
cleanupPipelineMetrics(pipelineLocation)) {
-            updated.setMetricsImapCleaned(true);
-        }
+            PipelineStatus currentStatus = 
getPipelineStatusFromIMap(pipelineLocation);
+            if (currentStatus != null && !currentStatus.isEndState()) {
+                return;
+            }
 
-        Map<TaskGroupLocation, Address> taskGroups = updated.getTaskGroups();
-        if (taskGroups != null && !taskGroups.isEmpty()) {
-            for (Map.Entry<TaskGroupLocation, Address> taskGroup : 
taskGroups.entrySet()) {
-                TaskGroupLocation taskGroupLocation = taskGroup.getKey();
-                if (updated.getCleanedTaskGroups() != null
-                        && 
updated.getCleanedTaskGroups().contains(taskGroupLocation)) {
-                    continue;
-                }
-                Address workerAddress = taskGroup.getValue();
-                if (workerAddress == null
-                        || 
nodeEngine.getClusterService().getMember(workerAddress) == null) {
-                    continue;
-                }
-                try {
-                    NodeEngineUtil.sendOperationToMemberNode(
-                                    nodeEngine,
-                                    new 
CleanTaskGroupContextOperation(taskGroupLocation),
-                                    workerAddress)
-                            .get();
-                    updated.getCleanedTaskGroups().add(taskGroupLocation);
-                } catch (HazelcastInstanceNotActiveException e) {
-                    logger.warning(
-                            String.format(
-                                    "%s clean TaskGroupContext failed: %s",
-                                    taskGroupLocation, 
ExceptionUtils.getMessage(e)));
-                } catch (Exception e) {
-                    logger.warning(
-                            String.format(
-                                    "%s clean TaskGroupContext failed: %s",
-                                    taskGroupLocation, 
ExceptionUtils.getMessage(e)),
-                            e);
+            long now = System.currentTimeMillis();
+            PipelineCleanupRecord updated = copy(record);
+            updated.setLastAttemptTimeMillis(now);
+            updated.setAttemptCount(record.getAttemptCount() + 1);
+
+            if (!updated.isMetricsImapCleaned() && 
cleanupPipelineMetrics(pipelineLocation)) {
+                updated.setMetricsImapCleaned(true);
+            }
+
+            Map<TaskGroupLocation, Address> taskGroups = 
updated.getTaskGroups();
+            if (taskGroups != null && !taskGroups.isEmpty()) {
+                for (Map.Entry<TaskGroupLocation, Address> taskGroup : 
taskGroups.entrySet()) {
+                    TaskGroupLocation taskGroupLocation = taskGroup.getKey();
+                    if (updated.getCleanedTaskGroups() != null
+                            && 
updated.getCleanedTaskGroups().contains(taskGroupLocation)) {
+                        continue;
+                    }
+                    Address workerAddress = taskGroup.getValue();
+                    if (workerAddress == null
+                            || 
nodeEngine.getClusterService().getMember(workerAddress) == null) {
+                        continue;
+                    }
+                    try {
+                        NodeEngineUtil.sendOperationToMemberNode(
+                                        nodeEngine,
+                                        new 
CleanTaskGroupContextOperation(taskGroupLocation),
+                                        workerAddress)
+                                .get();
+                        updated.getCleanedTaskGroups().add(taskGroupLocation);
+                    } catch (HazelcastInstanceNotActiveException e) {
+                        logger.warning(
+                                String.format(
+                                        "%s clean TaskGroupContext failed: %s",
+                                        taskGroupLocation, 
ExceptionUtils.getMessage(e)));
+                    } catch (Exception e) {
+                        logger.warning(
+                                String.format(
+                                        "%s clean TaskGroupContext failed: %s",
+                                        taskGroupLocation, 
ExceptionUtils.getMessage(e)),
+                                e);
+                    }
                 }
             }
-        }
 
-        boolean replaced = 
pendingPipelineCleanupIMap.replace(pipelineLocation, record, updated);
-        if (!replaced) {
-            return;
-        }
-        if (updated.isCleaned()) {
-            pendingPipelineCleanupIMap.remove(pipelineLocation, updated);
+            boolean replaced = pendingCleanupIMap.replace(pipelineLocation, 
record, updated);
+            if (!replaced) {
+                return;
+            }
+            if (updated.isCleaned()) {
+                pendingCleanupIMap.remove(pipelineLocation, updated);
+            }
+        } finally {
+            if (locked) {
+                pendingCleanupIMap.unlock(pipelineLocation);
+            }
         }
     }
 
@@ -690,7 +713,8 @@ public class CoordinatorService {
         if (record.isSavepointEnd()) {
             return false;
         }
-        return PipelineStatus.CANCELED.equals(record.getFinalStatus())
+        return PipelineStatus.FAILED.equals(record.getFinalStatus())
+                || PipelineStatus.CANCELED.equals(record.getFinalStatus())
                 || PipelineStatus.FINISHED.equals(record.getFinalStatus());
     }
 
@@ -1054,6 +1078,50 @@ public class CoordinatorService {
         runningJobInfoIMap.remove(jobId);
     }
 
+    /**
+     * Removes pending pipeline cleanup records from an older execution round 
of the same job.
+     *
+     * <p>A savepoint-start submission reuses the same job id and pipeline 
ids, while pipeline
+     * cleanup records are keyed only by {@link PipelineLocation}. If a 
previous FAILED record
+     * survives into the restored round, it can delete metrics that the later 
savepoint-end round
+     * must retain.
+     *
+     * @param jobId job id that is entering a new savepoint-start execution 
round
+     */
+    @VisibleForTesting
+    void cleanupPendingPipelineCleanupForRestore(long jobId) {
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                pendingPipelineCleanupIMap;
+        if (pendingCleanupIMap == null || pendingCleanupIMap.isEmpty()) {
+            return;
+        }
+        for (Map.Entry<PipelineLocation, PipelineCleanupRecord> entry :
+                pendingCleanupIMap.entrySet()) {
+            PipelineLocation pipelineLocation = entry.getKey();
+            if (pipelineLocation == null) {
+                continue;
+            }
+            boolean locked = false;
+            try {
+                pendingCleanupIMap.lock(pipelineLocation);
+                locked = true;
+                PipelineCleanupRecord currentRecord = 
pendingCleanupIMap.get(pipelineLocation);
+                boolean sameJob =
+                        pipelineLocation.getJobId() == jobId
+                                || (currentRecord != null
+                                        && currentRecord.getPipelineLocation() 
!= null
+                                        && 
currentRecord.getPipelineLocation().getJobId() == jobId);
+                if (sameJob) {
+                    pendingCleanupIMap.remove(pipelineLocation);
+                }
+            } finally {
+                if (locked) {
+                    pendingCleanupIMap.unlock(pipelineLocation);
+                }
+            }
+        }
+    }
+
     /**
      * Polls master ownership and reconciles the local coordinator lifecycle 
with cluster state.
      *
@@ -1201,6 +1269,9 @@ public class CoordinatorService {
                     JobMaster jobMaster = null;
                     JobInfo submittedJobInfo = null;
                     try {
+                        if (isStartWithSavePoint) {
+                            cleanupPendingPipelineCleanupForRestore(jobId);
+                        }
                         JobCleanupRecord pendingCleanupRecord =
                                 pendingJobCleanupIMap != null
                                         ? pendingJobCleanupIMap.get(jobId)
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/common/statestore/metrics/hazelcast/HazelcastMetricsSnapshotStateStore.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/common/statestore/metrics/hazelcast/HazelcastMetricsSnapshotStateStore.java
index be4e3f83eb..20df3b848b 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/common/statestore/metrics/hazelcast/HazelcastMetricsSnapshotStateStore.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/common/statestore/metrics/hazelcast/HazelcastMetricsSnapshotStateStore.java
@@ -30,14 +30,12 @@ import com.hazelcast.map.listener.EntryUpdatedListener;
 
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
-import java.util.stream.Collectors;
 
 /** Implementation backed by a partitioned Hazelcast metrics {@link IMap}. */
 public class HazelcastMetricsSnapshotStateStore
@@ -115,40 +113,24 @@ public class HazelcastMetricsSnapshotStateStore
 
     @Override
     public void removePipeline(final PipelineLocation pipelineLocation) {
-        Map<Long, List<TaskLocation>> partitionedTasks = new HashMap<>();
-        for (Map.Entry<Long, Map<TaskLocation, SeaTunnelMetricsContext>> entry 
:
-                metricsImap.entrySet()) {
-            long partition = entry.getKey();
-            List<TaskLocation> tasksToRemove =
-                    entry.getValue().keySet().stream()
-                            .filter(
-                                    t ->
-                                            t.getTaskGroupLocation()
-                                                    .getPipelineLocation()
-                                                    .equals(pipelineLocation))
-                            .collect(Collectors.toList());
-            if (!tasksToRemove.isEmpty()) {
-                partitionedTasks.put(partition, tasksToRemove);
-            }
+        for (long partition = 0; partition < partitionCount; partition++) {
+            metricsImap.compute(
+                    partition,
+                    (ignored, current) -> {
+                        if (current == null || current.isEmpty()) {
+                            return current;
+                        }
+                        Map<TaskLocation, SeaTunnelMetricsContext> updated = 
new HashMap<>(current);
+                        updated.entrySet()
+                                .removeIf(
+                                        entry ->
+                                                pipelineLocation.equals(
+                                                        entry.getKey()
+                                                                
.getTaskGroupLocation()
+                                                                
.getPipelineLocation()));
+                        return updated.isEmpty() ? null : updated;
+                    });
         }
-
-        partitionedTasks
-                .entrySet()
-                .parallelStream()
-                .forEach(
-                        entry -> {
-                            long partition = entry.getKey();
-                            List<TaskLocation> tasks = entry.getValue();
-                            metricsImap.compute(
-                                    partition,
-                                    (k, oldVal) -> {
-                                        if (oldVal != null) {
-                                            tasks.forEach(oldVal::remove);
-                                            if (oldVal.isEmpty()) return null;
-                                        }
-                                        return oldVal;
-                                    });
-                        });
     }
 
     @Override
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 9272f9a0bd..824c38256a 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
@@ -1114,8 +1114,11 @@ public class JobMaster {
                 PipelineStatus.FINISHED.equals(pipelineStatus)
                         && checkpointManager != null
                         && 
checkpointManager.isPipelineSavePointEnd(pipelineLocation);
+        // Failed pipelines also need cleanup so their distributed metrics do 
not leak into later
+        // task recovery or re-submission flows.
         boolean shouldCleanup =
-                PipelineStatus.CANCELED.equals(pipelineStatus)
+                PipelineStatus.FAILED.equals(pipelineStatus)
+                        || PipelineStatus.CANCELED.equals(pipelineStatus)
                         || (PipelineStatus.FINISHED.equals(pipelineStatus) && 
!savepointEnd);
         if (!shouldCleanup) {
             return;
@@ -1172,7 +1175,8 @@ public class JobMaster {
 
     public void removeMetricsContext(
             PipelineLocation pipelineLocation, PipelineStatus pipelineStatus) {
-        if ((pipelineStatus.equals(PipelineStatus.FINISHED)
+        if (pipelineStatus.equals(PipelineStatus.FAILED)
+                || (pipelineStatus.equals(PipelineStatus.FINISHED)
                         && 
!checkpointManager.isPipelineSavePointEnd(pipelineLocation))
                 || pipelineStatus.equals(PipelineStatus.CANCELED)) {
 
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecord.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecord.java
index 6ace1cbb0b..b07b565581 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecord.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecord.java
@@ -136,6 +136,16 @@ public class PipelineCleanupRecord implements 
IdentifiedDataSerializable {
                 && cleanedTaskGroups.containsAll(taskGroups.keySet());
     }
 
+    /**
+     * Merges a newer cleanup intent into this record while preserving 
completed cleanup progress.
+     *
+     * <p>The caller passes the newer terminal-state record as {@code other}. 
Its final status must
+     * win so an older FAILED or CANCELED intent cannot keep driving cleanup 
after a later terminal
+     * state is reported for the same pipeline location.
+     *
+     * @param other newer cleanup record for the same pipeline location
+     * @return merged cleanup record
+     */
     public PipelineCleanupRecord mergeFrom(PipelineCleanupRecord other) {
         if (other == null) {
             return this;
@@ -161,7 +171,7 @@ public class PipelineCleanupRecord implements 
IdentifiedDataSerializable {
                         this.pipelineLocation != null
                                 ? this.pipelineLocation
                                 : other.pipelineLocation,
-                        this.finalStatus != null ? this.finalStatus : 
other.finalStatus,
+                        other.finalStatus != null ? other.finalStatus : 
this.finalStatus,
                         this.savepointEnd || other.savepointEnd,
                         mergedTaskGroups,
                         mergedCleaned,
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServicePipelineCleanupTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServicePipelineCleanupTest.java
index e8218e46ba..267596a7ce 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServicePipelineCleanupTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServicePipelineCleanupTest.java
@@ -36,7 +36,10 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.awaitility.Awaitility.await;
 
@@ -44,8 +47,7 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
 
     @Test
     void testCleanupRemovesMetricsAndRecordWhenNoTaskGroups() {
-        CoordinatorService coordinatorService = server.getCoordinatorService();
-        awaitCoordinatorActive(coordinatorService);
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
 
         long jobId = System.currentTimeMillis();
         PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
@@ -82,10 +84,48 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
         
Assertions.assertFalse(pendingCleanupIMap.containsKey(pipelineLocation));
     }
 
+    @Test
+    void testCleanupRemovesMetricsAndRecordForFailedPipeline() {
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
+
+        long jobId = System.currentTimeMillis();
+        PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
+        PipelineLocation otherPipelineLocation = new PipelineLocation(jobId + 
1, 1);
+
+        upsertMetricsForPipeline(pipelineLocation);
+        upsertMetricsForPipeline(otherPipelineLocation);
+        Assertions.assertTrue(hasMetricsForPipeline(pipelineLocation));
+        Assertions.assertTrue(hasMetricsForPipeline(otherPipelineLocation));
+
+        IMap<Object, Object> runningJobStateIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_RUNNING_JOB_STATE);
+        runningJobStateIMap.put(pipelineLocation, PipelineStatus.FAILED);
+
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_PENDING_PIPELINE_CLEANUP);
+        pendingCleanupIMap.put(
+                pipelineLocation,
+                new PipelineCleanupRecord(
+                        pipelineLocation,
+                        PipelineStatus.FAILED,
+                        false,
+                        Collections.emptyMap(),
+                        Collections.emptySet(),
+                        false,
+                        System.currentTimeMillis(),
+                        0L,
+                        0));
+
+        coordinatorService.runPendingPipelineCleanupOnce();
+
+        Assertions.assertFalse(hasMetricsForPipeline(pipelineLocation));
+        Assertions.assertTrue(hasMetricsForPipeline(otherPipelineLocation));
+        
Assertions.assertFalse(pendingCleanupIMap.containsKey(pipelineLocation));
+    }
+
     @Test
     void testSkipCleanupWhenPipelineNotEndState() {
-        CoordinatorService coordinatorService = server.getCoordinatorService();
-        awaitCoordinatorActive(coordinatorService);
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
 
         long jobId = System.currentTimeMillis();
         PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
@@ -122,8 +162,7 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
 
     @Test
     void testRemoveRecordWhenShouldCleanupIsFalse() {
-        CoordinatorService coordinatorService = server.getCoordinatorService();
-        awaitCoordinatorActive(coordinatorService);
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
 
         long jobId = System.currentTimeMillis();
         PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
@@ -157,10 +196,104 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
                 "Should not clean metrics when record is removed due to 
shouldCleanup=false");
     }
 
+    /**
+     * Verifies that savepoint-start invalidates stale failed cleanup records 
from the previous
+     * round.
+     */
+    @Test
+    void testRestoreStartRemovesStaleFailedCleanupRecordForSameJob() {
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
+
+        long jobId = System.currentTimeMillis();
+        PipelineLocation restoredPipelineLocation = new 
PipelineLocation(jobId, 1);
+        PipelineLocation otherPipelineLocation = new PipelineLocation(jobId + 
1, 1);
+
+        upsertMetricsForPipeline(restoredPipelineLocation);
+        upsertMetricsForPipeline(otherPipelineLocation);
+        Assertions.assertTrue(hasMetricsForPipeline(restoredPipelineLocation));
+        Assertions.assertTrue(hasMetricsForPipeline(otherPipelineLocation));
+
+        IMap<Object, Object> runningJobStateIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_RUNNING_JOB_STATE);
+        runningJobStateIMap.put(restoredPipelineLocation, 
PipelineStatus.FINISHED);
+        runningJobStateIMap.put(otherPipelineLocation, PipelineStatus.FAILED);
+
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_PENDING_PIPELINE_CLEANUP);
+        pendingCleanupIMap.put(
+                restoredPipelineLocation,
+                newPipelineCleanupRecord(restoredPipelineLocation, 
PipelineStatus.FAILED, false));
+        pendingCleanupIMap.put(
+                otherPipelineLocation,
+                newPipelineCleanupRecord(otherPipelineLocation, 
PipelineStatus.FAILED, false));
+
+        coordinatorService.cleanupPendingPipelineCleanupForRestore(jobId);
+
+        
Assertions.assertFalse(pendingCleanupIMap.containsKey(restoredPipelineLocation));
+        
Assertions.assertTrue(pendingCleanupIMap.containsKey(otherPipelineLocation));
+
+        coordinatorService.runPendingPipelineCleanupOnce();
+
+        Assertions.assertTrue(
+                hasMetricsForPipeline(restoredPipelineLocation),
+                "Stale failed cleanup record must not delete savepoint-end 
metrics");
+        Assertions.assertFalse(hasMetricsForPipeline(otherPipelineLocation));
+        
Assertions.assertFalse(pendingCleanupIMap.containsKey(otherPipelineLocation));
+    }
+
+    @Test
+    void 
testRestoreInvalidationPreventsCapturedCleanupFromDeletingNewRoundMetrics()
+            throws Exception {
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
+
+        long jobId = System.currentTimeMillis();
+        PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
+        PipelineCleanupRecord staleRecord =
+                newPipelineCleanupRecord(pipelineLocation, 
PipelineStatus.FAILED, false);
+
+        IMap<Object, Object> runningJobStateIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_RUNNING_JOB_STATE);
+        runningJobStateIMap.put(pipelineLocation, PipelineStatus.FAILED);
+
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_PENDING_PIPELINE_CLEANUP);
+        pendingCleanupIMap.put(pipelineLocation, staleRecord);
+        upsertMetricsForPipeline(pipelineLocation);
+
+        CountDownLatch cleanupStarted = new CountDownLatch(1);
+        pendingCleanupIMap.lock(pipelineLocation);
+        CompletableFuture<Void> capturedCleanup;
+        try {
+            capturedCleanup =
+                    CompletableFuture.runAsync(
+                            () -> {
+                                cleanupStarted.countDown();
+                                
coordinatorService.processPendingPipelineCleanup(
+                                        pipelineLocation, staleRecord);
+                            });
+            Assertions.assertTrue(cleanupStarted.await(10, TimeUnit.SECONDS));
+            Assertions.assertFalse(capturedCleanup.isDone());
+
+            coordinatorService.cleanupPendingPipelineCleanupForRestore(jobId);
+            
Assertions.assertFalse(pendingCleanupIMap.containsKey(pipelineLocation));
+
+            // Simulate metrics published by the restored execution before the 
captured cleanup
+            // gets CPU time again.
+            upsertMetricsForPipeline(pipelineLocation);
+            runningJobStateIMap.put(pipelineLocation, PipelineStatus.FINISHED);
+        } finally {
+            pendingCleanupIMap.unlock(pipelineLocation);
+        }
+
+        capturedCleanup.get(10, TimeUnit.SECONDS);
+        Assertions.assertTrue(
+                hasMetricsForPipeline(pipelineLocation),
+                "Captured cleanup from the old round must not delete 
restored-round metrics");
+    }
+
     @Test
     void testCleanupUpdatesRecordAndKeepsItWhenTaskGroupCannotBeCleaned() {
-        CoordinatorService coordinatorService = server.getCoordinatorService();
-        awaitCoordinatorActive(coordinatorService);
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
 
         long jobId = System.currentTimeMillis();
         PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
@@ -203,8 +336,7 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
 
     @Test
     void testCleanupRemovesRecordWhenAllTaskGroupsCleaned() {
-        CoordinatorService coordinatorService = server.getCoordinatorService();
-        awaitCoordinatorActive(coordinatorService);
+        CoordinatorService coordinatorService = 
awaitActiveCoordinatorService();
 
         long jobId = System.currentTimeMillis();
         PipelineLocation pipelineLocation = new PipelineLocation(jobId, 1);
@@ -252,15 +384,50 @@ class CoordinatorServicePipelineCleanupTest extends 
AbstractSeaTunnelServerTest
         server.updateMetrics(local);
     }
 
+    /**
+     * Creates a focused pending cleanup record without task-group cleanup 
requirements.
+     *
+     * <p>The cleanup tests use this helper to keep assertions focused on 
metric cleanup behavior.
+     */
+    private PipelineCleanupRecord newPipelineCleanupRecord(
+            PipelineLocation pipelineLocation,
+            PipelineStatus pipelineStatus,
+            boolean savepointEnd) {
+        return new PipelineCleanupRecord(
+                pipelineLocation,
+                pipelineStatus,
+                savepointEnd,
+                Collections.emptyMap(),
+                Collections.emptySet(),
+                false,
+                System.currentTimeMillis(),
+                0L,
+                0);
+    }
+
     private boolean hasMetricsForPipeline(PipelineLocation pipelineLocation) {
         MetricsSnapshotStateStore metricsStore =
                 
server.getEngineContext().getStateStores().metricsSnapshotStore();
         return metricsStore.containsPipeline(pipelineLocation);
     }
 
-    private void awaitCoordinatorActive(CoordinatorService coordinatorService) 
{
-        await().atMost(30, TimeUnit.SECONDS)
+    /**
+     * Waits until the test server exposes an active coordinator service.
+     *
+     * <p>{@link SeaTunnelServer#getCoordinatorService()} throws before 
active-master initialization
+     * finishes, so tests must wait around the lookup itself instead of 
checking activity after the
+     * lookup succeeds.
+     */
+    private CoordinatorService awaitActiveCoordinatorService() {
+        AtomicReference<CoordinatorService> coordinatorServiceRef = new 
AtomicReference<>();
+        await().ignoreExceptions()
+                .atMost(30, TimeUnit.SECONDS)
                 .untilAsserted(
-                        () -> 
Assertions.assertTrue(coordinatorService.isCoordinatorActive()));
+                        () -> {
+                            CoordinatorService coordinatorService = 
server.getCoordinatorService();
+                            
Assertions.assertTrue(coordinatorService.isCoordinatorActive());
+                            coordinatorServiceRef.set(coordinatorService);
+                        });
+        return coordinatorServiceRef.get();
     }
 }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/JobMasterTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/JobMasterTest.java
index 8c66837036..a394b9980b 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/JobMasterTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/JobMasterTest.java
@@ -35,6 +35,9 @@ import 
org.apache.seatunnel.engine.server.dag.physical.PipelineLocation;
 import org.apache.seatunnel.engine.server.dag.physical.SubPlan;
 import 
org.apache.seatunnel.engine.server.dag.physical.UnknownPhysicalPlanException;
 import org.apache.seatunnel.engine.server.execution.TaskGroupLocation;
+import org.apache.seatunnel.engine.server.execution.TaskLocation;
+import org.apache.seatunnel.engine.server.master.cleanup.PipelineCleanupRecord;
+import org.apache.seatunnel.engine.server.metrics.SeaTunnelMetricsContext;
 import org.apache.seatunnel.engine.server.resourcemanager.resource.SlotProfile;
 import org.apache.seatunnel.engine.server.service.slot.SlotService;
 import org.apache.seatunnel.engine.server.task.CoordinatorTask;
@@ -51,6 +54,7 @@ import com.hazelcast.internal.serialization.Data;
 import com.hazelcast.map.IMap;
 
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -267,6 +271,37 @@ public class JobMasterTest extends 
AbstractSeaTunnelServerTest {
                 () -> jobMaster.init(System.currentTimeMillis(), false));
     }
 
+    @Test
+    void testFailedPipelineCleanupEnqueuesRecordAndRemovesMetrics() throws 
Exception {
+        long jobId = 
instance.getFlakeIdGenerator(Constant.SEATUNNEL_ID_GENERATOR_NAME).newId();
+        JobMaster jobMaster = newJobInstanceWithRunningState(jobId);
+        PipelineLocation pipelineLocation = 
getRunningPipelineLocation(jobMaster);
+
+        upsertMetricsForPipeline(pipelineLocation);
+        Assertions.assertTrue(hasMetricsForPipeline(pipelineLocation));
+
+        IMap<PipelineLocation, PipelineCleanupRecord> pendingCleanupIMap =
+                
nodeEngine.getHazelcastInstance().getMap(Constant.IMAP_PENDING_PIPELINE_CLEANUP);
+
+        try {
+            jobMaster.enqueuePipelineCleanupIfNeeded(pipelineLocation, 
PipelineStatus.FAILED);
+
+            PipelineCleanupRecord cleanupRecord = 
pendingCleanupIMap.get(pipelineLocation);
+            Assertions.assertNotNull(cleanupRecord);
+            Assertions.assertEquals(PipelineStatus.FAILED, 
cleanupRecord.getFinalStatus());
+            Assertions.assertFalse(cleanupRecord.isSavepointEnd());
+
+            jobMaster.removeMetricsContext(pipelineLocation, 
PipelineStatus.FAILED);
+
+            await().atMost(30, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> 
Assertions.assertFalse(hasMetricsForPipeline(pipelineLocation)));
+        } finally {
+            server.getCoordinatorService().cancelJob(jobId).join();
+            testIMapRemovedAfterJobComplete(jobId, jobMaster);
+        }
+    }
+
     @Test
     void testRestoreStartWithSavePointKeepsFinishedPipelines() throws 
Exception {
         long jobId = 
instance.getFlakeIdGenerator(Constant.SEATUNNEL_ID_GENERATOR_NAME).newId();
@@ -285,7 +320,9 @@ public class JobMasterTest extends 
AbstractSeaTunnelServerTest {
 
     private void assertCloseIdleTask(JobMaster jobMaster) {
         SlotService slotService = server.getSlotService();
-        Assertions.assertEquals(4, 
slotService.getWorkerProfile().getAssignedSlots().length);
+        // Savepoint restore can overlap old slot release with new task 
scheduling for a short time.
+        await().atMost(60, TimeUnit.SECONDS)
+                .until(() -> 
slotService.getWorkerProfile().getAssignedSlots().length == 4);
 
         Assertions.assertEquals(1, 
jobMaster.getPhysicalPlan().getPipelineList().size());
         SubPlan subPlan = jobMaster.getPhysicalPlan().getPipelineList().get(0);
@@ -395,4 +432,26 @@ public class JobMasterTest extends 
AbstractSeaTunnelServerTest {
                 ConfigProvider.locateAndGetSeaTunnelConfig().getEngineConfig(),
                 server);
     }
+
+    private PipelineLocation getRunningPipelineLocation(JobMaster jobMaster) {
+        return 
jobMaster.getPhysicalPlan().getPipelineList().get(0).getPipelineLocation();
+    }
+
+    private void upsertMetricsForPipeline(PipelineLocation pipelineLocation) {
+        TaskGroupLocation taskGroupLocation =
+                new TaskGroupLocation(
+                        pipelineLocation.getJobId(), 
pipelineLocation.getPipelineId(), 1L);
+        TaskLocation taskLocation = new TaskLocation(taskGroupLocation, 0, 0);
+
+        Map<TaskLocation, SeaTunnelMetricsContext> local = new HashMap<>();
+        local.put(taskLocation, new SeaTunnelMetricsContext());
+        server.updateMetrics(local);
+    }
+
+    private boolean hasMetricsForPipeline(PipelineLocation pipelineLocation) {
+        return server.getEngineContext()
+                .getStateStores()
+                .metricsSnapshotStore()
+                .containsPipeline(pipelineLocation);
+    }
 }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecordTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecordTest.java
index b7f982a742..6586fd7549 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecordTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/master/cleanup/PipelineCleanupRecordTest.java
@@ -84,8 +84,13 @@ class PipelineCleanupRecordTest {
         Assertions.assertTrue(record.isCleaned());
     }
 
+    /**
+     * Verifies that merge keeps cleanup progress while the newer terminal 
status wins.
+     *
+     * <p>This protects later cleanup intents from being hidden behind stale 
terminal statuses.
+     */
     @Test
-    void testMergeFromPrefersNonNullFieldsAndUnionsCollections() {
+    void testMergeFromUsesNewTerminalStatusAndUnionsCollections() {
         PipelineLocation pipelineLocation1 = new PipelineLocation(1L, 1);
         PipelineLocation pipelineLocation2 = new PipelineLocation(1L, 2);
         TaskGroupLocation taskGroupLocation1 = new TaskGroupLocation(1L, 1, 
1L);
@@ -128,7 +133,7 @@ class PipelineCleanupRecordTest {
         PipelineCleanupRecord merged = record1.mergeFrom(record2);
 
         Assertions.assertEquals(pipelineLocation1, 
merged.getPipelineLocation());
-        Assertions.assertEquals(PipelineStatus.FINISHED, 
merged.getFinalStatus());
+        Assertions.assertEquals(PipelineStatus.CANCELED, 
merged.getFinalStatus());
         Assertions.assertTrue(merged.isSavepointEnd());
         Assertions.assertTrue(merged.isMetricsImapCleaned());
 


Reply via email to