You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by we...@apache.org on 2026/09/29 08:49:57 UTC

(spark) branch branch-4.x updated: [SPARK-59845][CORE] Register an accumulator with the TaskContext only after it is fully deserialized

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

cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new ba033ef70ab7 [SPARK-59845][CORE] Register an accumulator with the TaskContext only after it is fully deserialized
ba033ef70ab7 is described below

commit ba033ef70ab71a5607dd508e5d241afc5d72a54b
Author: Ziqi Liu <zi...@databricks.com>
AuthorDate: Tue Sep 29 08:49:18 2026 +0000

    [SPARK-59845][CORE] Register an accumulator with the TaskContext only after it is fully deserialized
    
    ### What changes were proposed in this pull request?
    
    Move the `TaskContext` registration out of `readObject` and into `readResolve`.
    
    `AccumulatorV2` currently registers from `readObject`, which runs at AccumulatorV2's own slot. Java serialization reads hierarchy data **superclass-first**, so no subclass field is assigned yet — while `registerAccumulator` publishes `this` into `TaskMetrics` where other threads can read it. The PR drops `readObject` (beyond registering, all it did was in.defaultReadObject(), which the framework does anyway) and registers from `final protected def readResolve()` instead, which runs on [...]
    
    It's `final` for the same reason `writeReplace` already is: `readObject` must be `private` so it's invoked per class, whereas `readResolve` is inherited — a subclass defining one would silently stop registration.
    
    ### Why are the changes needed?
    
    Subclasses are published before their state exists, so a subclass holding state in a `val` exposes a `null`. The reader that hits it is the executor heartbeater, calling `isZero` on every registered accumulator. The NPE propagates out of a `scheduleAtFixedRate` task, so `ScheduledThreadPoolExecutor` stops rescheduling — the executor never heartbeats again, the driver removes it on timeout.
    
    Plus the JLS 17.5.3 aspect: a val is a final field written reflectively after publication, so a reader has no guarantee of seeing it and the JIT may fold the read.
    
    SPARK-20977 fixed this locally in CollectionAccumulator; every subclass since has re-derived the workaround and not all have — SetAccumulator in sql/execution/debug still dereferences private val _set from isZero, and EventTimeStatsAccum escapes the NPE only incidentally via Scala's null-safe ==, reporting the wrong answer instead.
    
    ### Does this PR introduce _any_ user-facing change?
    NO
    
    ### How was this patch tested?
    New test case
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Yes. Claude Opus 5.0
    
    Closes #59124 from liuzqt/accumulator-register-after-deserialization.
    
    Authored-by: Ziqi Liu <zi...@databricks.com>
    Signed-off-by: Wenchen Fan <we...@databricks.com>
    (cherry picked from commit 5b7d3fe1d74733926ad60c108cd3f2f502e85498)
    Signed-off-by: Wenchen Fan <we...@databricks.com>
---
 .../org/apache/spark/util/AccumulatorV2.scala      | 15 ++++--
 .../org/apache/spark/util/AccumulatorV2Suite.scala | 62 ++++++++++++++++++++++
 2 files changed, 73 insertions(+), 4 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/util/AccumulatorV2.scala b/core/src/main/scala/org/apache/spark/util/AccumulatorV2.scala
index 7e2047526da0..20b2baef0f23 100644
--- a/core/src/main/scala/org/apache/spark/util/AccumulatorV2.scala
+++ b/core/src/main/scala/org/apache/spark/util/AccumulatorV2.scala
@@ -18,7 +18,6 @@
 package org.apache.spark.util
 
 import java.{lang => jl}
-import java.io.ObjectInputStream
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.atomic.AtomicLong
 
@@ -197,9 +196,16 @@ abstract class AccumulatorV2[IN, OUT] extends Serializable {
     }
   }
 
