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

github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new dab424cd1c [Fix][Zeta] Isolate task cleanup by execution context 
(#12238)
dab424cd1c is described below

commit dab424cd1c0bc31da9c85f23d7cb5453c74c8145
Author: zhiweiniu <[email protected]>
AuthorDate: Sun Sep 13 05:50:32 2026 +0000

    [Fix][Zeta] Isolate task cleanup by execution context (#12238)
    
    Co-authored-by: David Zollo <[email protected]>
---
 .../engine/server/TaskExecutionService.java        | 371 +++++++++++++++------
 .../engine/server/execution/TaskGroupContext.java  |  80 ++++-
 .../engine/server/TaskExecutionServiceTest.java    | 325 ++++++++++++++++--
 3 files changed, 653 insertions(+), 123 deletions(-)

diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java
index 7fd2be4c43..e2eb7f0fff 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/TaskExecutionService.java
@@ -178,8 +178,16 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
             new RunBusWorkSupplier(executorService, threadShareTaskQueue);
 
     /**
-     * Cache of active execution contexts, keyed by TaskGroupLocation. 
Contains context for tasks
-     * currently being executed.
+     * The single active deployment for each logical task-group location.
+     *
+     * <p>This map is intentionally not a history of deployments: one {@link 
TaskGroupLocation} key
+     * can have only one active {@link TaskGroupContext} value. Deployment 
serializes the
+     * check-and-publish path, while terminal cleanup uses {@link 
ConcurrentMap#compute(Object,
+     * java.util.function.BiFunction)} and removes the value only when its 
execution ID matches the
+     * finishing tracker. Therefore a stale tracker cannot remove a newer 
active deployment.
+     * Execution-scoped resource maps and each {@code TaskTracker} retain 
their concrete contexts
+     * independently, so replacing the active mapping does not lose the old 
deployment's cleanup
+     * identity.
      */
     private final ConcurrentMap<TaskGroupLocation, TaskGroupContext> 
executionContexts =
             new ConcurrentHashMap<>();
@@ -192,21 +200,31 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
             new ConcurrentHashMap<>();
 
     /**
-     * Map of async function futures for each task group. Used to track and 
cancel async functions
-     * associated with a task group.
+     * Async operations grouped by the context that created them.
+     *
+     * <p>Do not change the key back to {@link TaskGroupLocation}. A location 
identifies a logical
+     * task group and is reused by every restore generation, whereas a {@link 
TaskGroupContext}
+     * identifies one concrete deployment. A stale deployment must only cancel 
its own operations.
      */
-    private final ConcurrentMap<TaskGroupLocation, Map<String, 
CompletableFuture<?>>>
+    private final ConcurrentMap<TaskGroupContext, Map<String, 
CompletableFuture<?>>>
             taskAsyncFunctionFuture = new ConcurrentHashMap<>();
 
     /**
-     * Map of cancellation futures for each task group. Used to cancel task 
group execution on
-     * request.
+     * Cancellation signal for each concrete task-group deployment.
+     *
+     * <p>The active {@link TaskGroupContext} is first resolved from {@code 
executionContexts}; that
+     * same context is then used here so a cancel request is delivered to the 
active generation.
      */
-    private final ConcurrentMap<TaskGroupLocation, CompletableFuture<Void>> 
cancellationFutures =
+    private final ConcurrentMap<TaskGroupContext, CompletableFuture<Void>> 
cancellationFutures =
             new ConcurrentHashMap<>();
 
-    /** SeaTunnel configuration for this engine. */
-    private final ConcurrentMap<TaskGroupLocation, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+    /**
+     * Timer-flush tasks grouped by their owning deployment context.
+     *
+     * <p>Task locations are also reused after restore, so the outer context 
key is required to keep
+     * timers from different generations isolated.
+     */
+    private final ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
             timerFlushFutures = new ConcurrentHashMap<>();
 
     private final SeaTunnelConfig seaTunnelConfig;
@@ -526,6 +544,7 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                 }
                 AtomicBoolean classLoaderOwnershipTransferred = new 
AtomicBoolean();
                 deployLocalTask(
+                        taskImmutableInfo.getExecutionId(),
                         taskGroup,
                         classLoaders,
                         taskJars,
@@ -538,6 +557,9 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                 // Publication is a monotonic ownership transfer. The context 
may already have
                 // completed and left executionContexts by the time deployment 
returns.
                 if (classLoaderOwnershipTransferred.get()) {
+                    // Clearing this local bookkeeping list does not release 
the classloaders. It
+                    // records that their ownership moved to TaskGroupContext, 
whose tracker will
+                    // release them when this exact execution finishes.
                     acquiredClassLoaderJars.clear();
                 }
                 return TaskDeployState.success();
@@ -581,22 +603,29 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
     }
 
     /**
-     * Deploys a task group locally. This method initializes the task group, 
creates execution
-     * contexts, and submits tasks for execution based on the configured 
thread share mode.
+     * Initializes and publishes one concrete task-group deployment on this 
worker.
+     *
+     * <p>The {@code executionId} identifies this deployment, while {@link 
TaskGroupLocation}
+     * identifies the logical task group and may be reused by a later restore. 
The method creates a
+     * {@link TaskGroupContext}, registers all resources under that context, 
publishes it as the
+     * active context, and then submits the group's cooperative and blocking 
tasks.
+     *
+     * <p>{@code onContextPublished} marks the point at which ownership of the 
supplied classloaders
+     * and jars has transferred to the published context. If initialization 
fails before that point,
+     * {@code onFailureBeforeContextPublished} is invoked so the caller can 
release resources it
+     * still owns.
      *
-     * @param taskGroup the task group to deploy
-     * @param classLoaders map of task IDs to class loaders
-     * @param jars map of task IDs to connector jars
-     * @return a future that completes with the task execution state
+     * @param executionId unique ID generated for this deployment attempt
+     * @param taskGroup task group to initialize and execute
+     * @param classLoaders classloaders acquired for the tasks in this 
deployment
+     * @param jars connector jars associated with each task
+     * @param onContextPublished callback invoked after the context becomes 
active
+     * @param onFailureBeforeContextPublished callback invoked when deployment 
fails before context
+     *     publication
+     * @return future completed with this task group's terminal execution state
      */
-    public PassiveCompletableFuture<TaskExecutionState> deployLocalTask(
-            @NonNull TaskGroup taskGroup,
-            @NonNull ConcurrentHashMap<Long, ClassLoader> classLoaders,
-            ConcurrentHashMap<Long, Collection<URL>> jars) {
-        return deployLocalTask(taskGroup, classLoaders, jars, () -> {}, 
failure -> {});
-    }
-
-    private PassiveCompletableFuture<TaskExecutionState> deployLocalTask(
+    PassiveCompletableFuture<TaskExecutionState> deployLocalTask(
+            long executionId,
             @NonNull TaskGroup taskGroup,
             @NonNull ConcurrentHashMap<Long, ClassLoader> classLoaders,
             ConcurrentHashMap<Long, Collection<URL>> jars,
@@ -637,8 +666,6 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                             taskGroup.getTaskGroupLocation()));
             Collection<Task> tasks = taskGroup.getTasks();
             CompletableFuture<Void> cancellationFuture = new 
CompletableFuture<>();
-            TaskGroupExecutionTracker executionTracker =
-                    new TaskGroupExecutionTracker(cancellationFuture, 
taskGroup, resultFuture);
             ConcurrentMap<Long, TaskExecutionContext> taskExecutionContextMap =
                     new ConcurrentHashMap<>();
             final Map<Boolean, List<Task>> byCooperation =
@@ -670,12 +697,32 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                                                 }
                                                 return true;
                                             }));
