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 970b4dc006eb [SPARK-57491][CORE] Prevent and detect stale push-based
shuffle data from duplicate map task attempts
970b4dc006eb is described below
commit 970b4dc006ebcc1a5085c45c970340861ddc2f9c
Author: gaoyajun02 <[email protected]>
AuthorDate: Thu Jul 9 00:48:39 2026 +0800
[SPARK-57491][CORE] Prevent and detect stale push-based shuffle data from
duplicate map task attempts
### What changes were proposed in this pull request?
Fix push-based shuffle serving stale/incorrect data when multiple task
attempts for the same map partition both push data to the external merger.
Three layers of defense:
- **ShuffleWriteProcessor** — Defer shuffle block push via
`PostStatusUpdateListener` so that killed/failed tasks never push. The listener
checks `context.isInterrupted()` / `context.isFailed()` before initiating push,
and logs which task was skipped and why.
- **TaskSetManager + MapOutputTracker** — Track stale (duplicate)
partitionIds on the driver side. When a speculative or retried ShuffleMapTask
result arrives after another attempt for the same partition has already
committed, `TaskSetManager.detectStalePushIfShuffleTask` marks the partition's
ID as stale in `ShuffleStatus.staleMapIndexes`. The stale set is serialized
alongside existing map/merge output metadata and propagated to executor-side
`MapOutputTrackerWorker`.
- **PushBasedFetchHelper (`ShuffleBlockFetcherIterator`)** — Before reading
any merged block on the reducer side, check chunk-level RoaringBitmaps for
stale partitionIds via the new `checkStaleMapIdInMergedBlock` method. If a
stale mapIndex is found in any chunk bitmap, log a WARN and fall back to
fetching original unmerged blocks. When the stale set is empty (the common
case), this check short-circuits immediately with zero overhead.
### Why are the changes needed?
With push-based shuffle enabled, indeterminate stages can produce incorrect
results when speculation or task retry causes two attempts of the same map
partition to both push data to the external merger. The merged block ends up
with interleaved data from both attempts, but only one attempt's MapStatus is
committed on the driver. Downstream reducers silently read corrupted/duplicated
data.
This is triggered by non-deterministic functions (`rand()`, `UUID()`, etc.)
in shuffle keys or join conditions where row ordering differs between attempts.
Even with deferred push (layer 1), there is a race window: if an original
attempt succeeds and pushes, then gets killed by a speculative winner that also
pushes, the merger receives data from both attempts but only one MapStatus is
registered.
Why the existing checksum-mismatch / rollback machinery does not cover this
case?
The existing checksum-mismatch detection in
`DAGScheduler.handleTaskCompletion` relies on comparing
`MapStatus.checksumValue` values via `MapOutputTracker.registerMapOutput()`.
This only works for task results reported as **Success** — the `MapStatus` is
registered and compared against any previously registered status for the same
partition.
In speculation with push-based shuffle, the **losing attempt's result never
reaches `registerMapOutput()`**: when a later-arriving or killed speculative
attempt is detected in `TaskSetManager.handleSuccessfulTask`, it is handled as
`TaskKilled` (not `Success`) and forwarded to DAGScheduler as a kill event,
which bypasses the `registerMapOutput` path entirely. Therefore no checksum
comparison occurs for the loser's output.
Furthermore, even if this path were reachable,
`checksumMismatchFullRetryEnabled` is **force-disabled** when push-based
shuffle is active (`Dependency.scala:148-149`), since the two features are
designed for mutually exclusive scenarios: checksum mismatch triggers
stage-level rollback with full retry of all downstream stages, while stale push
detection handles the problem at the partition level with a lightweight fetch
fallback.
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
- UT
- Manual verification on internal cluster confirmed: speculative duplicate
pushes are either prevented by deferred push or detected and fallen back from,
with expected WARN log on fallback.
### Was this patch authored or co-authored using generative AI tooling?
Yes, co-authored with GLM-5V-Turbo.
Closes #56559 from gaoyajun02/SPARK-33235.
Lead-authored-by: gaoyajun02 <[email protected]>
Co-authored-by: gaoyajun02 <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit 50932d2f0f1aca02d594f4d8bc367a5ad91cf840)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../org/apache/spark/BarrierTaskContext.scala | 9 ++
.../scala/org/apache/spark/MapOutputTracker.scala | 149 ++++++++++++++++++++-
.../main/scala/org/apache/spark/TaskContext.scala | 19 ++-
.../scala/org/apache/spark/TaskContextImpl.scala | 20 +++
.../scala/org/apache/spark/executor/Executor.scala | 3 +
.../apache/spark/scheduler/TaskSetManager.scala | 30 +++++
.../spark/shuffle/ShuffleWriteProcessor.scala | 26 +++-
.../spark/storage/PushBasedFetchHelper.scala | 54 +++++++-
.../org/apache/spark/util/taskListeners.scala | 17 +++
.../org/apache/spark/MapOutputTrackerSuite.scala | 54 +++++++-
.../apache/spark/scheduler/TaskContextSuite.scala | 70 ++++++++++
.../spark/scheduler/TaskSetManagerSuite.scala | 83 ++++++++++++
.../storage/ShuffleBlockFetcherIteratorSuite.scala | 81 +++++++++++
project/MimaExcludes.scala | 5 +-
14 files changed, 603 insertions(+), 17 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/BarrierTaskContext.scala
b/core/src/main/scala/org/apache/spark/BarrierTaskContext.scala
index 7f5d1f39d86c..49a5e77764a6 100644
--- a/core/src/main/scala/org/apache/spark/BarrierTaskContext.scala
+++ b/core/src/main/scala/org/apache/spark/BarrierTaskContext.scala
@@ -295,6 +295,15 @@ class BarrierTaskContext private[spark] (
: T = {
taskContext.createResourceUninterruptibly(resourceBuilder)
}
+
+ override def addPostStatusUpdateListener(listener:
PostStatusUpdateListener): TaskContext = {
+ taskContext.addPostStatusUpdateListener(listener)
+ this
+ }
+
+ override private[spark] def invokePostStatusUpdateListeners(): Unit = {
+ taskContext.invokePostStatusUpdateListeners()
+ }
}
@Experimental
diff --git a/core/src/main/scala/org/apache/spark/MapOutputTracker.scala
b/core/src/main/scala/org/apache/spark/MapOutputTracker.scala
index 41a1b51a4315..51ba146f728f 100644
--- a/core/src/main/scala/org/apache/spark/MapOutputTracker.scala
+++ b/core/src/main/scala/org/apache/spark/MapOutputTracker.scala
@@ -17,7 +17,7 @@
package org.apache.spark
-import java.io.{ByteArrayInputStream, InputStream, IOException,
ObjectInputStream, ObjectOutputStream}
+import java.io._
import java.nio.ByteBuffer
import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue,
ThreadPoolExecutor, TimeUnit}
import java.util.concurrent.locks.ReentrantReadWriteLock
@@ -105,6 +105,32 @@ private class ShuffleStatus(
*/
private[spark] val checksumMismatchIndices: Set[Int] = Set()
+ /**
+ * Set of stale pushed partition indexes for this shuffle. Each entry is a
partitionId (which
+ * equals mapIndex, not MapStatus.mapId). When task retry or speculation
causes multiple
+ * attempts for the same map output to push, the merger may include data
from a stale attempt.
+ * We record the stale partition indexes here so the reduce side can check
chunkBitmaps and
+ * fallback if stale data is present in a merged block.
+ */
+ private[this] val staleMapIndexes = new java.util.HashSet[Int]()
+
+ /**
+ * Mark a partition as having stale (redundant) push attempts. Called from
TaskSetManager when it
+ * detects that multiple task attempts for the same map output pushed data
to the merger.
+ * @param mapIndex the partition index (== mapIndex) of the stale
(redundant) attempt;
+ * this is NOT MapStatus.mapId
+ */
+ def markStalePushedPartition(mapIndex: Int): Unit = withWriteLock {
+ staleMapIndexes.add(mapIndex)
+ }
+
+ /**
+ * Get all stale pushed partition indexes for this shuffle. Returns empty
set if none exist.
+ */
+ def getStaleMapIndexes: Set[Int] = withReadLock {
+ new java.util.HashSet[Int](staleMapIndexes).asScala
+ }
+
/**
* MergeStatus for each shuffle partition when push-based shuffle is
enabled. The index of the
* array is the shuffle partition id (reduce id). Each value in the array is
the MergeStatus for
@@ -413,7 +439,7 @@ private class ShuffleStatus(
broadcastManager: BroadcastManager,
isLocal: Boolean,
minBroadcastSize: Int,
- conf: SparkConf): (Array[Byte], Array[Byte]) = {
+ conf: SparkConf): (Array[Byte], Array[Byte], Array[Byte]) = {
val mapStatusesBytes: Array[Byte] =
serializedMapStatus(broadcastManager, isLocal, minBroadcastSize, conf)
var mergeStatusesBytes: Array[Byte] = null
@@ -437,7 +463,12 @@ private class ShuffleStatus(
// `withWriteLock`.
mergeStatusesBytes = cachedSerializedMergeStatus
}
- (mapStatusesBytes, mergeStatusesBytes)
+
+ // Serialize staleMapIndexes set for reduce-side stale chunk-level
detection.
+ val staleMapIndexBytes = withReadLock {
+ MapOutputTracker.serializeStaleMapIndexes(staleMapIndexes)
+ }
+ (mapStatusesBytes, mergeStatusesBytes, staleMapIndexBytes)
}
// Used in testing.
@@ -688,6 +719,12 @@ private[spark] abstract class MapOutputTracker(conf:
SparkConf) extends Logging
*/
def getShufflePushMergerLocations(shuffleId: Int): Seq[BlockManagerId]
+ /**
+ * Get all stale pushed partition indexes for a given shuffle.
+ * Called from PushBasedFetchHelper on the reduce side for chunk-level
detection.
+ */
+ def getStaleMapIndexes(shuffleId: Int): Set[Int]
+
/**
* Deletes map output status information for the specified shuffle stage.
*/
@@ -958,6 +995,26 @@ private[spark] class MapOutputTrackerMaster(
incrementEpoch()
}
+ /**
+ * Mark a partition as having stale (redundant) push attempts and bump the
epoch so that
+ * reducers with a cached (empty) stale set are forced to re-fetch. Without
the epoch bump,
+ * a reducer that already fetched its merge statuses before this mark would
keep its stale
+ * set cached and never see the new mark, causing layer-3 fallback to
silently not fire.
+ *
+ * @param shuffleId the shuffle id.
+ * @param mapIndex the partition index (== mapIndex, not MapStatus.mapId) of
the stale
+ * (redundant) attempt; this is NOT MapStatus.mapId
+ */
+ def markStalePushedPartition(shuffleId: Int, mapIndex: Int): Unit = {
+ shuffleStatuses.get(shuffleId).foreach { shuffleStatus =>
+ shuffleStatus.markStalePushedPartition(mapIndex)
+ // Bump the epoch so reducers with a cached stale set are forced to
re-fetch.
+ // A reducer that fetched its merge statuses before this mark otherwise
keeps
+ // its (empty) stale set and layer-3 fallback would not fire.
+ incrementEpoch()
+ }
+ }
+
/** Check if the given shuffle is being tracked */
def containsShuffle(shuffleId: Int): Boolean =
shuffleStatuses.contains(shuffleId)
@@ -1260,6 +1317,10 @@ private[spark] class MapOutputTrackerMaster(
shuffleStatuses.get(shuffleId).map(_.getShufflePushMergerLocations).getOrElse(Seq.empty)
}
+ override def getStaleMapIndexes(shuffleId: Int): Set[Int] = {
+
shuffleStatuses.get(shuffleId).map(_.getStaleMapIndexes).getOrElse(Set.empty)
+ }
+
override def stop(): Unit = {
mapOutputTrackerMasterMessages.offer(PoisonPill)
threadpool.shutdown()
@@ -1305,6 +1366,28 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
*/
val shufflePushMergerLocations = new ConcurrentHashMap[Int,
Seq[BlockManagerId]]().asScala
+ /**
+ * Tracks stale pushed partition indexes per shuffle for speculation
stale-push detection.
+ * Populated during getStatuses fetch from driver, used by
PushBasedFetchHelper
+ * to check chunkBitmaps for stale data at chunk level.
+ */
+ private[spark] val staleMapIndexes =
+ new ConcurrentHashMap[Int, java.util.HashSet[Int]]().asScala
+
+ /**
+ * Get all stale pushed partition indexes for a given shuffle. Returns empty
set if none exist.
+ * Called from PushBasedFetchHelper on the reduce side for chunk-level
stale-data detection.
+ */
+ def getStaleMapIndexes(shuffleId: Int): Set[Int] = {
+ val dupSetOpt = staleMapIndexes.get(shuffleId)
+ dupSetOpt match {
+ case Some(dupSet) =>
+ val result = new java.util.HashSet[Int](dupSet) // defensive copy
+ result.asScala
+ case None => Set.empty[Int]
+ }
+ }
+
/**
* A [[KeyLock]] whose key is a shuffle id to ensure there is only one
thread fetching
* the same shuffle block.
@@ -1356,12 +1439,13 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
s"mappers $startMapIndex-$actualEndMapIndex, partitions
$startPartition-$endPartition")
MapOutputTracker.convertMapStatuses(
shuffleId, startPartition, endPartition, mapOutputStatuses,
startMapIndex,
- actualEndMapIndex, Option(mergedOutputStatuses))
+ actualEndMapIndex, Option(mergedOutputStatuses))
} catch {
case e: MetadataFetchFailedException =>
// We experienced a fetch failure so our mapStatuses cache is
outdated; clear it:
mapStatuses.clear()
mergeStatuses.clear()
+ staleMapIndexes.clear()
throw e
}
}
@@ -1386,6 +1470,7 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
case e: MetadataFetchFailedException =>
mapStatuses.clear()
mergeStatuses.clear()
+ staleMapIndexes.clear()
throw e
}
}
@@ -1407,6 +1492,7 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
case e: MetadataFetchFailedException =>
mapStatuses.clear()
mergeStatuses.clear()
+ staleMapIndexes.clear()
throw e
}
}
@@ -1456,13 +1542,20 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
if (fetchedMapStatuses == null || fetchedMergeStatuses == null) {
logInfo(log"Doing the fetch; tracker endpoint = " +
log"${MDC(RPC_ENDPOINT_REF, trackerEndpoint)}")
- val fetchedBytes =
- askTracker[(Array[Byte],
Array[Byte])](GetMapAndMergeResultStatuses(shuffleId))
+ val fetchedBytes = askTracker[(Array[Byte], Array[Byte],
Array[Byte])](
+ GetMapAndMergeResultStatuses(shuffleId))
try {
fetchedMapStatuses =
MapOutputTracker.deserializeOutputStatuses[MapStatus](fetchedBytes._1, conf)
fetchedMergeStatuses =
MapOutputTracker.deserializeOutputStatuses[MergeStatus](fetchedBytes._2, conf)
+ // Deserialize staleMapIndexes set for speculation stale-push
detection.
+ val deserializedStale =
MapOutputTracker.deserializeStaleMapIndexes(fetchedBytes._3)
+ staleMapIndexes.put(shuffleId, deserializedStale)
+ if (!deserializedStale.isEmpty) {
+ logInfo(s"Got stale pushed partition indexes for shuffle
$shuffleId: " +
+ deserializedStale.asScala.mkString(","))
+ }
} catch {
case e: SparkException =>
throw new MetadataFetchFailedException(shuffleId, -1,
@@ -1519,6 +1612,7 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
mapStatuses.remove(shuffleId)
mergeStatuses.remove(shuffleId)
shufflePushMergerLocations.remove(shuffleId)
+ staleMapIndexes.remove(shuffleId)
}
/**
@@ -1534,6 +1628,7 @@ private[spark] class MapOutputTrackerWorker(conf:
SparkConf) extends MapOutputTr
mapStatuses.clear()
mergeStatuses.clear()
shufflePushMergerLocations.clear()
+ staleMapIndexes.clear()
}
}
}
@@ -1644,6 +1739,48 @@ private[spark] object MapOutputTracker extends Logging {
}
}
+ /**
+ * Serialize a set of stale pushed partition indexes into a compact byte
array.
+ * Uses DataOutputStream for a simple, efficient binary format:
+ * [int: count][int: mapIndex1][int: mapIndex2]...
+ * This is intentionally lightweight compared to serializeOutputStatuses
because
+ * the set is typically small (only non-empty during speculation retries).
+ */
+ def serializeStaleMapIndexes(
+ mapIndexes: java.util.HashSet[Int]): Array[Byte] = {
+ val out = new ApacheByteArrayOutputStream()
+ val dataOut = new DataOutputStream(out)
+ Utils.tryWithSafeFinally {
+ dataOut.writeInt(mapIndexes.size())
+ val iter = mapIndexes.iterator()
+ while (iter.hasNext) {
+ dataOut.writeInt(iter.next())
+ }
+ } {
+ dataOut.close()
+ }
+ out.toByteArray
+ }
+
+ /**
+ * Deserialize a byte array produced by [[serializeStaleMapIndexes]] back
into a HashSet.
+ */
+ def deserializeStaleMapIndexes(bytes: Array[Byte]): java.util.HashSet[Int] =
{
+ val dupMapIdIn = new DataInputStream(new ByteArrayInputStream(bytes))
+ Utils.tryWithSafeFinally {
+ val numEntries = dupMapIdIn.readInt()
+ val dupSet = new java.util.HashSet[Int]()
+ var i = 0
+ while (i < numEntries) {
+ dupSet.add(dupMapIdIn.readInt())
+ i += 1
+ }
+ dupSet
+ } {
+ dupMapIdIn.close()
+ }
+ }
+
/**
* Given an array of map statuses and a range of map output partitions,
returns a sequence that,
* for each block manager ID, lists the shuffle block IDs and corresponding
shuffle block sizes
diff --git a/core/src/main/scala/org/apache/spark/TaskContext.scala
b/core/src/main/scala/org/apache/spark/TaskContext.scala
index 11f1e1e63f11..a4f68d76128d 100644
--- a/core/src/main/scala/org/apache/spark/TaskContext.scala
+++ b/core/src/main/scala/org/apache/spark/TaskContext.scala
@@ -27,7 +27,7 @@ import org.apache.spark.metrics.source.Source
import org.apache.spark.resource.ResourceInformation
import org.apache.spark.scheduler.Task
import org.apache.spark.shuffle.FetchFailedException
-import org.apache.spark.util.{AccumulatorV2, TaskCompletionListener,
TaskFailureListener, TaskInterruptListener}
+import org.apache.spark.util.{AccumulatorV2, PostStatusUpdateListener,
TaskCompletionListener, TaskFailureListener, TaskInterruptListener}
object TaskContext {
@@ -195,6 +195,23 @@ abstract class TaskContext extends Serializable {
})
}
+ /**
+ * Adds a listener to be invoked after the task's status update has been
sent to the driver.
+ * This is useful for operations that should only begin after the driver has
been notified
+ * of the task's result. For example, push-based shuffle block push can use
this to
+ * ensure the driver processes the task result before any push data reaches
the merger,
+ * avoiding stale data being merged without detection.
+ *
+ * The callback runs on the same executor thread that sends the status
update.
+ */
+ private[spark] def addPostStatusUpdateListener(listener:
PostStatusUpdateListener): TaskContext
+
+ /**
+ * Invokes all registered post-status-update listeners. Called by Executor
after sending
+ * the task's status update to the driver.
+ */
+ private[spark] def invokePostStatusUpdateListeners(): Unit
+
/** Runs a task with this context, ensuring failure and completion listeners
get triggered. */
private[spark] def runTaskWithListeners[T](task: Task[T]): T = {
try {
diff --git a/core/src/main/scala/org/apache/spark/TaskContextImpl.scala
b/core/src/main/scala/org/apache/spark/TaskContextImpl.scala
index 24202d92601d..9bf9ce0d9de2 100644
--- a/core/src/main/scala/org/apache/spark/TaskContextImpl.scala
+++ b/core/src/main/scala/org/apache/spark/TaskContextImpl.scala
@@ -76,6 +76,13 @@ private[spark] class TaskContextImpl(
/** List of callback functions to execute when the task is interrupted. */
@transient private val onInterruptCallbacks = new
Stack[TaskInterruptListener]
+ /**
+ * List of callback functions to invoke after the task's status update has
been sent
+ * to the driver. Used for operations like push-based shuffle block push
that should only
+ * begin after the driver has been notified of the task result.
+ */
+ @transient private val onPostStatusUpdateCallbacks = new
Stack[PostStatusUpdateListener]
+
/**
* The thread currently executing task completion or failure listeners, if
any.
*
@@ -144,6 +151,19 @@ private[spark] class TaskContextImpl(
this
}
+ override def addPostStatusUpdateListener(listener:
PostStatusUpdateListener): this.type = {
+ synchronized {
+ onPostStatusUpdateCallbacks.push(listener)
+ }
+ this
+ }
+
+ private[spark] def invokePostStatusUpdateListeners(): Unit = {
+ invokeListeners(onPostStatusUpdateCallbacks, "PostStatusUpdateListener",
None) {
+ _.onStatusUpdateSent(this)
+ }
+ }
+
override def resourcesJMap(): java.util.Map[String, ResourceInformation] = {
resources.asJava
}
diff --git a/core/src/main/scala/org/apache/spark/executor/Executor.scala
b/core/src/main/scala/org/apache/spark/executor/Executor.scala
index 805ec68a8947..1790c781ecfc 100644
--- a/core/src/main/scala/org/apache/spark/executor/Executor.scala
+++ b/core/src/main/scala/org/apache/spark/executor/Executor.scala
@@ -1027,6 +1027,9 @@ private[spark] class Executor(
setTaskFinishedAndClearInterruptStatus()
plugins.foreach(_.onTaskSucceeded())
execBackend.statusUpdate(taskId, TaskState.FINISHED, serializedResult)
+ Utils.tryLogNonFatalError {
+ task.context.invokePostStatusUpdateListeners()
+ }
} catch {
case t: TaskKilledException =>
logInfo(log"Executor killed ${MDC(TASK_NAME, taskName)}," +
diff --git
a/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
b/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
index 5c077a7a3bbb..2e8e6628ea5d 100644
--- a/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
+++ b/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala
@@ -824,6 +824,8 @@ private[spark] class TaskSetManager(
val index = info.index
// Check if any other attempt succeeded before this and this attempt has
not been handled
if (successful(index) && killedByOtherAttempt.contains(tid)) {
+ // For ShuffleMapTasks, detect speculation duplicate before handling as
killed.
+ detectStalePushIfShuffleTask(tid, info.index, result)
// Undo the effect on calculatedTasks and totalResultSize made earlier
when
// checking if can fetch more results
calculatedTasks -= 1
@@ -930,6 +932,34 @@ private[spark] class TaskSetManager(
taskInfoWithAccumulables)
}
+ /**
+ * For ShuffleMapTasks, detect stale push: if a partition already has
+ * a registered MapStatus with a different mapId, it means another attempt
for the same
+ * partition also pushed data to the merger. Mark this partition so that
reducers will
+ * skip the merged block and fallback to unmerged blocks.
+ *
+ * This is called from handleSuccessfulTask for late-arriving or killed
attempt results,
+ * where the task result won't be forwarded to DAGScheduler (so
DAGScheduler's own
+ * stale detection won't cover these cases).
+ */
+ private def detectStalePushIfShuffleTask(
+ tid: Long, index: Int, result: DirectTaskResult[_]): Unit = {
+ if (!isShuffleMapTasks || shuffleId.isEmpty) {
+ return
+ }
+ // This method is only called for late-arriving or killed attempts,
meaning the partition
+ // already has a successful attempt registered. Any MapStatus arriving
here is from a
+ // stale (redundant) attempt that also pushed data to the merger.
+ val mapStatus = result.value().asInstanceOf[MapStatus]
+ val sid = shuffleId.get
+ val partitionId = tasks(index).partitionId
+ // Mark its mapIndex (== partitionId, NOT MapStatus.mapId) as stale so
reducers can detect
+ // the stale data in merged block chunks via chunkBitmaps and fallback if
needed.
+ logInfo(s"[StalePush] Late/killed attempt tid=$tid for
partition=$partitionId " +
+ s"(index=$index), attemptMapId=${mapStatus.mapId}. Marked as stale
push.")
+ sched.mapOutputTracker.markStalePushedPartition(sid, partitionId)
+ }
+
private[scheduler] def markPartitionCompleted(partitionId: Int): Unit = {
partitionToIndex.get(partitionId).foreach { index =>
if (!successful(index)) {
diff --git
a/core/src/main/scala/org/apache/spark/shuffle/ShuffleWriteProcessor.scala
b/core/src/main/scala/org/apache/spark/shuffle/ShuffleWriteProcessor.scala
index 47d54ae4f10b..c397ac03ed92 100644
--- a/core/src/main/scala/org/apache/spark/shuffle/ShuffleWriteProcessor.scala
+++ b/core/src/main/scala/org/apache/spark/shuffle/ShuffleWriteProcessor.scala
@@ -21,6 +21,7 @@ import org.apache.spark.{ShuffleDependency, SparkEnv,
TaskContext}
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys.{NUM_MERGER_LOCATIONS, SHUFFLE_ID,
STAGE_ID}
import org.apache.spark.scheduler.MapStatus
+import org.apache.spark.util.PostStatusUpdateListener
/**
* The interface for customizing shuffle write process. The driver create a
ShuffleWriteProcessor
@@ -79,8 +80,29 @@ private[spark] class ShuffleWriteProcessor extends
Serializable with Logging {
log" with shuffle ID ${MDC(SHUFFLE_ID, dep.shuffleId)}")
logDebug(s"Starting pushing blocks for the task
${context.taskAttemptId()}")
val dataFile = resolver.getDataFile(dep.shuffleId, mapId)
- new ShuffleBlockPusher(SparkEnv.get.conf)
- .initiateBlockPush(dataFile, writer.getPartitionLengths(),
dep, mapIndex)
+ val blockPusher = new ShuffleBlockPusher(SparkEnv.get.conf)
+ // Register a post-status-update listener to defer push until
after the task
+ // result has been sent to the driver. This ensures the driver
processes the
+ // task result (and can mark stale partitions from speculative
duplicates)
+ // before any push data reaches the merger, avoiding stale data
being merged
+ // without detection.
+ //
+ // The listener callback runs on the Task thread and only does a
lightweight
+ // submitTask; actual push I/O runs on BLOCK_PUSHER_POOL threads.
+ context.addPostStatusUpdateListener(new PostStatusUpdateListener
{
+ override def onStatusUpdateSent(context: TaskContext): Unit = {
+ if (!context.isInterrupted() && !context.isFailed()) {
+ logDebug(s"Task ${context.taskAttemptId()} status update
sent, " +
+ s"proceeding with shuffle block push for shuffle
${dep.shuffleId}")
+ blockPusher.initiateBlockPush(
+ dataFile, writer.getPartitionLengths(), dep, mapIndex)
+ } else {
+ logInfo(s"Task ${context.taskAttemptId()} was " +
+ s"${if (context.isInterrupted()) "killed" else
"failed"}, " +
+ s"skipping shuffle block push for shuffle
${dep.shuffleId}")
+ }
+ }
+ })
case _ =>
}
}
diff --git
a/core/src/main/scala/org/apache/spark/storage/PushBasedFetchHelper.scala
b/core/src/main/scala/org/apache/spark/storage/PushBasedFetchHelper.scala
index 16a68bbf50e2..aa0413be38a1 100644
--- a/core/src/main/scala/org/apache/spark/storage/PushBasedFetchHelper.scala
+++ b/core/src/main/scala/org/apache/spark/storage/PushBasedFetchHelper.scala
@@ -19,7 +19,6 @@ package org.apache.spark.storage
import java.util.concurrent.TimeUnit
-import scala.collection
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.util.{Failure, Success}
@@ -166,9 +165,16 @@ private class PushBasedFetchHelper(
meta: MergedBlockMeta): Unit = {
logDebug(s"Received the meta of push-merged block for ($shuffleId,
$shuffleMergeId," +
s" $reduceId) from ${req.address.host}:${req.address.port}")
+ val mergedBlock = ShuffleMergedBlockId(shuffleId, shuffleMergeId,
reduceId)
try {
-
iterator.addToResultsQueue(PushMergedRemoteMetaFetchResult(shuffleId,
shuffleMergeId,
- reduceId, sizeMap((shuffleId, reduceId)), meta.readChunkBitmaps(),
address))
+ val chunkBitmaps = meta.readChunkBitmaps()
+ if (checkStaleMapIdInMergedBlock(mergedBlock, address,
chunkBitmaps)) {
+
iterator.addToResultsQueue(PushMergedRemoteMetaFetchResult(shuffleId,
shuffleMergeId,
+ reduceId, sizeMap((shuffleId, reduceId)), chunkBitmaps, address))
+ } else {
+
iterator.addToResultsQueue(PushMergedRemoteMetaFailedFetchResult(shuffleId,
+ shuffleMergeId, reduceId, address))
+ }
} catch {
case exception: Exception =>
logError(log"Failed to parse the meta of push-merged block for (" +
@@ -273,9 +279,15 @@ private class PushBasedFetchHelper(
try {
val shuffleBlockId = blockId.asInstanceOf[ShuffleMergedBlockId]
val chunksMeta = blockManager.getLocalMergedBlockMeta(shuffleBlockId,
localDirs)
- iterator.addToResultsQueue(PushMergedLocalMetaFetchResult(
- shuffleBlockId.shuffleId, shuffleBlockId.shuffleMergeId,
- shuffleBlockId.reduceId, chunksMeta.readChunkBitmaps(), localDirs))
+ val chunkBitmaps = chunksMeta.readChunkBitmaps()
+ if (checkStaleMapIdInMergedBlock(shuffleBlockId, blockManagerId,
chunkBitmaps)) {
+ iterator.addToResultsQueue(PushMergedLocalMetaFetchResult(
+ shuffleBlockId.shuffleId, shuffleBlockId.shuffleMergeId,
+ shuffleBlockId.reduceId, chunkBitmaps, localDirs))
+ } else {
+ iterator.addToResultsQueue(FallbackOnPushMergedFailureResult(
+ blockId, blockManagerId, 0, isNetworkReqDone = false))
+ }
} catch {
case e: Exception =>
// If we see an exception with reading a push-merged-local meta, we
fallback to
@@ -288,6 +300,36 @@ private class PushBasedFetchHelper(
}
}
+ /**
+ * Check whether a push-merged block contains data from stale (duplicate)
task attempts.
+ * When speculation is enabled, multiple attempts for the same map output
may both push data
+ * to the merger. The merger may include data from both attempts in the same
merged block,
+ * but the driver only tracks one as the canonical MapStatus. We detect this
by checking
+ * if any stale pushed map index appears in the server-side chunkBitmaps.
+ *
+ * @param shuffleBlockId ShuffleMergedBlockId to be checked
+ * @param address BlockManagerId of push-based shuffle service
+ * @param chunkBitmaps Chunks bitmap from push-based shuffle service side
+ * @return false if any stale-marked mapIndex is present in this block
(forcing fallback),
+ * true otherwise
+ */
+ private[this] def checkStaleMapIdInMergedBlock(
+ shuffleBlockId: ShuffleMergedBlockId,
+ address: BlockManagerId,
+ chunkBitmaps: Array[RoaringBitmap]): Boolean = {
+ val staleMapIndexes =
+ mapOutputTracker.getStaleMapIndexes(shuffleBlockId.shuffleId)
+ if (staleMapIndexes.isEmpty) return true
+ val mergedBlockBitmap = new RoaringBitmap()
+ chunkBitmaps.foreach(mergedBlockBitmap.or)
+ val hasStale = staleMapIndexes.exists(id => mergedBlockBitmap.contains(id))
+ if (hasStale) {
+ logWarning(s"Found stale pushed map indexes in merged block
$shuffleBlockId from" +
+ s" ${address.host}:${address.port}, falling back to fetch the original
blocks")
+ }
+ !hasStale
+ }
+
/**
* This is executed by the task thread when the `iterator.next()` is invoked
and the iterator
* processes a response of type:
diff --git a/core/src/main/scala/org/apache/spark/util/taskListeners.scala
b/core/src/main/scala/org/apache/spark/util/taskListeners.scala
index 39b7dbe66f23..b40d1a5fc1b5 100644
--- a/core/src/main/scala/org/apache/spark/util/taskListeners.scala
+++ b/core/src/main/scala/org/apache/spark/util/taskListeners.scala
@@ -54,6 +54,23 @@ trait TaskInterruptListener extends EventListener {
def onTaskInterrupted(context: TaskContext, reason: String): Unit
}
+/**
+ * Listener providing a callback to be invoked after the task's status update
has been sent to
+ * the driver. This is useful for operations that should only begin after the
driver has been
+ * notified of the task's result, such as push-based shuffle block push, where
starting push
+ * before the driver processes the result can lead to stale data being merged
without detection.
+ *
+ * The callback runs on the same executor thread that sends the status update.
It should perform
+ * only lightweight work (e.g., submitting async I/O to another thread pool)
to avoid blocking
+ * the executor task thread.
+ *
+ * Listeners are guaranteed to execute sequentially in reverse order of
registration.
+ */
+private[spark]
+trait PostStatusUpdateListener extends EventListener {
+ def onStatusUpdateSent(context: TaskContext): Unit
+}
+
/**
* Exception thrown when there is an exception in executing the callback in
TaskCompletionListener.
diff --git a/core/src/test/scala/org/apache/spark/MapOutputTrackerSuite.scala
b/core/src/test/scala/org/apache/spark/MapOutputTrackerSuite.scala
index d2344b4e7291..2015ceb8b090 100644
--- a/core/src/test/scala/org/apache/spark/MapOutputTrackerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/MapOutputTrackerSuite.scala
@@ -637,7 +637,7 @@ class MapOutputTrackerSuite extends SparkFunSuite with
LocalSparkContext {
mapWorkerRpcEnv.setupEndpointRef(rpcEnv.address,
MapOutputTracker.ENDPOINT_NAME)
val fetchedBytes = mapWorkerTracker.trackerEndpoint
- .askSync[(Array[Byte], Array[Byte])](GetMapAndMergeResultStatuses(20))
+ .askSync[(Array[Byte], Array[Byte],
Array[Byte])](GetMapAndMergeResultStatuses(20))
assert(masterTracker.getNumAvailableMergeResults(20) == 1)
assert(masterTracker.getNumAvailableOutputs(20) == 100)
@@ -1166,4 +1166,56 @@ class MapOutputTrackerSuite extends SparkFunSuite with
LocalSparkContext {
rpcEnv.shutdown()
}
}
+
+ test("SPARK-57491: staleMapIndexes propagated from driver to worker via
getStatuses") {
+ val rpcEnv = createRpcEnv("test")
+ val masterTracker = newTrackerMaster()
+ try {
+ masterTracker.trackerEndpoint =
rpcEnv.setupEndpoint(MapOutputTracker.ENDPOINT_NAME,
+ new MapOutputTrackerMasterEndpoint(rpcEnv, masterTracker, conf))
+ // Simulate driver side: register shuffle
+ masterTracker.registerShuffle(0, 4, 2)
+ masterTracker.registerMapOutput(0, 0,
+ MapStatus(BlockManagerId("exec-1", "hostA", 1000), Array(100L, 200L),
0))
+ masterTracker.registerMapOutput(0, 1,
+ MapStatus(BlockManagerId("exec-2", "hostB", 1000), Array(300L, 400L),
1))
+
+ val initialEpoch = masterTracker.getEpoch
+
+ // Mark partition 1 as stale via MapOutputTrackerMaster: marks the
partition and bumps
+ // the epoch so reducers with a cached (empty) stale set are forced to
re-fetch
+ masterTracker.markStalePushedPartition(0, 1)
+
+ // Verify epoch was bumped so reducer-side stale set cache is invalidated
+ assert(masterTracker.getEpoch > initialEpoch,
+ s"Expected epoch to be bumped past $initialEpoch, got
${masterTracker.getEpoch}")
+
+ val shuffleStatus = masterTracker.shuffleStatuses(0)
+
+ // Verify the stale mark was recorded on the shuffle status
+ assert(shuffleStatus.getStaleMapIndexes.contains(1))
+
+ // Serialize statuses including staleMapIndexes (simulates what driver
sends to executors)
+ val (mapBytes, mergeBytes, staleBytes) =
+ shuffleStatus.serializedMapAndMergeStatus(
+ masterTracker.broadcastManager, isLocal = true, minBroadcastSize =
Int.MaxValue, conf)
+
+ // Simulate worker side: deserialize and verify staleMapIndexes are
received
+ val deserializedStale =
MapOutputTracker.deserializeStaleMapIndexes(staleBytes)
+ assert(deserializedStale.contains(1))
+
+ // Also verify via MapOutputTrackerWorker (the path used by real
executors)
+ val workerTracker = new MapOutputTrackerWorker(conf)
+ // Manually populate what the worker would get from a getStatuses fetch
+ workerTracker.staleMapIndexes.put(0, {
+ val s = new JHashSet[Int]()
+ s.addAll(deserializedStale)
+ s
+ })
+ assert(workerTracker.getStaleMapIndexes(0).contains(1))
+ } finally {
+ masterTracker.stop()
+ rpcEnv.shutdown()
+ }
+ }
}
diff --git
a/core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala
b/core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala
index ac8d000d8a44..d9bfbe2b5f24 100644
--- a/core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala
+++ b/core/src/test/scala/org/apache/spark/scheduler/TaskContextSuite.scala
@@ -768,6 +768,76 @@ class TaskContextSuite extends SparkFunSuite with
BeforeAndAfter with LocalSpark
assert(isFailed)
}
+ test("SPARK-57491: invokePostStatusUpdateListeners triggers
PostStatusUpdateListener") {
+ // Basic invocation - listener is called when
invokePostStatusUpdateListeners is invoked
+ val context1 = TaskContext.empty()
+ var invoked = false
+ context1.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit = invoked =
true
+ })
+ assert(!invoked)
+ context1.invokePostStatusUpdateListeners()
+ assert(invoked)
+
+ // Multiple listeners are called in reverse registration order
+ val context2 = TaskContext.empty()
+ val callOrder = ArrayBuffer.empty[String]
+ context2.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit = callOrder
+= "first"
+ })
+ context2.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit = callOrder
+= "second"
+ })
+ context2.invokePostStatusUpdateListeners()
+ assert(callOrder === Seq("second", "first"))
+
+ // Listener exception is wrapped in TaskCompletionListenerException
+ val context3 = TaskContext.empty()
+ context3.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit =
+ throw new RuntimeException("post-status-update failure")
+ })
+ val e = intercept[TaskCompletionListenerException] {
+ context3.invokePostStatusUpdateListeners()
+ }
+ assert(e.getMessage.contains("post-status-update failure"))
+
+ // All listeners are called even if one throws (same behavior as
completion listeners)
+ val context4 = TaskContext.empty()
+ val listener = mock(classOf[PostStatusUpdateListener])
+ context4.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit =
+ throw new RuntimeException("bad listener")
+ })
+ context4.addPostStatusUpdateListener(listener)
+ context4.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit =
+ throw new RuntimeException("another bad listener")
+ })
+
+ intercept[TaskCompletionListenerException] {
+ context4.invokePostStatusUpdateListeners()
+ }
+
+ verify(listener, times(1)).onStatusUpdateSent(any())
+
+ // invokePostStatusUpdateListeners is independent from
TaskCompletionListener
+ // (completion listener should NOT be triggered by
invokePostStatusUpdateListeners)
+ val context5 = TaskContext.empty()
+ var postStatusInvoked = false
+ var completionInvoked = false
+ context5.addPostStatusUpdateListener(new PostStatusUpdateListener {
+ override def onStatusUpdateSent(context: TaskContext): Unit =
postStatusInvoked = true
+ })
+ context5.addTaskCompletionListener(new TaskCompletionListener {
+ override def onTaskCompletion(context: TaskContext): Unit =
completionInvoked = true
+ })
+
+ context5.invokePostStatusUpdateListeners()
+ assert(postStatusInvoked)
+ assert(!completionInvoked) // completion listener should NOT be triggered
+ }
+
test("SPARK-46947: ensure the correct block manager is used to unroll memory
for task") {
import BlockManagerValidationPlugin._
BlockManagerValidationPlugin.resetState()
diff --git
a/core/src/test/scala/org/apache/spark/scheduler/TaskSetManagerSuite.scala
b/core/src/test/scala/org/apache/spark/scheduler/TaskSetManagerSuite.scala
index 4fd710712221..299bce879ba2 100644
--- a/core/src/test/scala/org/apache/spark/scheduler/TaskSetManagerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/scheduler/TaskSetManagerSuite.scala
@@ -2921,6 +2921,89 @@ class TaskSetManagerSuite
s"\nCaptured logs:\n${logs.mkString("\n")}")
}
+ test("SPARK-57491: late-arriving speculative ShuffleMapTask marks stale
partitionId") {
+ sc = new SparkContext("local", "test")
+ sched = new FakeTaskScheduler(sc, ("exec1", "host1"), ("exec2", "host2"),
("exec3", "host3"))
+ sc.conf.set(config.SPECULATION_MULTIPLIER, 0.0)
+ sc.conf.set(config.SPECULATION_ENABLED, true)
+
+ val taskSet = FakeTask.createShuffleMapTaskSet(2, 0, 0,
+ Seq(TaskLocation("host1", "exec1")),
+ Seq(TaskLocation("host2", "exec2")))
+ val clock = new ManualClock()
+ val manager = new TaskSetManager(sched, taskSet, MAX_TASK_FAILURES, clock
= clock)
+ val accumUpdatesByTask: Array[Seq[AccumulatorV2[_, _]]] =
taskSet.tasks.map { task =>
+ task.metrics.internalAccums
+ }
+
+ // Register shuffle in MapOutputTrackerMaster so
detectStalePushIfShuffleTask can find it
+ val mapOutputTrackerMaster = sched.mapOutputTracker
+ val shuffleId = taskSet.shuffleId.get
+ mapOutputTrackerMaster.registerShuffle(shuffleId, 2, 2)
+
+ // Offer resources for 2 tasks to start
+ val task0 = manager.resourceOffer("exec1", "host1", PROCESS_LOCAL)._1.get
+ val task1 = manager.resourceOffer("exec2", "host2", PROCESS_LOCAL)._1.get
+ assert(task0.index === 0)
+ assert(task1.index === 1)
+
+ // Advance clock so tasks have been running long enough (markFinished
requires time > 0)
+ clock.advance(1)
+
+ // Complete task 1 (partition 1) successfully with a MapStatus
+ val mapStatus1 = MapStatus(BlockManagerId("exec2", "host2", 2000),
Array(2L, 2L), mapTaskId = 1)
+ val result1 = createMapStatusTaskResult(mapStatus1, accumUpdatesByTask(1))
+ manager.handleSuccessfulTask(task1.taskId, result1)
+ assert(sched.endedTasks(task1.index) === Success)
+
+ // Advance clock so task 0 has been running long enough for speculation.
+ // checkSpeculatableTasks requires tasks to have been running > 0ms when
threshold is 0.
+ clock.advance(1)
+ assert(manager.checkSpeculatableTasks(0))
+ assert(sched.speculativeTasks.toSet === Set(0))
+
+ // Offer resource to start the speculative attempt for partition 0 on a
different host
+ val specTaskOption = manager.resourceOffer("exec3", "host3", ANY)._1
+ assert(specTaskOption.isDefined, "Expected speculative task to be
launched")
+ val specTask = specTaskOption.get
+ assert(specTask.index === 0)
+ assert(specTask.attemptNumber === 1)
+
+ // Replace backend with mock before completing original task 0, to handle
killTask call
+ sched.backend = mock(classOf[SchedulerBackend])
+ sched.dagScheduler.stop()
+ sched.dagScheduler = mock(classOf[DAGScheduler])
+
+ // Complete original task 0 (partition 0) - this will kill the speculative
attempt
+ val mapStatus0 = MapStatus(BlockManagerId("exec1", "host1", 1000),
Array(1L, 1L), mapTaskId = 0)
+ val result0 = createMapStatusTaskResult(mapStatus0, accumUpdatesByTask(0))
+ manager.handleSuccessfulTask(task0.taskId, result0)
+
+ // Verify no stale pushed map indexes yet (stale is only marked when late
result arrives)
+ assert(mapOutputTrackerMaster.getStaleMapIndexes(shuffleId).isEmpty)
+
+ // Now the speculative attempt's result arrives late. Since task 0 already
succeeded,
+ // handleSuccessfulTask will see successful(0)=true and
killedByOtherAttempt contains
+ // the speculative tid, triggering detectStalePushIfShuffleTask.
+ val specMapStatus = MapStatus(
+ BlockManagerId("exec3", "host3", 3000), Array(3L, 3L), mapTaskId = 999)
+ val specResult = createMapStatusTaskResult(specMapStatus,
accumUpdatesByTask(0))
+ manager.handleSuccessfulTask(specTask.taskId, specResult)
+
+ // Verify that partition 0 is now tracked as stale
+ val staleMapIndexes = mapOutputTrackerMaster.getStaleMapIndexes(shuffleId)
+ assert(staleMapIndexes.contains(0),
+ s"Expected staleMapIndexes to contain mapIndex 0, got $staleMapIndexes")
+ }
+
+ private def createMapStatusTaskResult(
+ mapStatus: MapStatus,
+ accumUpdates: Seq[AccumulatorV2[_, _]],
+ metricPeaks: Array[Long] = Array.empty): DirectTaskResult[MapStatus] = {
+ val valueSer = SparkEnv.get.serializer.newInstance()
+ new DirectTaskResult[MapStatus](valueSer.serialize(mapStatus),
accumUpdates, metricPeaks)
+ }
+
}
class FakeLongTasks(stageId: Int, partitionId: Int) extends FakeTask(stageId,
partitionId) {
diff --git
a/core/src/test/scala/org/apache/spark/storage/ShuffleBlockFetcherIteratorSuite.scala
b/core/src/test/scala/org/apache/spark/storage/ShuffleBlockFetcherIteratorSuite.scala
index 211de2e8729e..f9d2fdf941d0 100644
---
a/core/src/test/scala/org/apache/spark/storage/ShuffleBlockFetcherIteratorSuite.scala
+++
b/core/src/test/scala/org/apache/spark/storage/ShuffleBlockFetcherIteratorSuite.scala
@@ -59,6 +59,7 @@ class ShuffleBlockFetcherIteratorSuite extends SparkFunSuite {
mapOutputTracker = mock(classOf[MapOutputTracker])
when(mapOutputTracker.getMapSizesForMergeResult(any(), any(), any()))
.thenReturn(Seq.empty.iterator)
+
when(mapOutputTracker.getStaleMapIndexes(any())).thenReturn(mutable.Set.empty[Int])
}
private def doReturn(value: Any) = org.mockito.Mockito.doReturn(value,
Seq.empty: _*)
@@ -2076,4 +2077,84 @@ class ShuffleBlockFetcherIteratorSuite extends
SparkFunSuite {
assert(iterator.next()._1 === ShuffleBlockId(0, 1, 0))
assert(!iterator.hasNext)
}
+
+ test("SPARK-57491: fallback behavior when stale mapIndex is in/not in merged
block") {
+ val blockManager = createMockBlockManager()
+ val localHost = "test-local-host"
+ val localBmId = BlockManagerId("test-client", localHost, 1)
+ val pushMergedBmId = BlockManagerId(SHUFFLE_MERGER_IDENTIFIER, localHost,
1)
+ val localDirs = Array("test-merged-dir-1", "test-merged-dir-2")
+ initHostLocalDirManager(blockManager, Map(SHUFFLE_MERGER_IDENTIFIER ->
localDirs))
+
+ when(mapOutputTracker.getStaleMapIndexes(0)).thenReturn(mutable.Set(1))
+ when(mapOutputTracker.getMapSizesForMergeResult(0, 2))
+ .thenReturn(Seq((localBmId,
+ toBlockList(Seq(ShuffleBlockId(0, 0, 2), ShuffleBlockId(0, 1, 2)), 1L,
1))).iterator)
+ doReturn(createMockManagedBuffer(100)).when(blockManager)
+ .getLocalBlockData(ShuffleBlockId(0, 0, 2))
+ doReturn(createMockManagedBuffer(100)).when(blockManager)
+ .getLocalBlockData(ShuffleBlockId(0, 1, 2))
+
+ // Stale mapIndex IS in the merged block's bitmap -> should trigger
fallback
+ val bitmaps1 = Array(new RoaringBitmap)
+ bitmaps1(0).add(0) // chunk 0 has mapIndex 0
+ bitmaps1(0).add(1) // chunk 0 also has mapIndex 1 (stale)
+
+ val pushMergedBlockMeta1 = createMockPushMergedBlockMeta(bitmaps1.length,
bitmaps1)
+ when(blockManager.getLocalMergedBlockMeta(ShuffleMergedBlockId(0, 0, 2),
localDirs))
+ .thenReturn(pushMergedBlockMeta1)
+ doReturn(Seq(createMockManagedBuffer(100), createMockManagedBuffer(100)))
+ .when(blockManager).getLocalMergedBlockData(ShuffleMergedBlockId(0, 0,
2), localDirs)
+ doReturn(createMockManagedBuffer(100)).when(blockManager)
+ .getLocalBlockData(ShuffleBlockId(0, 3, 2))
+
+ val blocksByAddress1 = Map[BlockManagerId, Seq[(BlockId, Long, Int)]](
+ (localBmId, toBlockList(Seq(ShuffleBlockId(0, 3, 2)), 1L, 1)),
+ (pushMergedBmId, toBlockList(Seq(ShuffleMergedBlockId(0, 0, 2)), 200L,
+ SHUFFLE_PUSH_MAP_ID)))
+
+ val taskContext1 = TaskContext.empty()
+ val shuffleMetrics1 =
taskContext1.taskMetrics.createTempShuffleReadMetrics()
+ val iterator1 = createShuffleBlockIteratorWithDefaults(blocksByAddress1,
+ blockManager = Some(blockManager),
+ shuffleMetrics = Some(shuffleMetrics1))
+
+ val (id1, _) = iterator1.next()
+ assert(id1 === ShuffleBlockId(0, 3, 2))
+
+ val (id2, _) = iterator1.next()
+ assert(id2 === ShuffleBlockId(0, 0, 2))
+ val (id3, _) = iterator1.next()
+ assert(id3 === ShuffleBlockId(0, 1, 2))
+ assert(!iterator1.hasNext)
+ assert(shuffleMetrics1.mergedFetchFallbackCount === 1)
+
+ // Stale mapIndex is NOT in the merged block's bitmap -> should NOT
trigger fallback
+ val bitmaps2 = Array(new RoaringBitmap, new RoaringBitmap)
+ bitmaps2(0).add(0) // chunk 0 has mapIndex 0
+ bitmaps2(1).add(2) // chunk 1 has mapIndex 2
+
+ val pushMergedBlockMeta2 = createMockPushMergedBlockMeta(bitmaps2.length,
bitmaps2)
+ when(blockManager.getLocalMergedBlockMeta(ShuffleMergedBlockId(0, 0, 2),
localDirs))
+ .thenReturn(pushMergedBlockMeta2)
+ doReturn(Seq(createMockManagedBuffer(100), createMockManagedBuffer(100)))
+ .when(blockManager).getLocalMergedBlockData(ShuffleMergedBlockId(0, 0,
2), localDirs)
+
+ val blocksByAddress2 = Map[BlockManagerId, Seq[(BlockId, Long, Int)]](
+ (pushMergedBmId, toBlockList(Seq(ShuffleMergedBlockId(0, 0, 2)), 200L,
+ SHUFFLE_PUSH_MAP_ID)))
+
+ val taskContext2 = TaskContext.empty()
+ val shuffleMetrics2 =
taskContext2.taskMetrics.createTempShuffleReadMetrics()
+ val iterator2 = createShuffleBlockIteratorWithDefaults(blocksByAddress2,
+ blockManager = Some(blockManager),
+ shuffleMetrics = Some(shuffleMetrics2))
+
+ val (id4, _) = iterator2.next()
+ assert(id4.isInstanceOf[ShuffleBlockChunkId])
+ val (id5, _) = iterator2.next()
+ assert(id5.isInstanceOf[ShuffleBlockChunkId])
+ assert(!iterator2.hasNext)
+ assert(shuffleMetrics2.mergedFetchFallbackCount === 0)
+ }
}
diff --git a/project/MimaExcludes.scala b/project/MimaExcludes.scala
index 48ba1d418b65..643e4dca984e 100644
--- a/project/MimaExcludes.scala
+++ b/project/MimaExcludes.scala
@@ -83,7 +83,10 @@ object MimaExcludes {
// methods of V2ExpressionSQLBuilder. MySQLDialect is private, so this is
not a public API.
ProblemFilters.exclude[InaccessibleMethodProblem]("org.apache.spark.sql.connector.util.V2ExpressionSQLBuilder.visitStartsWith"),
ProblemFilters.exclude[InaccessibleMethodProblem]("org.apache.spark.sql.connector.util.V2ExpressionSQLBuilder.visitEndsWith"),
-
ProblemFilters.exclude[InaccessibleMethodProblem]("org.apache.spark.sql.connector.util.V2ExpressionSQLBuilder.visitContains")
+
ProblemFilters.exclude[InaccessibleMethodProblem]("org.apache.spark.sql.connector.util.V2ExpressionSQLBuilder.visitContains"),
+ // [SPARK-57491][CORE] Add PostStatusUpdateListener to TaskContext for
stale push detection
+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.spark.TaskContext.addPostStatusUpdateListener"),
+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.spark.TaskContext.invokePostStatusUpdateListeners")
)
// Exclude rules for 4.1.x from 4.0.0
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]