cloud-fan commented on code in PR #58437:
URL: https://github.com/apache/spark/pull/58437#discussion_r4085168311
##########
core/src/main/scala/org/apache/spark/shuffle/ShuffleHandle.scala:
##########
@@ -25,4 +25,15 @@ import org.apache.spark.annotation.DeveloperApi
* @param shuffleId ID of the shuffle
*/
@DeveloperApi
-abstract class ShuffleHandle(val shuffleId: Int) extends Serializable {}
+abstract class ShuffleHandle(val shuffleId: Int) extends Serializable {
+ /**
+ * Whether this shuffle's output is stored reliably, off the executor that
produced it (e.g. a
+ * remote shuffle service or distributed filesystem). When true, losing the
executor or its host
+ * does not lose the output, so its map outputs are not unregistered on
executor/worker loss.
+ *
+ * Per-shuffle override of the app-global
`ShuffleDriverComponents.supportsReliableStorage()`:
Review Comment:
**Non-blocking (P2):** `Some(false)` makes this shuffle explicitly
executor-local even when `supportsReliableStorage()` is true, but executor-hold
support and dynamic-allocation admission still trust only the global flag. In a
mixed/fallback application they can therefore retire all executors while an
active local shuffle has no durable copy, violating the hold guarantee and
forcing recomputation. The per-shuffle registry needs to participate in those
application-wide eligibility checks, or this override state needs to be
excluded.
**Recommended change:** Expose a thread-safe aggregate of active
ordinary-shuffle reliability from the tracker and require hold and
dynamic-allocation scale-down decisions to refuse retirement whenever any
active shuffle is explicitly unreliable.
**Why this works:** Keep the global capability as the None fallback and
configuration bootstrap, but make runtime retirement conditional on the
resolved active per-shuffle registry. Re-evaluate the aggregate at each hold or
executor-removal decision so registering or unregistering a fallback shuffle
changes admission without duplicating reliability resolution in consumers.
**Scope:** Connect registered per-shuffle reliability to application-wide
executor retirement and add mixed-mode lifecycle coverage.
**Compatibility:** Per-shuffle Some(true)/Some(false) remains authoritative
in DAGScheduler and TaskSetManager, while None keeps the legacy global fallback.
**Risks:** A stale or incorrectly synchronized aggregate could allow unsafe
retirement or permanently suppress scale-down. An empty active registry must
not be confused with a manager that can never produce unreliable shuffles.
**Constraints:** Some(false) must remain authoritative over global true.
Legacy None plus global true behavior must remain available when all active
shuffles resolve reliable. The lifecycle query must track shuffle registration
and unregistration safely across scheduler and allocation threads.
**Success:** Hold and ordinary dynamic-allocation retirement cannot evict
executors while an active ordinary shuffle resolves unreliable and no
independent preservation mechanism protects it. Removing the last active
unreliable shuffle restores the all-reliable admission path. Per-shuffle
cleanup and task resubmission continue to use the same resolved Boolean.
##########
core/src/main/scala/org/apache/spark/MapOutputTracker.scala:
##########
@@ -1043,21 +1126,76 @@ private[spark] class MapOutputTrackerMaster(
/**
* Removes all shuffle outputs associated with this host. Note that this
will also remove
- * outputs which are served by an external shuffle server (if one exists).
+ * outputs which are served by an external shuffle server (if one exists).
Unconditional cleanup:
+ * every shuffle's output on the host is dropped. See the two-argument
overload for the selective
+ * (reliable-storage-preserving) variant.
*/
- def removeOutputsOnHost(host: String): Unit = {
- shuffleStatuses.valuesIterator.foreach { _.removeOutputsOnHost(host) }
- incrementEpoch()
- }
+ def removeOutputsOnHost(host: String): Unit =
+ removeOutputsOnHost(host, respectReliablyStored = false)
/**
- * Removes all shuffle outputs associated with this executor. Note that this
will also remove
+ * Removes all map outputs associated with this executor. Note that this
will also remove
* outputs which are served by an external shuffle server (if one exists),
as they are still
- * registered with this execId.
+ * registered with this execId. Unconditional cleanup: every shuffle's
output on the executor is
+ * dropped. See the three-argument overload for the selective variant.
*/
- def removeOutputsOnExecutor(execId: String): Unit = {
- shuffleStatuses.valuesIterator.foreach { _.removeOutputsOnExecutor(execId)
}
- incrementEpoch()
+ def removeOutputsOnExecutor(execId: String): Unit =
+ removeOutputsOnExecutor(execId, respectReliablyStored = false)
+
+ /**
+ * Removes shuffle outputs on this host. With `respectReliablyStored` true,
reliably-stored
+ * shuffles are kept, except `failedShuffleId` (the shuffle whose fetch
failed), which is cleared
Review Comment:
**Nit (P3):** This Scaladoc promises that `failedShuffleId` is exempted from
preservation, but this overload always delegates with `None`; only the
following three-argument overload can supply that value. Please put the
failed-shuffle semantics on the overload that accepts the parameter so callers
are not promised behavior this signature cannot provide.
##########
core/src/main/scala/org/apache/spark/MapOutputTracker.scala:
##########
@@ -929,23 +994,41 @@ private[spark] class MapOutputTrackerMaster(
shuffleStatuses.valuesIterator.count(_.hasCachedSerializedBroadcast)
}
- def registerShuffle(shuffleId: Int, numMaps: Int, numReduces: Int): Unit = {
+ def registerShuffle(shuffleId: Int, numMaps: Int, numReduces: Int): Unit =
+ registerShuffle(shuffleId, numMaps, numReduces, isReliablyStored = false)
+
+ def registerShuffle(
+ shuffleId: Int,
+ numMaps: Int,
+ numReduces: Int,
+ isReliablyStored: Boolean): Unit = {
+ // isReliablyStored is orthogonal to push-based shuffle; the branches
differ only in tracking
+ // merge status (numReduces). pushBasedShuffleEnabled is app-global while
reliability is
+ // per-shuffle, so no shuffle is both: a reliable manager (e.g. Celeborn)
intercepts it, and its
Review Comment:
**Nit (P3):** This says no shuffle can be both push-based and reliably
stored, but the branch below stores reliability when push-based tracking is
enabled and the new host-loss test registers exactly that combination. Since
the map and merge cleanup split relies on this supported state, please describe
the actual orthogonality instead of claiming mutual exclusion.
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -1112,11 +1124,184 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
completeShuffleMapStageSuccessfully(0, 0, 1)
runEvent(ExecutorLost("hostA-exec", event))
verify(blockManagerMaster, times(1)).removeExecutorAsync("hostA-exec")
- verify(mapOutputTracker, times(0)).removeOutputsOnExecutor("hostA-exec")
+ verify(mapOutputTracker, times(0)).removeOutputsOnExecutor("hostA-exec",
true)
assert(mapOutputTracker.getMapSizesByExecutorId(shuffleId,
0).map(_._1).toSet ===
HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
}
+ test("SPARK-59138: executor loss keeps a reliably-stored shuffle but drops a
local-disk one") {
+ // No external shuffle service, so a plain (local-disk) shuffle's outputs
are lost on executor
+ // loss, but a per-shuffle reliably-stored one survives.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+
+ val reliableRdd = new MyRDD(sc, 2, Nil)
+ val reliableDep = new ReliablyStoredShuffleDependency(reliableRdd, new
HashPartitioner(1))
+ val localRdd = new MyRDD(sc, 2, Nil)
+ val localDep = new ShuffleDependency(localRdd, new HashPartitioner(1))
+ val reduceRdd = new MyRDD(sc, 1, List(reliableDep, localDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ // Two independent map stages, one per shuffle; complete both on hostA /
hostB.
+ completeShuffleMapStageSuccessfully(0, 0, 1)
+ completeShuffleMapStageSuccessfully(1, 0, 1)
+
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+ verify(mapOutputTracker, times(1)).removeOutputsOnExecutor("hostA-exec",
true)
+
+ // Reliable shuffle keeps hostA's output; local-disk shuffle loses it.
+ assert(mapOutputTracker.getMapSizesByExecutorId(reliableDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
+ intercept[MetadataFetchFailedException] {
+ mapOutputTracker.getMapSizesByExecutorId(localDep.shuffleId, 0)
+ }
+ }
+
+ test("SPARK-59138: per-shuffle reliablyStored=false overrides a global
supportsReliableStorage") {
+ // Global flag true, but one shuffle reports Some(false); the per-shuffle
value must win.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+ conf.set(config.SHUFFLE_IO_PLUGIN_CLASS.key,
+ classOf[TestShuffleDataIOWithMockedComponents].getName)
+ when(sc.shuffleDriverComponents.supportsReliableStorage()).thenReturn(true)
+
+ val reliableRdd = new MyRDD(sc, 2, Nil)
+ val reliableDep = new ReliablyStoredShuffleDependency(reliableRdd, new
HashPartitioner(1))
+ val fallbackRdd = new MyRDD(sc, 2, Nil)
+ val fallbackDep =
+ new ReliablyStoredShuffleDependency(fallbackRdd, new HashPartitioner(1),
Some(false))
+ val reduceRdd = new MyRDD(sc, 1, List(reliableDep, fallbackDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ completeShuffleMapStageSuccessfully(0, 0, 1)
+ completeShuffleMapStageSuccessfully(1, 0, 1)
+
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+
+ // Some(true) keeps hostA's output despite the global flag; Some(false)
loses it despite it.
+ assert(mapOutputTracker.getMapSizesByExecutorId(reliableDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
+ intercept[MetadataFetchFailedException] {
+ mapOutputTracker.getMapSizesByExecutorId(fallbackDep.shuffleId, 0)
+ }
+ }
+
+ test("SPARK-59138: same-epoch FetchFailed after selective executor-loss
cleanup is not skipped") {
+ // A selective cleanup preserves reliable outputs, so it must not stamp
shuffleFileLostEpoch;
Review Comment:
**Nit (P3):** These comments say selective cleanup leaves
`shuffleFileLostEpoch` unstamped and that the same-epoch failure performs
another selective removal. The current code stamps the executor fence after an
admitted cleanup; the reliable case proceeds through the per-shuffle bypass,
while the local duplicate is skipped. The assertions can pass while teaching
the obsolete state model, so please update the narrative to name the two-level
fence actually under test.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -4043,7 +4057,10 @@ private[spark] class DAGScheduler(
}
// TODO: mark the executor as failed only if there were lots of
fetch failures on it
- unregisterOutputsOnFetchFailedExecutor(bmAddress, task)
+ // Exempt the failed shuffle from reliable-preservation only on a
map-output failure
+ // (mapIndex != -1); a merged-chunk failure says nothing about its
reliable map output.
+ val failedMapShuffleId = if (mapIndex != -1) Some(shuffleId) else
None
Review Comment:
**Non-blocking (P2):** The `mapIndex == -1` branch must preserve reliable
original map output while handling the missing merged chunk, but current tests
separate reliable map failures from ordinary merged-chunk failures. Please add
a DAGScheduler case combining a reliably stored shuffle with a merged-chunk
failure so changing this `None` path to `Some(shuffleId)` fails instead of
silently discarding valid fallback maps.
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -1112,11 +1124,184 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
completeShuffleMapStageSuccessfully(0, 0, 1)
runEvent(ExecutorLost("hostA-exec", event))
verify(blockManagerMaster, times(1)).removeExecutorAsync("hostA-exec")
- verify(mapOutputTracker, times(0)).removeOutputsOnExecutor("hostA-exec")
+ verify(mapOutputTracker, times(0)).removeOutputsOnExecutor("hostA-exec",
true)
assert(mapOutputTracker.getMapSizesByExecutorId(shuffleId,
0).map(_._1).toSet ===
HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
}
+ test("SPARK-59138: executor loss keeps a reliably-stored shuffle but drops a
local-disk one") {
+ // No external shuffle service, so a plain (local-disk) shuffle's outputs
are lost on executor
+ // loss, but a per-shuffle reliably-stored one survives.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+
+ val reliableRdd = new MyRDD(sc, 2, Nil)
+ val reliableDep = new ReliablyStoredShuffleDependency(reliableRdd, new
HashPartitioner(1))
+ val localRdd = new MyRDD(sc, 2, Nil)
+ val localDep = new ShuffleDependency(localRdd, new HashPartitioner(1))
+ val reduceRdd = new MyRDD(sc, 1, List(reliableDep, localDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ // Two independent map stages, one per shuffle; complete both on hostA /
hostB.
+ completeShuffleMapStageSuccessfully(0, 0, 1)
+ completeShuffleMapStageSuccessfully(1, 0, 1)
+
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+ verify(mapOutputTracker, times(1)).removeOutputsOnExecutor("hostA-exec",
true)
+
+ // Reliable shuffle keeps hostA's output; local-disk shuffle loses it.
+ assert(mapOutputTracker.getMapSizesByExecutorId(reliableDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
+ intercept[MetadataFetchFailedException] {
+ mapOutputTracker.getMapSizesByExecutorId(localDep.shuffleId, 0)
+ }
+ }
+
+ test("SPARK-59138: per-shuffle reliablyStored=false overrides a global
supportsReliableStorage") {
+ // Global flag true, but one shuffle reports Some(false); the per-shuffle
value must win.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+ conf.set(config.SHUFFLE_IO_PLUGIN_CLASS.key,
+ classOf[TestShuffleDataIOWithMockedComponents].getName)
+ when(sc.shuffleDriverComponents.supportsReliableStorage()).thenReturn(true)
+
+ val reliableRdd = new MyRDD(sc, 2, Nil)
+ val reliableDep = new ReliablyStoredShuffleDependency(reliableRdd, new
HashPartitioner(1))
+ val fallbackRdd = new MyRDD(sc, 2, Nil)
+ val fallbackDep =
+ new ReliablyStoredShuffleDependency(fallbackRdd, new HashPartitioner(1),
Some(false))
+ val reduceRdd = new MyRDD(sc, 1, List(reliableDep, fallbackDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ completeShuffleMapStageSuccessfully(0, 0, 1)
+ completeShuffleMapStageSuccessfully(1, 0, 1)
+
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+
+ // Some(true) keeps hostA's output despite the global flag; Some(false)
loses it despite it.
+ assert(mapOutputTracker.getMapSizesByExecutorId(reliableDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
+ intercept[MetadataFetchFailedException] {
+ mapOutputTracker.getMapSizesByExecutorId(fallbackDep.shuffleId, 0)
+ }
+ }
+
+ test("SPARK-59138: same-epoch FetchFailed after selective executor-loss
cleanup is not skipped") {
+ // A selective cleanup preserves reliable outputs, so it must not stamp
shuffleFileLostEpoch;
+ // a same-epoch FetchFailed for a preserved-but-gone output must still
trigger real removal.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+
+ val shuffleMapRdd = new MyRDD(sc, 2, Nil)
+ val shuffleDep = new ReliablyStoredShuffleDependency(shuffleMapRdd, new
HashPartitioner(2))
+ val shuffleId = shuffleDep.shuffleId
+ val reduceRdd = new MyRDD(sc, 2, List(shuffleDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0, 1))
+ completeShuffleMapStageSuccessfully(0, 0, reduceRdd.partitions.length)
+
+ // Executor loss: reliably-stored shuffle is preserved (selective
cleanup), no output removed.
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+ assert(mapOutputTracker.getMapSizesByExecutorId(shuffleId,
0).map(_._1.host).toSet ===
+ HashSet("hostA", "hostB"))
+
+ // Same-epoch FetchFailed for hostA's (really gone) output: no epoch was
stamped, so the
+ // epoch-gated cleanup proceeds instead of being skipped. The executor
loss preserved the
+ // reliable shuffle (2-arg cleanup); this map-output FetchFailed exempts
the failed shuffle.
+ complete(taskSets(1), Seq(
+ (Success, 42),
+ (FetchFailed(makeBlockManagerId("hostA"), shuffleId, 0L, 0, 1,
"ignored"), null)))
+ verify(mapOutputTracker, times(1)).removeOutputsOnExecutor("hostA-exec",
true)
+ verify(mapOutputTracker, times(1)).removeOutputsOnExecutor("hostA-exec",
true, Some(shuffleId))
+ }
+
+ test("SPARK-59138: same-epoch FetchFailed for a local shuffle preserves a
reliable one") {
+ // Executor loss selectively drops the local-disk shuffle but keeps the
reliable one. A
+ // same-epoch FetchFailed for the local shuffle must not bulk-drop the
reliable shuffle that
+ // shares the executor, since its output lives off-executor.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+
+ val reliableRdd = new MyRDD(sc, 2, Nil)
+ val reliableDep = new ReliablyStoredShuffleDependency(reliableRdd, new
HashPartitioner(1))
+ val localRdd = new MyRDD(sc, 2, Nil)
+ val localDep = new ShuffleDependency(localRdd, new HashPartitioner(1))
+ val reduceRdd = new MyRDD(sc, 1, List(reliableDep, localDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ completeShuffleMapStageSuccessfully(0, 0, 1)
+ completeShuffleMapStageSuccessfully(1, 0, 1)
+
+ runEvent(ExecutorLost("hostA-exec", ExecutorKilled))
+
+ // Same-epoch FetchFailed for the local shuffle. It removes non-reliable
output only, so the
+ // reliable shuffle keeps hostA's output.
+ complete(taskSets(2), Seq(
+ (FetchFailed(makeBlockManagerId("hostA"), localDep.shuffleId, 0L, 0, 0,
"ignored"), null)))
+ assert(mapOutputTracker.getMapSizesByExecutorId(reliableDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA"), makeBlockManagerId("hostB")))
+ }
+
+ test("SPARK-59138: map-output FetchFailed clears the failed shuffle's
co-located maps in one " +
+ "pass while an unrelated reliable shuffle on the same executor survives") {
+ // A FetchFailed against a reliably-stored map output is evidence that
shuffle's output on the
+ // host is actually gone, so the failed shuffle is exempted from reliable
preservation and all
+ // its maps on hostA clear together. A different reliable shuffle on hostA
has no such evidence
+ // and stays registered.
+ conf.set(config.SHUFFLE_SERVICE_ENABLED.key, "false")
+
+ val failedRdd = new MyRDD(sc, 2, Nil)
+ val failedDep = new ReliablyStoredShuffleDependency(failedRdd, new
HashPartitioner(1))
+ val otherRdd = new MyRDD(sc, 1, Nil)
+ val otherDep = new ReliablyStoredShuffleDependency(otherRdd, new
HashPartitioner(1))
+ val reduceRdd = new MyRDD(sc, 1, List(failedDep, otherDep), tracker =
mapOutputTracker)
+ submit(reduceRdd, Array(0))
+
+ // Put every map of both shuffles on hostA (the 1-task stage uses only the
first entry). The
+ // failed shuffle's two hostA maps must both clear via the FetchFailed
exemption; the unrelated
+ // reliable shuffle's hostA map stays registered.
+ completeShuffleMapStageSuccessfully(0, 0, 1, hostNames = Seq("hostA",
"hostA"))
+ completeShuffleMapStageSuccessfully(1, 0, 1, hostNames = Seq("hostA",
"hostA"))
+
+ complete(taskSets(2), Seq(
+ (FetchFailed(makeBlockManagerId("hostA"), failedDep.shuffleId, 0L, 0, 0,
"ignored"), null)))
+
+ // The failed shuffle loses both hostA maps in one pass (it is exempted
from preservation).
+ // getNumAvailableOutputs proves both are gone: the old per-map behavior
would leave one.
+ assert(mapOutputTracker.getNumAvailableOutputs(failedDep.shuffleId) === 0)
+ intercept[MetadataFetchFailedException] {
+ mapOutputTracker.getMapSizesByExecutorId(failedDep.shuffleId, 0)
+ }
+ // The unrelated reliable shuffle keeps hostA's output.
+ assert(mapOutputTracker.getMapSizesByExecutorId(otherDep.shuffleId,
0).map(_._1).toSet ===
+ HashSet(makeBlockManagerId("hostA")))
+ }
+
+ test("SPARK-59138: duplicate same-epoch FetchFailed for a local shuffle is
fenced even when a " +
Review Comment:
**Non-blocking (P2):** This duplicate regression uses a local shuffle, so it
never exercises `reliableFetchFailedBypass` or `reliableShuffleFileLostEpoch`.
A missing write or read of the new per-shuffle fence would still pass while
repeated reliable-shuffle failures could clear freshly recomputed maps. Please
deliver two same-epoch failures for the same reliably stored shuffle and assert
that only the first reaches cleanup.
See **Shared repair plan 1** in the review body.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -4674,27 +4715,42 @@ private[spark] class DAGScheduler(
clearCacheLocs()
}
if (fileLost) {
- // When the fetch failure is for a merged shuffle chunk,
ignoreShuffleFileLostEpoch is true
- // and so all the files will be removed.
- val remove = if (ignoreShuffleFileLostEpoch) {
- true
- } else if (!shuffleFileLostEpoch.contains(execId) ||
- shuffleFileLostEpoch(execId) < currentEpoch) {
- shuffleFileLostEpoch(execId) = currentEpoch
- true
- } else {
- false
+ // ignoreShuffleFileLostEpoch (merged-chunk failure) removes everything
past the gate. A
+ // FetchFailed for a reliably-stored shuffle is exempt once per epoch:
the executor loss
+ // preserved it, so the executor fence would otherwise skip clearing it
now proven gone.
+ val reliableFetchFailedBypass = failedShuffleId.exists { id =>
+ mapOutputTracker.isReliablyStored(id) &&
+ reliableShuffleFileLostEpoch.get((execId, id)).forall(_ <
currentEpoch)
}
- if (remove) {
+ val shouldRemove = ignoreShuffleFileLostEpoch ||
+ reliableFetchFailedBypass ||
+ !shuffleFileLostEpoch.contains(execId) ||
+ shuffleFileLostEpoch(execId) < currentEpoch
+ if (shouldRemove) {
hostToUnregisterOutputs match {
case Some(host) =>
logInfo(log"Shuffle files lost for host: ${MDC(HOST, host)} (epoch
" +
log"${MDC(EPOCH, currentEpoch)}")
- mapOutputTracker.removeOutputsOnHost(host)
+ failedShuffleId match {
+ case None => mapOutputTracker.removeOutputsOnHost(host,
respectReliablyStored)
+ case failed =>
+ mapOutputTracker.removeOutputsOnHost(host,
respectReliablyStored, failed)
+ }
case None =>
- logInfo(log"Shuffle files lost for executor: ${MDC(EXECUTOR_ID,
execId)} " +
- log"(epoch ${MDC(EPOCH, currentEpoch)})")
- mapOutputTracker.removeOutputsOnExecutor(execId)
+ logInfo(log"Shuffle files lost for executor: ${MDC(EXECUTOR_ID,
execId)} " +
+ log"(epoch ${MDC(EPOCH, currentEpoch)})")
+ failedShuffleId match {
+ case None => mapOutputTracker.removeOutputsOnExecutor(execId,
respectReliablyStored)
+ case failed =>
+ mapOutputTracker.removeOutputsOnExecutor(execId,
respectReliablyStored, failed)
+ }
+ }
+ // Stamp the executor fence on every real cleanup to dedup same-epoch
duplicates; also stamp
+ // the per-shuffle fence so a bypassed reliable shuffle's own
duplicates are deduped. Match
+ // prior behavior: don't stamp under ignoreShuffleFileLostEpoch.
+ if (!ignoreShuffleFileLostEpoch) {
+ shuffleFileLostEpoch(execId) = currentEpoch
+ failedShuffleId.foreach(id => reliableShuffleFileLostEpoch((execId,
id)) = currentEpoch)
Review Comment:
**Non-blocking (P2):** Every successful map-output `FetchFailed` writes an
`(execId, shuffleId)` entry here, including for non-reliable shuffles. These
entries are removed only if that exact executor ID is added again; shuffle
unregistration and replacement executors with new IDs do not retire them, so
long-running failure-heavy applications retain unbounded scheduler metadata.
See **Shared repair plan 1** in the review body.
##########
core/src/main/scala/org/apache/spark/MapOutputTracker.scala:
##########
@@ -369,30 +398,60 @@ private class ShuffleStatus(
}
/**
- * Removes all shuffle outputs associated with this host. Note that this
will also remove
- * outputs which are served by an external shuffle server (if one exists).
+ * Removes this shuffle's outputs on `host`. Map output is preserved when
`respectReliablyStored`
+ * is set and the shuffle is reliably stored (it lives off the host). Merge
results are always
+ * removed: a `MergeStatus` sits on its merger host, so one matching `host`
is genuinely gone and
+ * an off-host one does not match the filter. Also removes outputs served by
an external shuffle
+ * server, if any.
*/
- def removeOutputsOnHost(host: String): Unit = withWriteLock {
- logDebug(s"Removing outputs for host ${host}")
- removeOutputsByFilter(x => x.host == host)
- removeMergeResultsByFilter(x => x.host == host)
- }
+ def removeOutputsOnHost(host: String, respectReliablyStored: Boolean):
ShuffleRemovalResult =
+ withWriteLock {
+ logDebug(s"Removing outputs for host ${host}")
+ val filter = (x: BlockManagerId) => x.host == host
+ val mergeChanged = removeMergeResultsByFilter(filter)
+ removeMapOutputsSelectively(filter, respectReliablyStored, mergeChanged)
+ }
/**
- * Removes all map outputs associated with the specified executor. Note that
this will also
- * remove outputs which are served by an external shuffle server (if one
exists), as they are
- * still registered with that execId.
+ * Removes this shuffle's map outputs on the executor, preserving them when
+ * `respectReliablyStored` is set and the shuffle is reliably stored.
Executor loss never matches
+ * merge results, whose locations carry the synthetic merger executor id.
Also removes outputs
+ * served by an external shuffle server, if any, as they are still
registered with that execId.
*/
- def removeOutputsOnExecutor(execId: String): Unit = withWriteLock {
- logDebug(s"Removing outputs for execId ${execId}")
- removeOutputsByFilter(x => x.executorId == execId)
+ def removeOutputsOnExecutor(
+ execId: String,
+ respectReliablyStored: Boolean): ShuffleRemovalResult = withWriteLock {
+ logDebug(s"Removing outputs for execId ${execId}")
+ removeMapOutputsSelectively(x => x.executorId == execId,
respectReliablyStored,
+ alreadyChanged = false)
+ }
+
+ /**
+ * Shared map-output removal for the executor/host selective paths.
`mapPreservedReliable` is true
+ * only when a live map output actually matched `f`. `alreadyChanged` folds
in any merge removal
+ * the caller already performed.
+ */
+ private def removeMapOutputsSelectively(
+ f: BlockManagerId => Boolean,
+ respectReliablyStored: Boolean,
+ alreadyChanged: Boolean): ShuffleRemovalResult = withWriteLock {
+ if (respectReliablyStored && isReliablyStored) {
+ val preserved = mapStatuses.exists(s => s != null && f(s.location))
Review Comment:
**Non-blocking (P2):** This target-scoped `exists` check is what preserves
the epoch bump when reliable outputs exist only on other executors, but the
added tests cover only a matching reliable target and an actual mixed removal.
Please add a case with reliable output registered elsewhere and no output on
the lost target, and assert that the ordinary no-op loss still advances the
epoch; otherwise a regression to shuffle-level classification passes.
##########
core/src/main/scala/org/apache/spark/shuffle/ShuffleHandle.scala:
##########
@@ -25,4 +25,15 @@ import org.apache.spark.annotation.DeveloperApi
* @param shuffleId ID of the shuffle
*/
@DeveloperApi
-abstract class ShuffleHandle(val shuffleId: Int) extends Serializable {}
+abstract class ShuffleHandle(val shuffleId: Int) extends Serializable {
+ /**
+ * Whether this shuffle's output is stored reliably, off the executor that
produced it (e.g. a
Review Comment:
**Non-blocking (P2):** This `@DeveloperApi` contract promises that
`Some(true)` means output survives executor or host loss for any
`ShuffleHandle`. The common registration path also forwards the resolved value
for a `PipelinedShuffleDependency`, but `StreamingShuffleOutputTrackerMaster`
discards it and retains its existing group-abort/task-location recovery. That
lets a supported third-party pipelined manager opt into a guarantee it does not
receive; please explicitly exclude and reject that dependency kind, or
implement the contract for it.
**Recommended change:** Scope reliablyStored to materialized
map-output-tracked shuffles and make a non-empty pipelined value fail clearly
instead of being silently accepted and ignored.
**Why this works:** State the blocking/materialized restriction on the
public method, resolve the value only on the ordinary MapOutputTracker
registration route, and reject Some(_) for PipelinedShuffleDependency before
streaming or tracker-free registration. Remove the unused reliability parameter
from the streaming-only contract when possible.
**Scope:** Align the public reliability domain with dependency-kind
registration and pipelined loss semantics.
**Compatibility:** Pipelined group-atomic failure handling and transient
task-location tracking remain unchanged for supported handles.
**Risks:** A guard placed after partial stage registration could leave
inconsistent scheduler state.
**Constraints:** Blocking shuffle Some(true), Some(false), and None
semantics must remain unchanged. Pipelined managers returning the default None
must keep current behavior. Unsupported non-empty pipelined values must fail
before stage or tracker mutation.
**Success:** The public method explicitly names its supported dependency
domain. No pipelined registration silently accepts a reliability value it
cannot honor. Existing pipelined handles using the default None continue to
register normally.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -4674,27 +4715,42 @@ private[spark] class DAGScheduler(
clearCacheLocs()
}
if (fileLost) {
- // When the fetch failure is for a merged shuffle chunk,
ignoreShuffleFileLostEpoch is true
- // and so all the files will be removed.
- val remove = if (ignoreShuffleFileLostEpoch) {
- true
- } else if (!shuffleFileLostEpoch.contains(execId) ||
- shuffleFileLostEpoch(execId) < currentEpoch) {
- shuffleFileLostEpoch(execId) = currentEpoch
- true
- } else {
- false
+ // ignoreShuffleFileLostEpoch (merged-chunk failure) removes everything
past the gate. A
+ // FetchFailed for a reliably-stored shuffle is exempt once per epoch:
the executor loss
+ // preserved it, so the executor fence would otherwise skip clearing it
now proven gone.
+ val reliableFetchFailedBypass = failedShuffleId.exists { id =>
+ mapOutputTracker.isReliablyStored(id) &&
+ reliableShuffleFileLostEpoch.get((execId, id)).forall(_ <
currentEpoch)
Review Comment:
**Non-blocking (P2):** The bypass checks only the per-shuffle fence. If a
local failure at epoch `e2` has already stamped the executor fence, a delayed
reliable failure from `e1 < e2` with no per-shuffle entry is still admitted,
clears current maps, and assigns the executor fence back to `e1`; failures
through `e2` can then run cleanup again. The bypass must respect a newer
executor epoch, and this assignment must never lower that fence.
See **Shared repair plan 1** in the review body.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]