-            executionContexts.put(
-                    taskGroup.getTaskGroupLocation(),
-                    new TaskGroupContext(taskGroup, classLoaders, jars));
+            TaskGroupLocation taskGroupLocation = 
taskGroup.getTaskGroupLocation();
+            TaskGroupContext context =
+                    new TaskGroupContext(executionId, taskGroup, classLoaders, 
jars);
+            TaskGroupExecutionTracker executionTracker =
+                    new TaskGroupExecutionTracker(cancellationFuture, context, 
resultFuture);
+
+            // Publish a deployment in two phases:
+            //
+            // 1. Register every execution-scoped resource under the new 
context. These entries are
+            //    invisible to location-based callers because the context is 
not active yet.
+            // 2. Publish location -> context in executionContexts. All 
callers first resolve the
+            //    active context from executionContexts and then use that 
context to find its
+            //    cancellation future, async functions, or timer-flush tasks.
+            //
+            // The executionContexts write must remain last. Once another 
thread observes this
+            // context through ConcurrentHashMap#get, the preceding resource 
registrations are also
+            // visible to that thread. Reversing the order would allow 
cancelTaskGroup() to observe
+            // an active context before its cancellation future exists and 
lose the cancel request.
+            // No service-wide lock is required: publication is one-way, and 
cleanup uses the
+            // context's execution ID rather than TaskGroupLocation to address 
the exact deployment.
+            cancellationFutures.put(context, cancellationFuture);
+            taskAsyncFunctionFuture.put(context, new ConcurrentHashMap<>());
+            timerFlushFutures.put(context, new ConcurrentHashMap<>());
+            executionContexts.put(taskGroupLocation, context);
             contextPublished = true;
             onContextPublished.run();
-            cancellationFutures.put(taskGroup.getTaskGroupLocation(), 
cancellationFuture);
             submitThreadShareTask(executionTracker, byCooperation.get(true));
             submitBlockingTask(executionTracker, byCooperation.get(false));
             taskGroup.setTasksContext(taskExecutionContextMap);
