You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by do...@apache.org on 2026/09/28 14:46:05 UTC

(spark) branch branch-4.0 updated: [SPARK-59819][CORE] Fix `Resubmitted` task end accounting in `AppStatusListener`

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

dongjoon-hyun pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new 167505c411d3 [SPARK-59819][CORE] Fix `Resubmitted` task end accounting in `AppStatusListener`
167505c411d3 is described below

commit 167505c411d38d9eacfeff2f9f2315417da6db37
Author: Dongjoon Hyun <do...@apache.org>
AuthorDate: Mon Sep 28 07:34:42 2026 -0700

    [SPARK-59819][CORE] Fix `Resubmitted` task end accounting in `AppStatusListener`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to fix two `Resubmitted` accounting bugs in `AppStatusListener.onTaskEnd`.
    
    When an executor is lost, `TaskSetManager.executorLost` posts `SparkListenerTaskEnd` with `Resubmitted` reason for the already-successful shuffle map tasks of that executor (without external shuffle service or on decommission) if the task set is not zombie. This event reuses the finished `TaskInfo` of the successful attempt and has no matching `SparkListenerTaskStart`. Since SPARK-41187 (apache/spark#38702), `onTaskEnd` uses `activeDelta = 0` for `Resubmitted`, but the following place [...]
    
    1. The speculation summary decrements `numActiveTasks` unconditionally. This PR uses `activeDelta` like the other active task counters.
    2. `ExecutorStageSummary.taskTime` and `ExecutorSummary.totalDuration` add `event.taskInfo.duration` again although the duration of the successful attempt was already counted. This PR skips them for `Resubmitted` like the executor-level task metrics, which are already skipped for `Resubmitted`.
    
    ### Why are the changes needed?
    
    To show correct values.
    
    - When a successful speculative task is resubmitted, `numActiveTasks` of the speculation summary becomes negative (e.g. `-1`), and nothing corrects it later because only `onTaskStart` and `onTaskEnd` update it. This value is stored in `SpeculationStageSummaryWrapper` and shown via the REST API `speculationSummary` field and the `Speculation Summary` table of the stage page.
    - For every `Resubmitted` task end, the duration of the successful attempt is counted twice in `ExecutorStageSummary.taskTime` (`Task Time` of `Aggregated Metrics by Executor` table in the stage page). `ExecutorSummary.totalDuration` (`Task Time (GC Time)` in the executors page and `totalDuration_seconds_total` in the Prometheus metrics) is also counted twice if the `Resubmitted` task end arrives before `SparkListenerExecutorRemoved` of the lost executor. This is timing-dependent beca [...]
    
    These were found during the review of apache/spark#58968.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, this fixes the incorrect values in the Spark UI, REST API, and Prometheus metrics. The negative `numActiveTasks` of the speculation summary exists since Apache Spark [v3.3.0](https://github.com/apache/spark/releases/tag/v3.3.0) (2022-06-09), and the double-counted task duration exists since Apache Spark [v2.3.0](https://github.com/apache/spark/releases/tag/v2.3.0) (2018-02-22).
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test case. I also manually verified that the new test case fails without this fix.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #59092 from dongjoon-hyun/SPARK-59819.
    
    Authored-by: Dongjoon Hyun <do...@apache.org>
    Signed-off-by: Dongjoon Hyun <do...@apache.org>
    (cherry picked from commit b1b2685058d6359e00842cbd4c723f4ce3810709)
    Signed-off-by: Dongjoon Hyun <do...@apache.org>
---
 .../apache/spark/status/AppStatusListener.scala    |  9 +++--
 .../spark/status/AppStatusListenerSuite.scala      | 39 ++++++++++++++++++++++
 2 files changed, 45 insertions(+), 3 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/status/AppStatusListener.scala b/core/src/main/scala/org/apache/spark/status/AppStatusListener.scala
index 5c93bf4bf77a..076a84bc2fea 100644
--- a/core/src/main/scala/org/apache/spark/status/AppStatusListener.scala
+++ b/core/src/main/scala/org/apache/spark/status/AppStatusListener.scala
@@ -741,7 +741,10 @@ private[spark] class AppStatusListener(
       }
 
       val esummary = stage.executorSummary(event.taskInfo.executorId)
-      esummary.taskTime += event.taskInfo.duration
+      // `Resubmitted` reuses the finished TaskInfo whose duration is already counted.
+      if (event.reason != Resubmitted) {
+        esummary.taskTime += event.taskInfo.duration
+      }
       esummary.succeededTasks += completedDelta
       esummary.failedTasks += failedDelta
       esummary.killedTasks += killedDelta
@@ -760,7 +763,7 @@ private[spark] class AppStatusListener(
       }
 
       if (event.taskInfo.speculative) {
-        stage.speculationStageSummary.numActiveTasks -= 1
+        stage.speculationStageSummary.numActiveTasks -= activeDelta
         stage.speculationStageSummary.numCompletedTasks += completedDelta
         stage.speculationStageSummary.numFailedTasks += failedDelta
         stage.speculationStageSummary.numKilledTasks += killedDelta
@@ -782,7 +785,6 @@ private[spark] class AppStatusListener(
       exec.activeTasks -= activeDelta
       exec.completedTasks += completedDelta
       exec.failedTasks += failedDelta
-      exec.totalDuration += event.taskInfo.duration
       exec.peakExecutorMetrics.compareAndUpdatePeakValues(event.taskExecutorMetrics)
 
       // Note: For resubmitted tasks, we continue to use the metrics that belong to the
@@ -790,6 +792,7 @@ private[spark] class AppStatusListener(
       // could have failed half-way through. The correct fix would be to keep track of the
       // metrics added by each attempt, but this is much more complicated.
       if (event.reason != Resubmitted) {
+        exec.totalDuration += event.taskInfo.duration
         if (event.taskMetrics != null) {
           val readMetrics = event.taskMetrics.shuffleReadMetrics
           exec.totalGcTime += event.taskMetrics.jvmGCTime
diff --git a/core/src/test/scala/org/apache/spark/status/AppStatusListenerSuite.scala b/core/src/test/scala/org/apache/spark/status/AppStatusListenerSuite.scala
index 4d75f5d7a1fc..cee2c362e9c9 100644
--- a/core/src/test/scala/org/apache/spark/status/AppStatusListenerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/status/AppStatusListenerSuite.scala
@@ -1911,6 +1911,45 @@ abstract class AppStatusListenerSuite extends SparkFunSuite with BeforeAndAfter
     assert(listener.deadExecutors.size === 0)
   }
 
+  test("SPARK-59819: Resubmitted should not update speculative active tasks and task duration") {
+    val listener = new AppStatusListener(store, conf, true)
+    val appStore = new AppStatusStore(store)
+
+    listener.onExecutorAdded(createExecutorAddedEvent(1))
+    listener.onExecutorAdded(createExecutorAddedEvent(2))
+    val stage = new StageInfo(1, 0, "stage", 4, Nil, Nil, "details",
+      resourceProfileId = ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID)
+    listener.onJobStart(SparkListenerJobStart(1, time, Seq(stage), null))
+
+    time += 1
+    stage.submissionTime = Some(time)
+    listener.onStageSubmitted(SparkListenerStageSubmitted(stage, new Properties()))
+
+    val tasks = createTasks(2, Array("1", "2"))
+    tasks.foreach { task =>
+      listener.onTaskStart(SparkListenerTaskStart(stage.stageId, stage.attemptNumber(), task))
+    }
+    val speculativeTask = tasks(1)
+    assert(speculativeTask.speculative)
+
+    time += 1
+    speculativeTask.markFinished(TaskState.FINISHED, time)
+    listener.onTaskEnd(SparkListenerTaskEnd(stage.stageId, stage.attemptNumber(), "taskType",
+      Success, speculativeTask, new ExecutorMetrics, null))
+
+    // executor lost, success speculative task will be resubmitted with the same TaskInfo
+    time += 1
+    listener.onTaskEnd(SparkListenerTaskEnd(stage.stageId, stage.attemptNumber(), "taskType",
+      Resubmitted, speculativeTask, new ExecutorMetrics, null))
+
+    val execId = speculativeTask.executorId
+    assert(appStore.speculationSummary(stage.stageId, stage.attemptNumber())
+      .map(_.numActiveTasks) === Some(0))
+    assert(appStore.executorSummary(stage.stageId, stage.attemptNumber())(execId).taskTime ===
+      speculativeTask.duration)
+    assert(appStore.executorSummary(execId).totalDuration === speculativeTask.duration)
+  }
+
   test("SPARK-41683: Should correctly calculate numActiveStages if some stages are not submitted") {
     val stage1 = new StageInfo( 0, 0, "stage1", 0, Seq.empty, Seq.empty, "", resourceProfileId = 0)
     val stage2 = new StageInfo( 1, 0, "stage2", 0, Seq.empty, Seq.empty, "", resourceProfileId = 0)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org