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

czy006 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git


The following commit(s) were added to refs/heads/master by this push:
     new 27ae8d55f [AMORO-4235] Recognize stale optimizer responses on the AMS 
side (#4261)
27ae8d55f is described below

commit 27ae8d55f2d300acb0d123602d90928ab7349b6b
Author: Jiwon Park <[email protected]>
AuthorDate: Thu Jul 16 00:21:04 2026 +0900

    [AMORO-4235] Recognize stale optimizer responses on the AMS side (#4261)
    
    * [AMORO-4235] Recognize stale optimizer responses on the AMS side
    
    When the OptimizerKeeper (or resetStaleTasksForThread on re-poll) resets a
    task that is still executing, the optimizer's in-flight ack/complete arrives
    after the task's token has been cleared. TaskRuntime.validThread then threw
    "Task has been reset or not yet scheduled", surfacing as an ERROR; for
    completion it also broke the SCHEDULED -> SUCCESS transition
    (IllegalTaskStateException).
    
    The AMS now recognizes stale responses by (token, threadId, status):
    - ack: rejected outside the transaction so the optimizer skips the obsolete
      round, without the misleading "failed to commit transaction" error.
    - complete: ignored gracefully, since the reported run belongs to a
      torn-down round.
    
    This fixes the rejection of valid optimizer responses. The permanent-stuck /
    uncancelable symptom in the issue could not be reproduced from the excerpt
    logs and may need the full log to confirm -- hence "Relates to" rather than
    "Close".
    
    Tests:
    - TestOptimizingQueue: unit reproductions via the pollTask path (multi-task
      stale ack, single-task stale completion).
    - TestDefaultOptimizingService: end-to-end reproduction driving the real
      OptimizerKeeper ack-timeout reset of a live optimizer's task.
    
    Relates to #4235
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4235] Fix testAckAndCompleteTask for absorbed stale completion
    
    Completing a task before ack now produces no exception (the AMS absorbs it
    as a stale response, indistinguishable from a completion for a task reset
    and re-scheduled to the same thread), so assert the task stays SCHEDULED
    instead of expecting IllegalTaskStateException.
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4235] Apply spotless formatting
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4235] Lower test ack-timeout and poll for status instead of 
sleeping 35s
    
    Address review feedback: testAckTimeoutResetThenLateAckRejected relied on 
the
    default 30s optimizer.task-ack-timeout and a fixed Thread.sleep(35000).
    
    - Override OPTIMIZER_TASK_ACK_TIMEOUT to 5s in AMSServiceTestBase. It must
      stay above optimizer.polling-timeout (3s): a blocking pollTask waiting out
      its full timeout would otherwise pick up the task the keeper reset by
      ack-timeout in the meantime (this broke testPollTaskThreeTimes at 2s).
    - Replace the fixed sleep with waitForTaskStatus polling every 100ms, so the
      test finishes right after the keeper reset lands (~6s instead of 35s).
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    * [AMORO-4235] Stamp an attempt id on schedule and ignore completions of 
previous attempts
    
    A completion delayed until the same task has been reset, re-scheduled to
    the same optimizer thread and re-acked matches (token, threadId, status)
    and was accepted as the result of the new attempt.
    
    Every schedule now stamps a monotonically increasing attempt id into the
    task properties (reaching the optimizer via the existing
    OptimizingTask.properties map), the optimizer echoes it back in the
    result summary, and complete() ignores a completion whose echoed attempt
    id does not match the current one. Results from older optimizers carry
    no attempt id and fall back to the previous checks.
    
    Signed-off-by: Jiwon Park <[email protected]>
    
    ---------
    
    Signed-off-by: Jiwon Park <[email protected]>
    Co-authored-by: ConradJam <[email protected]>
---
 .../amoro/server/optimizing/TaskRuntime.java       |  98 ++++++++++++++++--
 .../apache/amoro/server/AMSServiceTestBase.java    |   3 +
 .../amoro/server/TestDefaultOptimizingService.java |  47 ++++++++-
 .../server/optimizing/TestOptimizingQueue.java     | 115 +++++++++++++++++++++
 .../apache/amoro/process/StagedTaskDescriptor.java |   4 +
 .../apache/amoro/optimizing/TaskProperties.java    |   7 ++
 .../amoro/optimizer/common/OptimizerExecutor.java  |  22 ++++
 .../optimizer/common/TestOptimizerExecutor.java    |  36 +++++++
 8 files changed, 317 insertions(+), 15 deletions(-)