@@ -762,9 +809,12 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
      */
     public void cancelTaskGroup(TaskGroupLocation taskGroupLocation) {
         logger.info(String.format("Task (%s) need cancel.", 
taskGroupLocation));
-        if (cancellationFutures.containsKey(taskGroupLocation)) {
+        TaskGroupContext context = executionContexts.get(taskGroupLocation);
+        CompletableFuture<Void> cancellationFuture =
+                context == null ? null : cancellationFutures.get(context);
+        if (cancellationFuture != null) {
             try {
-                cancellationFutures.get(taskGroupLocation).cancel(false);
+                cancellationFuture.cancel(false);
             } catch (CancellationException ignore) {
                 // ignore
             }
@@ -775,24 +825,53 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
     }
 
     /**
-     * Executes a function asynchronously in the context of a task group. The 
function is tracked
-     * and can be cancelled when the task group is cancelled.
+     * Executes and tracks an asynchronous function for the currently active 
deployment of a task
+     * group.
+     *
+     * <p>{@link TaskGroupLocation} identifies a logical task group and may be 
reused after a
+     * restore. This method therefore resolves the location to its active 
{@link TaskGroupContext}
+     * exactly once and records the future in that deployment's bucket. 
Cancellation or terminal
+     * cleanup can then cancel only the functions owned by that deployment; a 
late cleanup from an
+     * older deployment cannot affect functions registered by a newer 
deployment at the same
+     * location.
+     *
+     * <p>The completion callback removes the future from the captured bucket 
instead of resolving
+     * the location again. By the time the callback runs, the location may 
already refer to a newer
+     * deployment. The deployment-level bucket itself is retained until 
task-group cleanup removes
+     * it.
      *
      * @param taskGroupLocation the task group location
-     * @param task the Runnable to execute
+     * @param task the function to execute on the shared task executor
+     * @throws TaskGroupContextNotFoundException if the task group has no 
active deployment
      */
     public void asyncExecuteFunction(TaskGroupLocation taskGroupLocation, 
Runnable task) {
+        // The ID distinguishes multiple async functions owned by the same 
deployment and lets each
+        // completion callback remove only its own future.
         String id = UUID.randomUUID().toString();
         logger.fine("accept async execute function from " + taskGroupLocation 
+ " with id " + id);
-        if (!taskAsyncFunctionFuture.containsKey(taskGroupLocation)) {
-            taskAsyncFunctionFuture.put(taskGroupLocation, new 
ConcurrentHashMap<>());
+
+        // Capture the active deployment once. Never resolve taskGroupLocation 
again for this
+        // function: a restore may publish another context at the same 
location while it is running.
+        TaskGroupContext context = 
getActiveExecutionContext(taskGroupLocation);
+        Map<String, CompletableFuture<?>> taskGroupFutures = 
taskAsyncFunctionFuture.get(context);
+        if (taskGroupFutures == null) {
+            // Deployment publishes this bucket before publishing the active 
context. A missing
+            // bucket therefore means cleanup has already claimed this 
execution; recreating it
+            // here would leave the function outside the tracker's 
cancellation lifecycle.
+            throw new TaskGroupContextNotFoundException(
+                    String.format(
+                            "Async-function resources for task group %s 
execution %s not found.",
+                            taskGroupLocation, context.getExecutionId()));
         }
         CompletableFuture<?> future =
                 CompletableFuture.runAsync(task, 
MDCTracer.tracing(executorService));
-        taskAsyncFunctionFuture.get(taskGroupLocation).put(id, future);
+        taskGroupFutures.put(id, future);
+
+        // Capture taskGroupFutures in the callback. Looking up the bucket by 
location here could
+        // remove a future from a newer deployment after restore.
         future.whenComplete(
                 (r, e) -> {
-                    taskAsyncFunctionFuture.get(taskGroupLocation).remove(id);
+                    taskGroupFutures.remove(id);
                     logger.fine(
                             "remove async execute function from "
                                     + taskGroupLocation
@@ -996,8 +1075,8 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
      * Register or replace a periodic timer-flush task for one source subtask.
      *
      * <p>If a timer already exists for the same {@link TaskLocation}, cancel 
it first. The task is
-     * scheduled with fixed delay on {@code timerFlushWorker} and stored in 
{@code
-     * timerFlushFutures}.
+     * scheduled with fixed delay on {@code timerFlushWorker} and owned by the 
active task-group
+     * context.
      *
      * @param taskLocation source subtask location (map key)
      * @param callback flush callback to run on each tick
@@ -1011,8 +1090,19 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
             throw new IllegalArgumentException("intervalMs must be positive, 
got: " + intervalMs);
         }
         TaskGroupLocation groupLocation = taskLocation.getTaskGroupLocation();
+        // Bind the timer to the currently active deployment rather than to 
the reusable location.
+        TaskGroupContext context = getActiveExecutionContext(groupLocation);
         ConcurrentMap<TaskLocation, ScheduledFuture<?>> groupFutures =
-                timerFlushFutures.computeIfAbsent(groupLocation, k -> new 
ConcurrentHashMap<>());
+                timerFlushFutures.get(context);
+        if (groupFutures == null) {
+            // Deployment publishes this bucket before publishing the active 
context. Never
+            // recreate a bucket after cleanup, otherwise the new timer could 
no longer be found
+            // and cancelled by the owning tracker.
+            throw new TaskGroupContextNotFoundException(
+                    String.format(
+                            "Timer-flush resources for task group %s execution 
%s not found.",
+                            groupLocation, context.getExecutionId()));
+        }
 
         ScheduledFuture<?> existing = groupFutures.remove(taskLocation);
         if (existing != null && !existing.isDone()) {
@@ -1035,15 +1125,33 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
     /**
      * Cancel and remove the timer-flush task for one source subtask.
      *
-     * <p>No-op if the task group or task entry does not exist. If the 
task-group bucket becomes
-     * empty, remove the bucket as well.
+     * <p>{@code executionContexts} contains at most one active context for a 
task-group location,
+     * so this lookup addresses the timer owned by the deployment that is 
active when this method is
+     * called. The returned context, rather than the reusable location, is 
then used as the outer
+     * key of {@code timerFlushFutures}.
+     *
+     * <p>No-op if the task group or task entry does not exist. The 
deployment-level bucket remains
+     * registered even when empty and is removed only by the owning {@code 
TaskTracker}. Keeping one
+     * owner for the bucket lifecycle prevents a concurrent registration from 
being detached by an
+     * unrelated subtask closing the last previously registered timer.
      *
      * @param taskLocation source subtask location
      */
     public void closeTimerFlushTask(TaskLocation taskLocation) {
         TaskGroupLocation groupLocation = taskLocation.getTaskGroupLocation();
+        // A location maps to one active deployment. Old deployments remain 
addressable through
+        // their own contexts, but are deliberately not returned by this 
active-context index.
+        TaskGroupContext context = executionContexts.get(groupLocation);
+        if (context == null) {
+            // The task group has already left the active index. Do not fall 
back to
+            // finishedExecutionContexts: TaskLocation does not contain an 
executionId, so such a
+            // fallback could select a different deployment that reused the 
same location. The
+            // finishing TaskTracker owns the exact context and cancels any 
remaining timers during
+            // deployment cleanup.
+            return;
+        }
         ConcurrentMap<TaskLocation, ScheduledFuture<?>> groupFutures =
-                timerFlushFutures.get(groupLocation);
+                timerFlushFutures.get(context);
         if (groupFutures == null) {
             return;
         }
@@ -1051,22 +1159,13 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
         if (future != null && !future.isDone()) {
             future.cancel(false);
         }
-        if (groupFutures.isEmpty()) {
-            timerFlushFutures.remove(groupLocation, groupFutures);
-        }
         logger.info(String.format("Closed timer-flush task for %s", 
taskLocation));
     }
 
-    /**
-     * Cancel and remove all timer-flush tasks in one task group.
-     *
-     * <p>No-op if the group has no registered timers.
-     *
-     * @param taskGroupLocation task group location
-     */
-    private void cancelTimerFlushForTaskGroup(TaskGroupLocation 
taskGroupLocation) {
+    /** Cancels only timer-flush tasks created by the supplied deployment 
context. */
+    private void cancelTimerFlushFutures(TaskGroupContext context) {
         ConcurrentMap<TaskLocation, ScheduledFuture<?>> groupFutures =
-                timerFlushFutures.remove(taskGroupLocation);
+                timerFlushFutures.remove(context);
         if (groupFutures == null) {
             return;
         }
@@ -1078,9 +1177,58 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                                 f.cancel(false);
                             }
                         });
+        groupFutures.clear();
         logger.info(
                 String.format(
-                        "Cancelled all timer-flush tasks for task group %s", 
taskGroupLocation));
+                        "Cancelled all timer-flush tasks for task group %s",
+                        context.getTaskGroup().getTaskGroupLocation()));
+    }
+
+    /**
+     * Detaches and cancels asynchronous-function futures owned by one 
concrete deployment.
+     *
+     * <p>The outer map is keyed by {@link TaskGroupContext}, whose identity 
is the immutable
+     * execution ID, rather than by the reusable {@link TaskGroupLocation}. It 
is therefore safe for
+     * a stale tracker to call this method after a newer deployment has become 
active: removing the
+     * stale context's bucket cannot remove the newer context's futures.
+     *
+     * <p>The bucket is removed from the outer map before its futures are 
cancelled. This first
+     * makes the execution undiscoverable to new registrations; {@link 
#asyncExecuteFunction}
+     * rejects a missing bucket instead of recreating one after cleanup. 
Completion callbacks may
+     * still remove their own entries from the detached bucket, which is safe 
because the bucket is
+     * a {@link ConcurrentHashMap} created during deployment.
+     *
+     * <p>Calling this method more than once is safe. After the first call 
claims the bucket, later
+     * calls find no entry and return. Cancellation is requested only for 
futures that are not
+     * already complete or cancelled, and clearing the detached bucket 
releases its remaining
+     * references. Code running inside an async function must not rely on 
cancellation causing an
+     * immediate thread interruption; it must still cooperate with its own 
shutdown mechanism.
+     *
+     * @param context deployment whose asynchronous futures should be cleaned
+     */
+    private void cancelAsyncFunctionFutures(TaskGroupContext context) {
+        // Removing first transfers this deployment's bucket from the service 
to this cleanup path.
+        Map<String, CompletableFuture<?>> asyncFunctionFutures =
+                taskAsyncFunctionFuture.remove(context);
+        if (asyncFunctionFutures == null) {
+            // The bucket was never published or has already been claimed by 
another cleanup path.
+            return;
+        }
+        try {
+            asyncFunctionFutures.values().stream()
+                    .filter(f -> !f.isDone())
+                    .filter(f -> !f.isCancelled())
+                    .forEach(f -> f.cancel(true));
+            // Completion callbacks remove individual entries, while 
deployment cleanup releases
+            // any references that remain in the detached bucket.
+            asyncFunctionFutures.clear();
+        } catch (CancellationException e) {
+            logger.warning(ExceptionUtils.getMessage(e));
+        }
+        logger.fine(
+                String.format(
+                        "Cancelled all async functions for task group %s",
+                        context.getTaskGroup().getTaskGroupLocation()));
     }
 
     public void reportEvent(Event e) {
@@ -1374,13 +1522,17 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
 
         private final Map<Long, Future<?>> currRunningTaskFuture = new 
ConcurrentHashMap<>();
 
+        /** The concrete deployment whose tasks and resources are tracked by 
this instance. */
+        private final TaskGroupContext context;
+
         TaskGroupExecutionTracker(
                 @NonNull CompletableFuture<Void> cancellationFuture,
-                @NonNull TaskGroup taskGroup,
+                @NonNull TaskGroupContext context,
                 @NonNull CompletableFuture<TaskExecutionState> future) {
             this.future = future;
+            this.context = context;
+            this.taskGroup = context.getTaskGroup();
             this.completionLatch = new 
AtomicInteger(taskGroup.getTasks().size());
-            this.taskGroup = taskGroup;
             cancellationFuture.whenComplete(
                     withTryCatch(
                             logger,
@@ -1392,7 +1544,7 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                                                     "cancellationFuture should 
be completed exceptionally");
                                 }
                                 exception(e);
-                                
cancelAllTask(taskGroup.getTaskGroupLocation());
+                                cancelAllTask();
                             }));
         }
 
@@ -1406,28 +1558,18 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
             executionException.compareAndSet(null, t);
         }
 
-        private void cancelAllTask(TaskGroupLocation taskGroupLocation) {
+        /**
+         * Cancels tasks and background work owned by this tracker, regardless 
of active generation.
+         */
+        private void cancelAllTask() {
             try {
                 blockingFutures.forEach(f -> f.cancel(true));
                 currRunningTaskFuture.values().forEach(f -> f.cancel(true));
             } catch (CancellationException ignore) {
                 // ignore
             }
-            cancelAsyncFunction(taskGroupLocation);
-            cancelTimerFlushForTaskGroup(taskGroupLocation);
-        }
-
-        private void cancelAsyncFunction(TaskGroupLocation taskGroupLocation) {
-            try {
-                if (taskAsyncFunctionFuture.containsKey(taskGroupLocation)) {
-                    
taskAsyncFunctionFuture.remove(taskGroupLocation).values().stream()
-                            .filter(f -> !f.isDone())
-                            .filter(f -> !f.isCancelled())
-                            .forEach(f -> f.cancel(true));
-                }
-            } catch (CancellationException ignore) {
-                logger.warning(ExceptionUtils.getMessage(ignore));
-            }
+            cancelAsyncFunctionFutures(context);
+            cancelTimerFlushFutures(context);
         }
 
         /**
@@ -1455,20 +1597,7 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                             task.getTaskID(), taskGroupLocation));
             Throwable ex = executionException.get();
             if (completionLatch.decrementAndGet() == 0) {
-                recycleClassLoader(taskGroupLocation);
-                finishedExecutionContexts.put(
-                        taskGroupLocation, 
executionContexts.remove(taskGroupLocation));
-                cancellationFutures.remove(taskGroupLocation);
-                try {
-                    cancelAsyncFunction(taskGroupLocation);
-                } catch (Throwable t) {
-                    logger.severe("cancel async function failed", t);
-                }
-                try {
-                    cancelTimerFlushForTaskGroup(taskGroupLocation);
-                } catch (Throwable t) {
-                    logger.severe("cancel timer-flush tasks failed", t);
-                }
+                finishExecution(taskGroupLocation);
                 try {
                     updateMetricsContextInImap();
                 } catch (Throwable t) {
@@ -1500,18 +1629,70 @@ public class TaskExecutionService implements 
DynamicMetricsProvider {
                         String.format(
                                 "task %s error with exception: [%s], cancel 
other task in taskGroup %s.",
                                 task.getTaskID(), ex, taskGroupLocation));
-                cancelAllTask(taskGroupLocation);
+                cancelAllTask();
             }
         }
 
-        private void recycleClassLoader(TaskGroupLocation taskGroupLocation) {
-            TaskGroupContext context = 
executionContexts.get(taskGroupLocation);
-            executionContexts.get(taskGroupLocation).setClassLoaders(null);
+        private void recycleClassLoader(
+                TaskGroupLocation taskGroupLocation, TaskGroupContext context) 
{
+            context.setClassLoaders(null);
             for (Collection<URL> jars : context.getJars().values()) {
                 
classLoaderService.releaseClassLoader(taskGroupLocation.getJobId(), jars);
             }
         }
 
+        /**
+         * Finishes this deployment without removing a newer deployment at the 
same location.
+         *
+         * <p>Only the active context mapping is conditional. All other 
resources are keyed by this
+         * tracker's context and can therefore always be cleaned safely.
+         */
+        private void finishExecution(TaskGroupLocation taskGroupLocation) {
+            // AtomicBoolean is only a mutable result holder for the compute 
lambda. The atomic
+            // active-context transition itself is provided by 
ConcurrentMap.compute.
+            AtomicBoolean activeGeneration = new AtomicBoolean();
+            // ConcurrentMap.compute serializes updates for this location. 
Comparing contexts by
+            // their immutable executionId and performing the 
active-to-finished transition in the
+            // same computation prevents an older generation from removing a 
newer generation.
+            executionContexts.compute(
+                    taskGroupLocation,
+                    (ignored, activeContext) -> {
+                        if (!context.equals(activeContext)) {
+                            return activeContext;
+                        }
+                        finishedExecutionContexts.put(taskGroupLocation, 
context);
+                        activeGeneration.set(true);
+                        return null;
+                    });
+
+            // These removals are intentionally unconditional. Even when this 
tracker is stale, the
+            // context key identifies its exact execution, so cleaning it 
cannot affect the newer
+            // context stored for the same TaskGroupLocation.
+            cancellationFutures.remove(context);
+
+            if (!activeGeneration.get()) {
+                logger.warning(
+                        String.format(
+                                "Task group %s execution %s finished after it 
was superseded",
+                                taskGroupLocation, context.getExecutionId()));
+            }
+            try {
+                recycleClassLoader(taskGroupLocation, context);
+            } catch (Throwable t) {
+                logger.severe("recycle classloader failed", t);
+            }
+            try {
+                cancelAsyncFunctionFutures(context);
+            } catch (Throwable t) {
+                logger.severe("cancel async function failed", t);
+            }
+            try {
+                cancelTimerFlushFutures(context);
+            } catch (Throwable t) {
+                logger.severe("cancel timer-flush tasks failed", t);
+            }
+        }
+
         boolean executionCompletedExceptionally() {
             return executionException.get() != null;
         }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/TaskGroupContext.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/TaskGroupContext.java
index e048e50486..8c5b11a04e 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/TaskGroupContext.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/execution/TaskGroupContext.java
@@ -17,21 +17,76 @@
 
 package org.apache.seatunnel.engine.server.execution;
 
-import lombok.AllArgsConstructor;
-import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
 
 import java.net.URL;
 import java.util.Collection;
 import java.util.concurrent.ConcurrentHashMap;
 
-@Data
-@AllArgsConstructor
+/**
+ * Worker-side runtime context for one deployment of a task group.
+ *
+ * <p>{@link TaskGroupLocation} identifies the logical task group and is 
reused after restore. In
+ * contrast, {@code executionId} identifies this particular deployment. 
Equality and hash code are
+ * therefore based only on the immutable execution ID so this context can 
safely be used as the key
+ * for execution-scoped resources.
+ *
+ * <p>Mutable fields such as {@code taskGroup}, {@code classLoaders}, and 
{@code jars} must never
+ * participate in {@code equals} or {@code hashCode}. Changing those fields 
after inserting this
+ * context into a map must not change its key.
+ */
+@Getter
+@Setter
 public class TaskGroupContext {
+
+    /**
+     * Globally unique ID of this deployment attempt.
+     *
+     * <p>The master generates a new ID whenever it deploys the task group and 
sends it to the
+     * worker through {@code TaskGroupImmutableInformation}. It is immutable 
because this field is
+     * the complete identity used by {@link #equals(Object)} and {@link 
#hashCode()}.
+     */
+    private final long executionId;
+
+    /**
+     * Runtime task group created for this deployment.
+     *
+     * <p>It contains the executable tasks and their shared lifecycle state. 
Worker operations use
+     * it to locate and control a task after resolving the active context. The 
reference is mutable
+     * for lifecycle management, so it is deliberately excluded from equality 
and hash code.
+     */
     private TaskGroup taskGroup;
 
+    /**
+     * Classloader used by each task, indexed by task ID.
+     *
+     * <p>The worker keeps these references for task execution and clears them 
when this deployment
+     * finishes, allowing the deployment's classloaders to be reclaimed. This 
mutable cleanup state
+     * must not affect the context's map-key identity.
+     */
     private ConcurrentHashMap<Long, ClassLoader> classLoaders;
+
+    /**
+     * Connector/plugin jar URLs acquired for each task, indexed by task ID.
+     *
+     * <p>The URLs record which classloader references belong to this 
deployment. They are used to
+     * release the matching references from {@code ClassLoaderService} during 
cleanup, including
+     * cleanup of a stale deployment after a newer one has started at the same 
task-group location.
+     */
     private ConcurrentHashMap<Long, Collection<URL>> jars;
 
+    public TaskGroupContext(
+            long executionId,
+            TaskGroup taskGroup,
+            ConcurrentHashMap<Long, ClassLoader> classLoaders,
+            ConcurrentHashMap<Long, Collection<URL>> jars) {
+        this.executionId = executionId;
+        this.taskGroup = taskGroup;
+        this.classLoaders = classLoaders;
+        this.jars = jars;
+    }
+
     public ClassLoader getClassLoader(long taskId) {
         if (classLoaders != null) {
             return classLoaders.get(taskId);
@@ -39,4 +94,21 @@ public class TaskGroupContext {
             return null;
         }
     }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || getClass() != other.getClass()) {
+            return false;
+        }
+        TaskGroupContext that = (TaskGroupContext) other;
+        return executionId == that.executionId;
+    }
+
+    @Override
+    public int hashCode() {
+        return Long.hashCode(executionId);
+    }
 }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
index 4c98fd977b..dc456aeaf1 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/TaskExecutionServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.seatunnel.engine.server;
 
 import org.apache.seatunnel.shade.com.google.common.collect.Lists;
 
+import org.apache.seatunnel.common.utils.ReflectionUtils;
 import org.apache.seatunnel.engine.common.utils.PassiveCompletableFuture;
 import org.apache.seatunnel.engine.common.utils.concurrent.CompletableFuture;
 import org.apache.seatunnel.engine.core.classloader.DefaultClassLoaderService;
@@ -57,10 +58,13 @@ import java.net.URL;
 import java.net.URLClassLoader;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -93,7 +97,12 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         ConcurrentHashMap<Long, ClassLoader> classLoaders = new 
ConcurrentHashMap<>();
         classLoaders.put(taskId, 
Thread.currentThread().getContextClassLoader());
         return taskExecutionService.deployLocalTask(
-                taskGroup, classLoaders, new ConcurrentHashMap<>());
+                FLAKE_ID_GENERATOR.newId(),
+                taskGroup,
+                classLoaders,
+                new ConcurrentHashMap<>(),
+                () -> {},
+                failure -> {});
     }
 
     @Test