-  // Called by Java when deserializing an object
-  private def readObject(in: ObjectInputStream): Unit = Utils.tryOrIOException {
-    in.defaultReadObject()
+  // Called by Java once this object is fully deserialized -- after every class in the hierarchy has
+  // had its fields read, including a subclass's. This must not move to `readObject`, which runs at
+  // this class's slot while a subclass's fields are still unset: `registerAccumulator` publishes
+  // `this` where other threads can reach it, and the executor heartbeater calls `isZero` on every
+  // registered accumulator. Each subclass would then have to tolerate its own state being null,
+  // which is the hazard SPARK-20977 worked around in `CollectionAccumulator`.
+  // `final` for the same reason as `writeReplace` above: unlike `readObject`, which is private and
+  // therefore invoked once per class in the hierarchy, `readResolve` is inherited, so a subclass
+  // defining one would silently replace this and stop registering the accumulator entirely.
+  final protected def readResolve(): Any = Utils.tryOrIOException {
     if (atDriverSide) {
       atDriverSide = false
 
@@ -213,6 +219,7 @@ abstract class AccumulatorV2[IN, OUT] extends Serializable {
     } else {
       atDriverSide = true
     }
+    this
   }
 
   override def toString: String = {
diff --git a/core/src/test/scala/org/apache/spark/util/AccumulatorV2Suite.scala b/core/src/test/scala/org/apache/spark/util/AccumulatorV2Suite.scala
index 172bebbfec61..22e518da5b2c 100644
--- a/core/src/test/scala/org/apache/spark/util/AccumulatorV2Suite.scala
+++ b/core/src/test/scala/org/apache/spark/util/AccumulatorV2Suite.scala
@@ -17,7 +17,10 @@
 
 package org.apache.spark.util
 
+import java.util.Properties
+
 import org.apache.spark._
+import org.apache.spark.executor.TaskMetrics
 
 class AccumulatorV2Suite extends SparkFunSuite {
 
@@ -127,6 +130,65 @@ class AccumulatorV2Suite extends SparkFunSuite {
     assert(acc3.value.isEmpty)
   }
 
+  test("SPARK-59845: a subclass is registered only once it is fully deserialized") {
+    // Deserialization registers the accumulator with the `TaskContext`, which publishes it to other
+    // threads -- in production the executor heartbeater, which calls `isZero` on every registered
+    // accumulator. Registering before the subclass's fields have been read exposes state that is
+    // still unset, so a subclass holding its state in a plain `val` would see a null there.
+    // Probe from `registerAccumulator` to observe that instant without racing a real reader.
+    val acc = new NaiveStateAccumulator
+    acc.metadata = AccumulatorMetadata(AccumulatorContext.newId(), None, countFailedValues = false)
+    AccumulatorContext.register(acc)
+
+    var probed = false
+    val taskContext = new TaskContextImpl(
+      stageId = 0,
+      stageAttemptNumber = 0,
+      partitionId = 0,
+      taskAttemptId = 0L,
+      attemptNumber = 0,
+      numPartitions = 1,
+      taskMemoryManager = null,
+      localProperties = new Properties,
+      metricsSystem = null,
+      taskMetrics = TaskMetrics.empty,
+      cpuAmount = BigDecimal(1)) {
+      private[spark] override def registerAccumulator(a: AccumulatorV2[_, _]): Unit = {
+        assert(a.isZero, "accumulator state should be readable as soon as it is registered")
+        probed = true
+      }
+    }
+
+    TaskContext.setTaskContext(taskContext)
+    try {
+      Utils.deserialize[NaiveStateAccumulator](Utils.serialize(acc))
+    } finally {
+      TaskContext.unset()
+      AccumulatorContext.remove(acc.id)
+    }
+    assert(probed, "the accumulator was never registered, so nothing was verified")
+  }
 }
 
 class MyData(val i: Int) extends Serializable
+
+/**
+ * Holds its state the way a subclass naturally would, in a `val` dereferenced by `isZero`, with no
+ * null handling. Registration must therefore happen only after this field has been deserialized.
+ */
+private class NaiveStateAccumulator extends AccumulatorV2[Int, java.util.List[Int]] {
+  private val state = new java.util.ArrayList[Int]()
+
+  override def isZero: Boolean = state.isEmpty
+  override def copyAndReset(): NaiveStateAccumulator = new NaiveStateAccumulator
+  override def copy(): NaiveStateAccumulator = {
+    val newAcc = new NaiveStateAccumulator
+    newAcc.state.addAll(state)
+    newAcc
+  }
+  override def reset(): Unit = state.clear()
+  override def add(v: Int): Unit = state.add(v)
+  override def merge(other: AccumulatorV2[Int, java.util.List[Int]]): Unit =
+    state.addAll(other.value)
+  override def value: java.util.List[Int] = state
+}


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