diff --git 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/TaskRuntime.java 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/TaskRuntime.java
index d0c3f9f7c..fc7e67877 100644
--- 
a/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/TaskRuntime.java
+++ 
b/amoro-ams/src/main/java/org/apache/amoro/server/optimizing/TaskRuntime.java
@@ -24,6 +24,7 @@ import org.apache.amoro.api.OptimizingTaskId;
 import org.apache.amoro.api.OptimizingTaskResult;
 import org.apache.amoro.exception.IllegalTaskStateException;
 import org.apache.amoro.exception.TaskRuntimeException;
+import org.apache.amoro.optimizing.TaskProperties;
 import org.apache.amoro.process.SimpleFuture;
 import org.apache.amoro.process.StagedTaskDescriptor;
 import org.apache.amoro.server.AmoroServiceConstants;
@@ -33,12 +34,17 @@ import org.apache.amoro.server.resource.OptimizerThread;
 import org.apache.amoro.shade.guava32.com.google.common.base.MoreObjects;
 import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableMap;
 import org.apache.amoro.shade.guava32.com.google.common.collect.ImmutableSet;
+import org.apache.amoro.shade.guava32.com.google.common.collect.Maps;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.Map;
 import java.util.Set;
 
 public class TaskRuntime<T extends StagedTaskDescriptor<?, ?, ?>> extends 