@@ -559,6 +568,189 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         taskExecutionService.cancelTaskGroup(location);
     }
 
+    @Test
+    public void testStaleTaskDoneCleansOnlyOwnedGenerationResources() throws 
Exception {
+        TaskExecutionService taskExecutionService = 
server.getTaskExecutionService();
+        TaskGroupLocation location = newTaskGroupLocation();
+        Task oldTask = new TestTask(new AtomicBoolean(true), 0, true);
+        TaskGroup oldTaskGroup =
+                new TaskGroupDefaultImpl(location, "old-generation", 
Lists.newArrayList(oldTask));
+        TaskGroup newTaskGroup =
+                new TaskGroupDefaultImpl(
+                        location,
+                        "new-generation",
+                        Lists.newArrayList(new TestTask(new 
AtomicBoolean(true), 0, true)));
+        TaskGroupContext oldContext = newTaskGroupContext(1L, oldTaskGroup);
+        TaskGroupContext newContext = newTaskGroupContext(2L, newTaskGroup);
+        CompletableFuture<Void> oldCancellationFuture = new 
CompletableFuture<>();
+        CompletableFuture<Void> newCancellationFuture = new 
CompletableFuture<>();
+        CompletableFuture<TaskExecutionState> oldResultFuture = new 
CompletableFuture<>();
+        TaskExecutionService.TaskGroupExecutionTracker oldTracker =
+                taskExecutionService
+                .new TaskGroupExecutionTracker(oldCancellationFuture, 
oldContext, oldResultFuture);
+
+        CompletableFuture<?> oldAsyncFuture = new CompletableFuture<>();
+        CompletableFuture<?> newAsyncFuture = new CompletableFuture<>();
+        Map<String, CompletableFuture<?>> oldAsyncFutures = new 
ConcurrentHashMap<>();
+        Map<String, CompletableFuture<?>> newAsyncFutures = new 
ConcurrentHashMap<>();
+        oldAsyncFutures.put("old-generation-async", oldAsyncFuture);
+        newAsyncFutures.put("new-generation-async", newAsyncFuture);
+        TaskLocation oldTaskLocation = new TaskLocation(location, 
oldTask.getTaskID(), 0);
+        TaskLocation newTaskLocation =
+                new TaskLocation(
+                        location, 
newTaskGroup.getTasks().iterator().next().getTaskID(), 0);
+        ScheduledFuture<?> oldTimerFlushFuture = newPendingScheduledFuture();
+        ScheduledFuture<?> newTimerFlushFuture = newPendingScheduledFuture();
+        ConcurrentMap<TaskLocation, ScheduledFuture<?>> oldTimerFlushFutures =
+                new ConcurrentHashMap<>();
+        ConcurrentMap<TaskLocation, ScheduledFuture<?>> newTimerFlushFutures =
+                new ConcurrentHashMap<>();
+        oldTimerFlushFutures.put(oldTaskLocation, oldTimerFlushFuture);
+        newTimerFlushFutures.put(newTaskLocation, newTimerFlushFuture);
+
+        ConcurrentMap<TaskGroupLocation, TaskGroupContext> executionContexts =
+                getField(taskExecutionService, "executionContexts");
+        ConcurrentMap<TaskGroupLocation, TaskGroupContext> 
finishedExecutionContexts =
+                getField(taskExecutionService, "finishedExecutionContexts");
+        ConcurrentMap<TaskGroupContext, CompletableFuture<Void>> 
cancellationFutures =
+                getField(taskExecutionService, "cancellationFutures");
+        ConcurrentMap<TaskGroupContext, Map<String, CompletableFuture<?>>> 
asyncFutures =
+                getField(taskExecutionService, "taskAsyncFunctionFuture");
+        ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+                timerFlushFutures = getField(taskExecutionService, 
"timerFlushFutures");
+        executionContexts.put(location, newContext);
+        cancellationFutures.put(oldContext, oldCancellationFuture);
+        cancellationFutures.put(newContext, newCancellationFuture);
+        asyncFutures.put(oldContext, oldAsyncFutures);
+        asyncFutures.put(newContext, newAsyncFutures);
+        timerFlushFutures.put(oldContext, oldTimerFlushFutures);
+        timerFlushFutures.put(newContext, newTimerFlushFutures);
+
+        try {
+            oldTracker.taskDone(oldTask);
+
+            Assertions.assertSame(newContext, executionContexts.get(location));
+            
Assertions.assertFalse(finishedExecutionContexts.containsKey(location));
+            assertEquals(1L, oldContext.getExecutionId());
+            assertEquals(2L, newContext.getExecutionId());
+            Assertions.assertNull(oldContext.getClassLoaders());
+            Assertions.assertNotNull(newContext.getClassLoaders());
+            Assertions.assertTrue(oldAsyncFuture.isCancelled());
+            Mockito.verify(oldTimerFlushFuture).cancel(false);
+            Assertions.assertFalse(newCancellationFuture.isCancelled());
+            
Assertions.assertFalse(cancellationFutures.containsKey(oldContext));
+            Assertions.assertSame(newCancellationFuture, 
cancellationFutures.get(newContext));
+            Assertions.assertFalse(newAsyncFuture.isCancelled());
+            Mockito.verify(newTimerFlushFuture, Mockito.never()).cancel(false);
+            assertEquals(FINISHED, oldResultFuture.get().getExecutionState());
+        } finally {
+            executionContexts.remove(location);
+            cancellationFutures.remove(newContext);
+            asyncFutures.remove(newContext);
+            timerFlushFutures.remove(newContext);
+            newAsyncFuture.cancel(true);
+        }
+    }
+
+    @Test
+    public void testTaskGroupContextEqualityUsesExecutionId() {
+        TaskGroup taskGroup =
+                new TaskGroupDefaultImpl(
+                        newTaskGroupLocation(), "same-fields", 
Collections.emptyList());
+        ConcurrentHashMap<Long, ClassLoader> classLoaders = new 
ConcurrentHashMap<>();
+        ConcurrentHashMap<Long, Collection<URL>> jars = new 
ConcurrentHashMap<>();
+        TaskGroupContext first = new TaskGroupContext(1L, taskGroup, 
classLoaders, jars);
+        TaskGroupContext sameExecution = new TaskGroupContext(1L, taskGroup, 
classLoaders, jars);
+        // Long.hashCode(1L) and Long.hashCode(1L << 32) are both 1. Different 
executions must
+        // remain distinct even when their hash codes collide.
+        TaskGroupContext differentExecution =
+                new TaskGroupContext(1L << 32, taskGroup, classLoaders, jars);
+        Map<TaskGroupContext, String> contexts = new ConcurrentHashMap<>();
+        contexts.put(first, "first");
+        contexts.put(sameExecution, "same-execution");
+        contexts.put(differentExecution, "different-execution");
+
+        assertEquals(first, sameExecution);
+        assertEquals(first.hashCode(), sameExecution.hashCode());
+        Assertions.assertNotEquals(first, differentExecution);
+        assertEquals(first.hashCode(), differentExecution.hashCode());
+        assertEquals(2, contexts.size());
+        assertEquals("same-execution", contexts.get(first));
+        assertEquals("different-execution", contexts.get(differentExecution));
+    }
+
+    @Test
+    public void testStaleFailedTaskDoneCleansOnlyOwnedGenerationResources() {
+        TaskExecutionService taskExecutionService = 
server.getTaskExecutionService();
+        TaskGroupLocation location = newTaskGroupLocation();
+        Task oldTask1 = new TestTask(new AtomicBoolean(true), 0, true);
+        Task oldTask2 = new TestTask(new AtomicBoolean(true), 0, true);
+        TaskGroup oldTaskGroup =
+                new TaskGroupDefaultImpl(
+                        location, "old-generation", 
Lists.newArrayList(oldTask1, oldTask2));
+        TaskGroup newTaskGroup =
+                new TaskGroupDefaultImpl(
+                        location,
+                        "new-generation",
+                        Lists.newArrayList(new TestTask(new 
AtomicBoolean(true), 0, true)));
+        TaskGroupContext oldContext = newTaskGroupContext(1L, oldTaskGroup);
+        TaskGroupContext newContext = newTaskGroupContext(2L, newTaskGroup);
+        CompletableFuture<Void> oldCancellationFuture = new 
CompletableFuture<>();
+        CompletableFuture<TaskExecutionState> oldResultFuture = new 
CompletableFuture<>();
+        TaskExecutionService.TaskGroupExecutionTracker oldTracker =
+                taskExecutionService
+                .new TaskGroupExecutionTracker(oldCancellationFuture, 
oldContext, oldResultFuture);
+
+        CompletableFuture<?> oldAsyncFuture = new CompletableFuture<>();
+        CompletableFuture<?> newAsyncFuture = new CompletableFuture<>();
+        Map<String, CompletableFuture<?>> oldAsyncFutures = new 
ConcurrentHashMap<>();
+        Map<String, CompletableFuture<?>> newAsyncFutures = new 
ConcurrentHashMap<>();
+        oldAsyncFutures.put("old-generation-async", oldAsyncFuture);
+        newAsyncFutures.put("new-generation-async", newAsyncFuture);
+        ScheduledFuture<?> oldTimerFlushFuture = newPendingScheduledFuture();
+        ScheduledFuture<?> newTimerFlushFuture = newPendingScheduledFuture();
+        ConcurrentMap<TaskLocation, ScheduledFuture<?>> oldTimerFlushFutures =
+                new ConcurrentHashMap<>();
+        ConcurrentMap<TaskLocation, ScheduledFuture<?>> newTimerFlushFutures =
+                new ConcurrentHashMap<>();
+        oldTimerFlushFutures.put(
+                new TaskLocation(location, oldTask1.getTaskID(), 0), 
oldTimerFlushFuture);
+        newTimerFlushFutures.put(
+                new TaskLocation(
+                        location, 
newTaskGroup.getTasks().iterator().next().getTaskID(), 0),
+                newTimerFlushFuture);
+
+        ConcurrentMap<TaskGroupLocation, TaskGroupContext> executionContexts =
+                getField(taskExecutionService, "executionContexts");
+        ConcurrentMap<TaskGroupContext, Map<String, CompletableFuture<?>>> 
asyncFutures =
+                getField(taskExecutionService, "taskAsyncFunctionFuture");
+        ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+                timerFlushFutures = getField(taskExecutionService, 
"timerFlushFutures");
+        executionContexts.put(location, newContext);
+        asyncFutures.put(oldContext, oldAsyncFutures);
+        asyncFutures.put(newContext, newAsyncFutures);
+        timerFlushFutures.put(oldContext, oldTimerFlushFutures);
+        timerFlushFutures.put(newContext, newTimerFlushFutures);
+
+        try {
+            oldTracker.exception(new RuntimeException("stale generation task 
failed"));
+            oldTracker.taskDone(oldTask1);
+
+            Assertions.assertSame(newContext, executionContexts.get(location));
+            Assertions.assertTrue(oldAsyncFuture.isCancelled());
+            Mockito.verify(oldTimerFlushFuture).cancel(false);
+            Assertions.assertFalse(newAsyncFuture.isCancelled());
+            Mockito.verify(newTimerFlushFuture, Mockito.never()).cancel(false);
+            Assertions.assertFalse(oldResultFuture.isDone());
+        } finally {
+            executionContexts.remove(location);
+            oldTracker.taskDone(oldTask2);
+            asyncFutures.remove(newContext);
+            timerFlushFutures.remove(newContext);
+            newAsyncFuture.cancel(true);
+        }
+    }
+
     public List<Task> buildFixedTestTask(
             long callTime, long count, AtomicBoolean stopMart, 
CopyOnWriteArrayList<Long> lagList) {
         List<Task> taskQueue = new ArrayList<>();
@@ -569,6 +761,42 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         return taskQueue;
     }
 
+    private TaskGroupLocation newTaskGroupLocation() {
+        return new TaskGroupLocation(
+                System.currentTimeMillis(), pipeLineId, 
FLAKE_ID_GENERATOR.newId());
+    }
+
+    private static TaskGroupContext newTaskGroupContext(long executionId, 
TaskGroup taskGroup) {
+        ConcurrentHashMap<Long, ClassLoader> classLoaders = new 
ConcurrentHashMap<>();
+        ConcurrentHashMap<Long, Collection<URL>> jars = new 
ConcurrentHashMap<>();
+        taskGroup
+                .getTasks()
+                .forEach(
+                        task -> {
+                            classLoaders.put(
+                                    task.getTaskID(),
+                                    
Thread.currentThread().getContextClassLoader());
+                            jars.put(task.getTaskID(), 
Collections.emptyList());
+                        });
+        return new TaskGroupContext(executionId, taskGroup, classLoaders, 
jars);
+    }
+
+    private static ScheduledFuture<?> newPendingScheduledFuture() {
+        ScheduledFuture<?> future = Mockito.mock(ScheduledFuture.class);
+        Mockito.when(future.isDone()).thenReturn(false);
+        return future;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T> T getField(Object target, String fieldName) {
+        return (T)
+                ReflectionUtils.getField(target, fieldName)
+                        .orElseThrow(
+                                () ->
+                                        new AssertionError(
+                                                "Field " + fieldName + " not 
found on " + target));
+    }
+
     public List<Task> buildStopTestTask(
             long callTime,
             long count,
@@ -600,17 +828,30 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         TaskExecutionService taskExecutionService = 
server.getTaskExecutionService();
         TaskGroupLocation groupLocation = new TaskGroupLocation(jobId, 
pipeLineId, 201L);
         TaskLocation taskLocation = new TaskLocation(groupLocation, 1L, 1);
-
-        ScheduledFuture<?> future =
-                taskExecutionService.registerTimerFlushTask(taskLocation, () 
-> {}, 1_000L);
-        Assertions.assertNotNull(future);
-        Assertions.assertFalse(future.isCancelled());
-
-        taskExecutionService.closeTimerFlushTask(taskLocation);
-        Assertions.assertTrue(future.isCancelled());
-
-        // closing again is idempotent
-        Assertions.assertDoesNotThrow(() -> 
taskExecutionService.closeTimerFlushTask(taskLocation));
+        TaskGroupContext context = installTestContext(taskExecutionService, 
groupLocation);
+
+        try {
+            ScheduledFuture<?> future =
+                    taskExecutionService.registerTimerFlushTask(taskLocation, 
() -> {}, 1_000L);
+            Assertions.assertNotNull(future);
+            Assertions.assertFalse(future.isCancelled());
+
+            taskExecutionService.closeTimerFlushTask(taskLocation);
+            Assertions.assertTrue(future.isCancelled());
+
+            // Closing the last timer must not detach the deployment-level 
bucket. The owning
+            // tracker is the only component allowed to remove that bucket 
during final cleanup.
+            ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+                    timerFlushFutures = getField(taskExecutionService, 
"timerFlushFutures");
+            Assertions.assertTrue(timerFlushFutures.containsKey(context));
+            Assertions.assertTrue(timerFlushFutures.get(context).isEmpty());
+
+            // closing again is idempotent
+            Assertions.assertDoesNotThrow(
+                    () -> 
taskExecutionService.closeTimerFlushTask(taskLocation));
+        } finally {
+            removeTestContext(taskExecutionService, groupLocation, context);
+        }
     }
 
     @Test
@@ -618,18 +859,23 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         TaskExecutionService taskExecutionService = 
server.getTaskExecutionService();
         TaskGroupLocation groupLocation = new TaskGroupLocation(jobId, 
pipeLineId, 202L);
         TaskLocation taskLocation = new TaskLocation(groupLocation, 1L, 1);
-
-        ScheduledFuture<?> first =
-                taskExecutionService.registerTimerFlushTask(taskLocation, () 
-> {}, 1_000L);
-        ScheduledFuture<?> second =
-                taskExecutionService.registerTimerFlushTask(taskLocation, () 
-> {}, 2_000L);
-
-        Assertions.assertNotSame(first, second);
-        Assertions.assertTrue(
-                first.isCancelled(), "previous future must be cancelled on 
re-register");
-        Assertions.assertFalse(second.isCancelled(), "new future must remain 
active");
-
-        taskExecutionService.closeTimerFlushTask(taskLocation);
+        TaskGroupContext context = installTestContext(taskExecutionService, 
groupLocation);
+
+        try {
+            ScheduledFuture<?> first =
+                    taskExecutionService.registerTimerFlushTask(taskLocation, 
() -> {}, 1_000L);
+            ScheduledFuture<?> second =
+                    taskExecutionService.registerTimerFlushTask(taskLocation, 
() -> {}, 2_000L);
+
+            Assertions.assertNotSame(first, second);
+            Assertions.assertTrue(
+                    first.isCancelled(), "previous future must be cancelled on 
re-register");
+            Assertions.assertFalse(second.isCancelled(), "new future must 
remain active");
+
+            taskExecutionService.closeTimerFlushTask(taskLocation);
+        } finally {
+            removeTestContext(taskExecutionService, groupLocation, context);
+        }
     }
 
     @Test
@@ -641,6 +887,37 @@ public class TaskExecutionServiceTest extends 
AbstractSeaTunnelServerTest {
         Assertions.assertDoesNotThrow(() -> 
taskExecutionService.closeTimerFlushTask(unknown));
     }
 
+    private static TaskGroupContext installTestContext(
+            TaskExecutionService taskExecutionService, TaskGroupLocation 
location) {
+        TaskGroup taskGroup =
+                new TaskGroupDefaultImpl(location, "timer-test", 
Collections.emptyList());
+        TaskGroupContext context =
+                new TaskGroupContext(
+                        FLAKE_ID_GENERATOR.newId(),
+                        taskGroup,
+                        new ConcurrentHashMap<>(),
+                        new ConcurrentHashMap<>());
+        ConcurrentMap<TaskGroupLocation, TaskGroupContext> executionContexts =
+                getField(taskExecutionService, "executionContexts");
+        ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+                timerFlushFutures = getField(taskExecutionService, 
"timerFlushFutures");
+        timerFlushFutures.put(context, new ConcurrentHashMap<>());
+        executionContexts.put(location, context);
+        return context;
+    }
+
+    private static void removeTestContext(
+            TaskExecutionService taskExecutionService,
+            TaskGroupLocation location,
+            TaskGroupContext context) {
+        ConcurrentMap<TaskGroupLocation, TaskGroupContext> executionContexts =
+                getField(taskExecutionService, "executionContexts");
+        ConcurrentMap<TaskGroupContext, ConcurrentMap<TaskLocation, 
ScheduledFuture<?>>>
+                timerFlushFutures = getField(taskExecutionService, 
"timerFlushFutures");
+        executionContexts.remove(location, context);
+        timerFlushFutures.remove(context);
+    }
+
     private static class ContextInitializationFailureTask implements Task {
 
         @Override

Reply via email to