StatedPersistentBase {
 
+  private static final Logger LOG = LoggerFactory.getLogger(TaskRuntime.class);
+
   private final SimpleFuture future = new SimpleFuture();
   private final TaskStatusMachine statusMachine = new TaskStatusMachine();
   private OptimizingTaskId taskId;
@@ -82,9 +88,25 @@ public class TaskRuntime<T extends StagedTaskDescriptor<?, 
?, ?>> extends Stated
   }
 
   void complete(OptimizerThread thread, OptimizingTaskResult result) {
+    // A completion for an already reset/rescheduled task is stale: the 
execution it reports belongs
+    // to a round that the OptimizerKeeper has already torn down. Absorb it 
gracefully instead of
+    // failing the (now illegal) state transition; the task will be 
re-executed in its current
+    // round.
+    if (isStaleResponse(thread) || status != Status.ACKED || 
isStaleAttempt(result)) {
+      LOG.warn(
+          "Ignoring stale completion for task {} from optimizer thread {}, 
current status {}, "
+              + "owner {}, attempt {} (reported attempt {}). The task was 
likely reset by the "
+              + "OptimizerKeeper and will be re-executed.",
+          taskId,
+          thread,
+          status,
+          getResourceDesc(),
+          currentAttemptId(),
+          reportedAttemptId(result));
+      return;
+    }
     invokeConsistency(
         () -> {
-          validThread(thread);
           if (result.getErrorMessage() != null) {
             statusMachine.accept(Status.FAILED);
             failReason = result.getErrorMessage();
@@ -128,14 +150,27 @@ public class TaskRuntime<T extends 
StagedTaskDescriptor<?, ?, ?>> extends Stated
           token = thread.getToken();
           threadId = thread.getThreadId();
           startTime = System.currentTimeMillis();
+          stampNextAttemptId();
           persistTaskRuntime();
         });
   }
 
   void ack(OptimizerThread thread) {
+    // If the task has been reset/rescheduled, reject the stale ack so the 
optimizer skips executing
+    // this obsolete round (OptimizerExecutor treats the failure as "skip"). 
Thrown outside the
+    // transaction to avoid a misleading "failed to commit transaction" error 
for this expected
+    // case.
+    if (isStaleResponse(thread) || status != Status.SCHEDULED) {
+      LOG.warn(
+          "Rejecting stale ack for task {} from optimizer thread {}: current 
status {}, owner {}.",
+          taskId,
+          thread,
+          status,
+          getResourceDesc());
+      throw new TaskRuntimeException("Task has been reset or not yet 
scheduled, taskId:%s", taskId);
+    }
     invokeConsistency(
         () -> {
-          validThread(thread);
           statusMachine.accept(Status.ACKED);
           persistTaskRuntime();
         });
@@ -247,15 +282,56 @@ public class TaskRuntime<T extends 
StagedTaskDescriptor<?, ?, ?>> extends Stated
         .toString();
   }
 
-  private void validThread(OptimizerThread thread) {
-    if (token == null) {
-      throw new TaskRuntimeException("Task has been reset or not yet 
scheduled, taskId:%s", taskId);
-    }
-    if (!thread.getToken().equals(getToken()) || thread.getThreadId() != 
threadId) {
-      throw new TaskRuntimeException(
-          "The optimizer thread does not match, the thread in the task is 
OptimizerThread(token=%s, threadId=%s), and the thread in the request is 
OptimizerThread(token=%s, threadId=%s).",
-          getToken(), threadId, thread.getToken(), thread.getThreadId());
-    }
+  /**
+   * Detects a response from an optimizer round the task no longer belongs to: 
it was reset (token
+   * cleared) or has since been rescheduled to a different owner. Reads 
token/threadId outside
+   * {@link #invokeConsistency}, so callers must hold the owning 
TableOptimizingProcess lock, under
+   * which OptimizingQueue serializes ack/complete/reset/poll.
+   */
+  private boolean isStaleResponse(OptimizerThread thread) {
+    return token == null || !thread.getToken().equals(token) || 
thread.getThreadId() != threadId;
+  }
+
+  /**
+   * Detects a completion from a previous attempt of this task, which {@link 
#isStaleResponse}
+   * cannot: once a reset task is re-scheduled to the same optimizer thread 
and re-acked, (token,
+   * threadId, status) all match again. Optimizers echo the attempt id they 
received with the task
+   * back in the result summary; a result from an older optimizer carries none 
and is checked by
+   * (token, threadId, status) alone.
+   */
+  private boolean isStaleAttempt(OptimizingTaskResult result) {
+    String reported = reportedAttemptId(result);
+    return reported != null && !reported.equals(currentAttemptId());
+  }
+
+  /**
+   * Advances the attempt id stamped into the task properties, which reach the 
optimizer via {@link
+   * #extractProtocolTask()}. The properties map is replaced instead of 
mutated, because
+   * extractProtocolTask hands the live reference to thrift serialization 
outside the process lock.
+   * The id is persisted with the task, so it stays monotonic across AMS 
restarts; if the enclosing
+   * transaction rolls back, the in-memory increment survives (properties are 
not a {@link
+   * StateField}), which merely skips a value and never reuses one.
+   */
+  private void stampNextAttemptId() {
+    String current = currentAttemptId();
+    long next = current == null ? 1 : Long.parseLong(current) + 1;
+    Map<String, String> properties =
+        taskDescriptor.getProperties() == null
+            ? Maps.newHashMap()
+            : Maps.newHashMap(taskDescriptor.getProperties());
+    properties.put(TaskProperties.TASK_ATTEMPT_ID, Long.toString(next));
+    taskDescriptor.setProperties(properties);
+  }
+
+  private String currentAttemptId() {
+    Map<String, String> properties = taskDescriptor.getProperties();
+    return properties == null ? null : 
properties.get(TaskProperties.TASK_ATTEMPT_ID);
+  }
+
+  private static String reportedAttemptId(OptimizingTaskResult result) {
+    return result.getSummary() == null
+        ? null
+        : result.getSummary().get(TaskProperties.TASK_ATTEMPT_ID);
   }
 
   private void persistTaskRuntime() {
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/AMSServiceTestBase.java 
b/amoro-ams/src/test/java/org/apache/amoro/server/AMSServiceTestBase.java
index 27ad5e29a..b52b71c21 100644
--- a/amoro-ams/src/test/java/org/apache/amoro/server/AMSServiceTestBase.java
+++ b/amoro-ams/src/test/java/org/apache/amoro/server/AMSServiceTestBase.java
@@ -45,6 +45,9 @@ public abstract class AMSServiceTestBase extends 
AMSManagerTestBase {
       configurations.set(AmoroManagementConf.OPTIMIZER_HB_TIMEOUT, 
Duration.ofMillis(800L));
       configurations.set(
           AmoroManagementConf.OPTIMIZER_TASK_EXECUTE_TIMEOUT, 
Duration.ofMillis(30000L));
+      // must stay above OPTIMIZER_POLLING_TIMEOUT (3s): a blocking pollTask 
waiting out its full
+      // timeout would otherwise pick up the task the keeper reset by 
ack-timeout in the meantime
+      configurations.set(AmoroManagementConf.OPTIMIZER_TASK_ACK_TIMEOUT, 
Duration.ofMillis(5000L));
       configurations.set(
           AmoroManagementConf.OPTIMIZER_GROUP_MIN_PARALLELISM_CHECK_INTERVAL,
           Duration.ofMillis(10L));
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultOptimizingService.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultOptimizingService.java
index 417150b39..365eee02d 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultOptimizingService.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/TestDefaultOptimizingService.java
@@ -35,8 +35,8 @@ import org.apache.amoro.catalog.BasicCatalogTestHelper;
 import org.apache.amoro.catalog.CatalogTestHelper;
 import org.apache.amoro.config.OptimizingConfig;
 import org.apache.amoro.config.TableConfiguration;
-import org.apache.amoro.exception.IllegalTaskStateException;
 import org.apache.amoro.exception.PluginRetryAuthException;
+import org.apache.amoro.exception.TaskRuntimeException;
 import org.apache.amoro.io.MixedDataTestHelpers;
 import org.apache.amoro.optimizing.RewriteFilesOutput;
 import org.apache.amoro.optimizing.TableOptimizing;
@@ -302,9 +302,12 @@ public class TestDefaultOptimizingService extends 
AMSTableTestBase {
   public void testAckAndCompleteTask() {
     OptimizingTask task = optimizingService().pollTask(token, THREAD_ID);
     Assertions.assertNotNull(task);
-    Assertions.assertThrows(
-        IllegalTaskStateException.class,
-        () -> optimizingService().completeTask(token, 
buildOptimizingTaskResult(task.getTaskId())));
+    // Completing before ack is now treated as a stale response and absorbed 
silently (see
+    // TaskRuntime#complete): the result cannot be told apart from a stale 
completion for a task
+    // that
+    // was reset and re-scheduled to the same thread, so the task simply stays 
SCHEDULED.
+    optimizingService().completeTask(token, 
buildOptimizingTaskResult(task.getTaskId()));
+    assertTaskStatus(TaskRuntime.Status.SCHEDULED);
 
     optimizingService().ackTask(token, THREAD_ID, task.getTaskId());
 
@@ -314,6 +317,29 @@ public class TestDefaultOptimizingService extends 
AMSTableTestBase {
     assertTaskCompleted(taskRuntime);
   }
 
+  // Reproduces the EXACT path of issue #4235 end-to-end with the real 
OptimizerKeeper: a live
+  // optimizer (the Toucher keeps heartbeating) polls a task but its ack is 
delayed past
+  // OPTIMIZER_TASK_ACK_TIMEOUT (5s in tests). The keeper, via the SCHEDULED + 
ackTimeout branch of
+  // buildSuspendingPredication, resets the still-owned task to PLANNED. The 
late ack then arrives
+  // and is rejected -- this is the "Task has been reset or not yet scheduled" 
from the issue log,
+  // produced without any artificial retryTask() call.
+  @Test
+  public void testAckTimeoutResetThenLateAckRejected() throws 
InterruptedException {
+    OptimizingTask task = optimizingService().pollTask(token, THREAD_ID);
+    Assertions.assertNotNull(task);
+    assertTaskStatus(TaskRuntime.Status.SCHEDULED); // polled but NOT acked
+
+    // the optimizer stays alive (Toucher touches every 300ms), so waiting 
past the ack timeout hits
+    // the SCHEDULED + ackTimeout branch rather than the optimizer-expired 
branch: the keeper resets
+    // the task out from under the live optimizer
+    waitForTaskStatus(TaskRuntime.Status.PLANNED, 10000);
+
+    // the delayed ack arrives for the now-reset task -> rejected, exactly 
like the issue
+    Assertions.assertThrows(
+        TaskRuntimeException.class,
+        () -> optimizingService().ackTask(token, THREAD_ID, task.getTaskId()));
+  }
+
   @Test
   public void testExecuteTaskTimeOutAndRetry() throws InterruptedException {
     OptimizingTask task = optimizingService().pollTask(token, THREAD_ID);
@@ -746,6 +772,19 @@ public class TestDefaultOptimizingService extends 
AMSTableTestBase {
         
optimizingService().listTasks(defaultResourceGroup().getName()).get(0).getStatus());
   }
 
+  private void waitForTaskStatus(TaskRuntime.Status expectedStatus, long 
timeoutMs)
+      throws InterruptedException {
+    long deadline = System.currentTimeMillis() + timeoutMs;
+    while (System.currentTimeMillis() < deadline) {
+      if (expectedStatus
+          == 
optimizingService().listTasks(defaultResourceGroup().getName()).get(0).getStatus())
 {
+        return;
+      }
+      Thread.sleep(100);
+    }
+    assertTaskStatus(expectedStatus);
+  }
+
   private void assertTaskCompleted(TaskRuntime<?> taskRuntime) {
     if (taskRuntime != null) {
       Assertions.assertEquals(TaskRuntime.Status.SUCCESS, 
taskRuntime.getStatus());
diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
index 014189f64..772f15c01 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/optimizing/TestOptimizingQueue.java
@@ -39,12 +39,14 @@ import org.apache.amoro.api.OptimizingTaskId;
 import org.apache.amoro.api.OptimizingTaskResult;
 import org.apache.amoro.catalog.BasicCatalogTestHelper;
 import org.apache.amoro.catalog.CatalogTestHelper;
+import org.apache.amoro.exception.TaskRuntimeException;
 import org.apache.amoro.io.MixedDataTestHelpers;
 import org.apache.amoro.metrics.Gauge;
 import org.apache.amoro.metrics.MetricKey;
 import org.apache.amoro.metrics.MetricRegistry;
 import org.apache.amoro.optimizing.RewriteFilesOutput;
 import org.apache.amoro.optimizing.TableOptimizing;
+import org.apache.amoro.optimizing.TaskProperties;
 import org.apache.amoro.process.ProcessStatus;
 import org.apache.amoro.resource.ResourceGroup;
 import org.apache.amoro.server.manager.MetricManager;
@@ -364,6 +366,119 @@ public class TestOptimizingQueue extends AMSTableTestBase 
{
     queue.dispose();
   }
 
+  // Issue #4235 fix: when the same optimizer thread polls again while one of 
its tasks is still
+  // ACKED, pollTask -> resetStaleTasksForThread resets that task. With more 
than one task in the
+  // process, the repoll schedules a *different* task, leaving the original 
PLANNED with token ==
+  // null. The optimizer's in-flight ack for the reset task is rejected on 
purpose so the optimizer
+  // skips executing this obsolete round; the task stays PLANNED to be 
re-polled by another thread.
+  @Test
+  public void testStaleAckAfterRepollIsRejected() {
+    DefaultTableRuntime tableRuntime = initTableWithPartitionedFiles();
+    OptimizingQueue queue = buildOptimizingGroupService(tableRuntime);
+
+    // 1. poll + ack task A -> ACKED (optimizer started executing it)
+    TaskRuntime<?> taskA = queue.pollTask(optimizerThread, MAX_POLLING_TIME);
+    Assert.assertNotNull(taskA);
+    queue.ackTask(taskA.getTaskId(), optimizerThread);
+    Assert.assertEquals(TaskRuntime.Status.ACKED, taskA.getStatus());
+
+    // 2. the same thread polls again; resetStaleTasksForThread resets the 
still-executing task A
+    // and
+    //    the repoll schedules a different task B. Task A is left PLANNED with 
no token.
+    TaskRuntime<?> taskB = queue.pollTask(optimizerThread, MAX_POLLING_TIME);
+    Assert.assertNotNull(taskB);
+    Assert.assertNotEquals(taskA.getTaskId(), taskB.getTaskId());
+    Assert.assertEquals(TaskRuntime.Status.PLANNED, taskA.getStatus());
+    Assert.assertNull(taskA.getToken());
+
+    // 3. task A's in-flight ack is rejected so the optimizer skips this stale 
round (no duplicate
+    //    execution). The exception still carries the message the optimizer 
client recognizes.
+    TaskRuntimeException e =
+        Assert.assertThrows(
+            TaskRuntimeException.class, () -> queue.ackTask(taskA.getTaskId(), 
optimizerThread));
+    Assert.assertTrue(e.getMessage().contains("has been reset or not yet 
scheduled"));
+
+    // task A remains PLANNED, still waiting to be re-polled and re-executed
+    Assert.assertEquals(TaskRuntime.Status.PLANNED, taskA.getStatus());
+    queue.dispose();
+  }
+
+  // Issue #4235 fix, single-task variant: the repoll re-schedules the *same* 
task (its token is
+  // restored), so the in-flight SUCCESS result for the previous run must be 
recognized as stale and
+  // ignored -- not blow up the SCHEDULED -> SUCCESS transition. The freshly 
re-scheduled round is
+  // left intact to be acked and completed normally.
+  @Test
+  public void testStaleCompleteAfterRepollIsIgnored() {
+    DefaultTableRuntime tableRuntime = initTableWithFiles();
+    OptimizingQueue queue = buildOptimizingGroupService(tableRuntime);
+
+    TaskRuntime<?> task = queue.pollTask(optimizerThread, MAX_POLLING_TIME);
+    Assert.assertNotNull(task);
+    queue.ackTask(task.getTaskId(), optimizerThread);
+    Assert.assertEquals(TaskRuntime.Status.ACKED, task.getStatus());
+
+    // same thread polls again -> resetStaleTasksForThread resets the 
executing task, then the
+    // single
+    // task is re-scheduled back to the same thread (now SCHEDULED again, 
awaiting its own ack).
+    TaskRuntime<?> repolled = queue.pollTask(optimizerThread, 
MAX_POLLING_TIME);
+    Assert.assertSame(task, repolled);
+    Assert.assertEquals(TaskRuntime.Status.SCHEDULED, task.getStatus());
+
+    // the previous run's SUCCESS result arrives; it is stale and must be 
ignored (no exception)
+    queue.completeTask(
+        optimizerThread,
+        buildOptimizingTaskResult(task.getTaskId(), 
optimizerThread.getThreadId()));
+
+    // the current re-scheduled round is untouched: still SCHEDULED, awaiting 
its own ack
+    Assert.assertEquals(TaskRuntime.Status.SCHEDULED, task.getStatus());
+    queue.dispose();
+  }
+
+  // The (token, threadId, status) checks alone cannot tell two attempts of 
the same task on the
+  // same optimizer thread apart: once the rescheduled round is ACKED again, a 
delayed completion
+  // of the previous attempt matches all three. Every schedule therefore 
stamps an attempt id into
+  // the task properties, and a completion echoing a different attempt id must 
be ignored.
+  @Test
+  public void testStaleCompleteFromPreviousAttemptIsIgnored() {
+    DefaultTableRuntime tableRuntime = initTableWithFiles();
+    OptimizingQueue queue = buildOptimizingGroupService(tableRuntime);
+
+    // 1. first attempt: poll + ack, the optimizer starts executing
+    TaskRuntime<?> task = queue.pollTask(optimizerThread, MAX_POLLING_TIME);
+    Assert.assertNotNull(task);
+    String firstAttempt =
+        
task.extractProtocolTask().getProperties().get(TaskProperties.TASK_ATTEMPT_ID);
+    Assert.assertNotNull(firstAttempt);
+    queue.ackTask(task.getTaskId(), optimizerThread);
+
+    // 2. the same thread polls again: the task is reset and re-scheduled to 
the SAME thread with
+    //    a new attempt id, then the second attempt is acked -> (token, 
threadId, ACKED) is now
+    //    identical to what the first attempt's completion will carry
+    TaskRuntime<?> repolled = queue.pollTask(optimizerThread, 
MAX_POLLING_TIME);
+    Assert.assertSame(task, repolled);
+    String secondAttempt =
+        
task.extractProtocolTask().getProperties().get(TaskProperties.TASK_ATTEMPT_ID);
+    Assert.assertNotEquals(firstAttempt, secondAttempt);
+    queue.ackTask(task.getTaskId(), optimizerThread);
+    Assert.assertEquals(TaskRuntime.Status.ACKED, task.getStatus());
+
+    // 3. the delayed completion of the FIRST attempt arrives; only the echoed 
attempt id can
+    //    expose it as stale -> ignored, the second attempt keeps executing
+    OptimizingTaskResult staleResult =
+        buildOptimizingTaskResult(task.getTaskId(), 
optimizerThread.getThreadId());
+    staleResult.setSummary(ImmutableMap.of(TaskProperties.TASK_ATTEMPT_ID, 
firstAttempt));
+    queue.completeTask(optimizerThread, staleResult);
+    Assert.assertEquals(TaskRuntime.Status.ACKED, task.getStatus());
+
+    // 4. the second attempt's own completion (echoing the current attempt id) 
is accepted
+    OptimizingTaskResult currentResult =
+        buildOptimizingTaskResult(task.getTaskId(), 
optimizerThread.getThreadId());
+    currentResult.setSummary(ImmutableMap.of(TaskProperties.TASK_ATTEMPT_ID, 
secondAttempt));
+    queue.completeTask(optimizerThread, currentResult);
+    Assert.assertEquals(TaskRuntime.Status.SUCCESS, task.getStatus());
+    queue.dispose();
+  }
+
   @Test
   public void testCommitTask() {
     DefaultTableRuntime tableRuntime = initTableWithFiles();
diff --git 
a/amoro-common/src/main/java/org/apache/amoro/process/StagedTaskDescriptor.java 
b/amoro-common/src/main/java/org/apache/amoro/process/StagedTaskDescriptor.java
index 638cb5971..0074e7a9f 100644
--- 
a/amoro-common/src/main/java/org/apache/amoro/process/StagedTaskDescriptor.java
+++ 
b/amoro-common/src/main/java/org/apache/amoro/process/StagedTaskDescriptor.java
@@ -85,6 +85,10 @@ public abstract class StagedTaskDescriptor<I, O, S> {
     return properties;
   }
 
+  public void setProperties(Map<String, String> properties) {
+    this.properties = properties;
+  }
+
   public S getSummary() {
     return summary;
   }
diff --git 
a/amoro-format-iceberg/src/main/java/org/apache/amoro/optimizing/TaskProperties.java
 
b/amoro-format-iceberg/src/main/java/org/apache/amoro/optimizing/TaskProperties.java
index 6a88da6b3..ede5467dc 100644
--- 
a/amoro-format-iceberg/src/main/java/org/apache/amoro/optimizing/TaskProperties.java
+++ 
b/amoro-format-iceberg/src/main/java/org/apache/amoro/optimizing/TaskProperties.java
@@ -31,6 +31,13 @@ public class TaskProperties {
   public static final String PROCESS_ID = "process-id";
   public static final String UNKNOWN_PROCESS_ID = "unknown";
 
+  /**
+   * Attempt id of a task execution round, stamped by AMS on every schedule 
and echoed back by the
+   * optimizer in the result summary, so AMS can tell a stale completion of a 
previous attempt apart
+   * from the current one.
+   */
+  public static final String TASK_ATTEMPT_ID = "task-attempt-id";
+
   public static final String EXTEND_DISK_STORAGE =
       OptimizerProperties.OPTIMIZER_EXTEND_DISK_STORAGE;
   public static final boolean EXTEND_DISK_STORAGE_DEFAULT = false;
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
index 2868cf083..371cc9069 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/main/java/org/apache/amoro/optimizer/common/OptimizerExecutor.java
@@ -156,6 +156,9 @@ public class OptimizerExecutor extends 
AbstractOptimizerOperator {
           }
         } finally {
           if (result != null) {
+            // every completion passes through here, including error results 
built by executor
+            // subclasses (e.g. SparkOptimizerExecutor), so the attempt id is 
echoed exactly once
+            echoTaskAttemptId(ackTask, result);
             completeTask(amsUrl, result);
           }
         }
@@ -328,6 +331,25 @@ public class OptimizerExecutor extends 
AbstractOptimizerOperator {
     }
   }
 
+  /**
+   * Echoes the attempt id received with the task back in the result summary, 
so AMS can tell a
+   * stale completion of a previous attempt apart from the current one. Tasks 
from older AMS
+   * versions carry no attempt id and the result is left untouched.
+   */
+  private static void echoTaskAttemptId(OptimizingTask task, 
OptimizingTaskResult result) {
+    String attemptId =
+        task.getProperties() == null
+            ? null
+            : task.getProperties().get(TaskProperties.TASK_ATTEMPT_ID);
+    if (attemptId == null) {
+      return;
+    }
+    Map<String, String> summary =
+        result.getSummary() == null ? Maps.newHashMap() : 
Maps.newHashMap(result.getSummary());
+    summary.put(TaskProperties.TASK_ATTEMPT_ID, attemptId);
+    result.setSummary(summary);
+  }
+
   private static Map<String, String> fillTaskProperties(
       OptimizerConfig config, OptimizingTask task) {
     if (config.isCacheEnabled()) {
diff --git 
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
 
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
index 866ad655a..3b4f9d933 100644
--- 
a/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
+++ 
b/amoro-optimizer/amoro-optimizer-common/src/test/java/org/apache/amoro/optimizer/common/TestOptimizerExecutor.java
@@ -158,6 +158,42 @@ public class TestOptimizerExecutor extends 
OptimizerTestBase {
         1, 
TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token).size());
   }
 
+  @Test
+  public void testEchoTaskAttemptIdOnSuccess() throws InterruptedException, 
TException {
+    TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
+    String token =
+        
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().keySet().iterator().next();
+    OptimizingTask task = TestOptimizingInput.successInput(1).toTask(0, 0);
+    task.getProperties().put(TaskProperties.TASK_ATTEMPT_ID, "7");
+    TEST_AMS.getOptimizerHandler().offerTask(task);
+    optimizerExecutor.setToken(token);
+    TimeUnit.MILLISECONDS.sleep(OptimizerTestHelpers.CALL_AMS_INTERVAL * 2);
+    OptimizingTaskResult taskResult =
+        TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token).get(0);
+    Assertions.assertNull(taskResult.getErrorMessage());
+    // TestOptimizingOutput.summary() is null, so the echo must create the 
summary map itself
+    Assertions.assertNotNull(taskResult.getSummary());
+    Assertions.assertEquals("7", 
taskResult.getSummary().get(TaskProperties.TASK_ATTEMPT_ID));
+  }
+
+  @Test
+  public void testEchoTaskAttemptIdOnFailure() throws InterruptedException, 
TException {
+    TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());
+    String token =
+        
TEST_AMS.getOptimizerHandler().getRegisteredOptimizers().keySet().iterator().next();
+    OptimizingTask task = TestOptimizingInput.failedInput(1).toTask(0, 0);
+    task.getProperties().put(TaskProperties.TASK_ATTEMPT_ID, "7");
+    TEST_AMS.getOptimizerHandler().offerTask(task);
+    optimizerExecutor.setToken(token);
+    TimeUnit.MILLISECONDS.sleep(OptimizerTestHelpers.CALL_AMS_INTERVAL * 2);
+    OptimizingTaskResult taskResult =
+        TEST_AMS.getOptimizerHandler().getCompletedTasks().get(token).get(0);
+    // an error result reports the attempt id too, so a stale FAILED 
completion cannot poison the
+    // retry counting of the current attempt
+    
Assertions.assertTrue(taskResult.getErrorMessage().contains(FAILED_TASK_MESSAGE));
+    Assertions.assertEquals("7", 
taskResult.getSummary().get(TaskProperties.TASK_ATTEMPT_ID));
+  }
+
   @Test
   public void testExecuteTaskFailed() throws InterruptedException, TException {
     TEST_AMS.getOptimizerHandler().authenticate(new OptimizerRegisterInfo());

Reply via email to