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

LuciferYang 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 09d12485d677 [SPARK-58693][K8S] Skip unlistable directories in K8s 
shuffle data recovery
09d12485d677 is described below

commit 09d12485d6772b2d37561a672c0c598ec42057e6
Author: YangJie <[email protected]>
AuthorDate: Tue Aug 11 16:11:43 2026 +0800

    [SPARK-58693][K8S] Skip unlistable directories in K8s shuffle data recovery
    
    ### What changes were proposed in this pull request?
    
    Make the local-disk shuffle recovery scan in 
`KubernetesLocalDiskShuffleExecutorComponents.recoverDiskStore` tolerate 
directories it cannot list, instead of aborting the whole recovery.
    
    All five listings now go through a helper that logs and skips when 
`File.listFiles` returns null. A local directory too shallow to have a volume 
root two levels up is skipped with a warning that names the config to change. 
The dead `if (files != null)` guard on an `Array.flatMap` result is removed. 
`SPARK-57530` took the same approach for `SparkFileUtils.recursiveList`.
    
    ### Why are the changes needed?
    
    The scan is a strict `Array.map`/`flatMap` chain with no null handling on 
any of its five `File.listFiles` calls, and the two-level `getParent` walk is 
unguarded. One bad entry throws, `initializeExecutor` swallows the exception 
through `Utils.tryLogNonFatalError`, and the executor goes on to serve requests 
with nothing recovered. The only trace is an NPE stack trace that does not 
mention shuffle recovery, and the job recomputes every map output on the reused 
PVC.
    
    Two cases reach it.
    
    A configured local directory with fewer than three path components. The 
scan walks two levels up from each entry, so for `/data` the first `getParent` 
yields `/` and the second yields null, and `new File((null: String))` throws 
`NullPointerException` from `java.io.File.<init>`. `/data` is the mount path in 
the code block immediately above the recovery instructions in the Local Storage 
section of `running-on-kubernetes.md`, so this is reachable by following the 
docs. Because `map` is s [...]
    
    A directory in the walk whose `listFiles()` returns null: removed while the 
walk runs, or present but not readable by the executor uid.
    
    The only null check in the method, `if (files != null)` applied to the 
result of `Array.flatMap`, can never fire, so the walk reads as null-safe while 
none of the five listings are guarded.
    
    `SPARK-40459` already established that a single file should not block the 
rest of the recovery. The collection phase in front of that loop was still 
all-or-nothing.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration change. Recovery that previously aborted on either 
condition now proceeds and recovers the remaining files, and each skip logs a 
warning. 
`spark.shuffle.sort.io.plugin.class=org.apache.spark.shuffle.KubernetesLocalDiskShuffleDataIO`
 plus `spark.kubernetes.driver.reusePersistentVolumeClaim=true` are still 
required for any of this code to run.
    
    ### How was this patch tested?
    
    Two tests added to `KubernetesLocalDiskShuffleDataIOSuite`, one per 
condition, both driving `recoverDiskStore` directly.
    
    Each test stubs `BlockManager.TempFileBasedBlockStoreUpdater` to throw a 
sentinel `IllegalStateException` and asserts that message, so passing requires 
the scan to have walked down to a recoverable shuffle file. A test that merely 
completed without throwing would pass on an empty scan and prove nothing.
    
    Both were confirmed to fail against the unfixed tree before the fix was 
written, with the NPE arriving from the expected place in each case:
    
    ```
    [info] - SPARK-58693: a local dir with fewer than three path components is 
skipped *** FAILED *** (571 milliseconds)
    [info]   Expected exception java.lang.IllegalStateException to be thrown, 
but java.lang.NullPointerException was thrown 
(KubernetesLocalDiskShuffleDataIOSuite.scala:280)
    [info]   Cause: java.lang.NullPointerException:
    [info]   at java.base/java.io.File.<init>(File.java:278)
    [info] - SPARK-58693: an unlistable directory in the scan does not abort 
recovery *** FAILED *** (8 milliseconds)
    [info]   Expected exception java.lang.IllegalStateException to be thrown, 
but java.lang.NullPointerException was thrown 
(KubernetesLocalDiskShuffleDataIOSuite.scala:298)
    [info]   Cause: java.lang.NullPointerException: Cannot invoke 
"scala.collection.IterableOnce.knownSize()" because "xs" is null
    [info]   at 
scala.collection.mutable.ArrayBuilder.addAll(ArrayBuilder.scala:69)
    [info]   at 
scala.collection.mutable.ArrayBuilder.addAll(ArrayBuilder.scala:24)
    ```
    
    The unlistable-directory test uses `setReadable(false, false)` and, 
following `FsHistoryProviderSuite`, `assume`s the bit actually cleared so it 
self-skips for root. The scala `kubernetes` module runs in the `build` job, 
which has no `container:` and therefore does not run as root, so it does 
execute in CI. The other test needs no permission manipulation and runs 
everywhere.
    
    `build/sbt -Pkubernetes 'kubernetes/testOnly 
org.apache.spark.shuffle.KubernetesLocalDiskShuffleDataIOSuite'`:
    
    ```
    [info] - recompute is not blocked by the recovery (12 seconds, 384 
milliseconds)
    [info] - Partial recompute shuffle data (11 seconds, 118 milliseconds)
    [info] - A new rdd and full recovery of old data (8 seconds, 816 
milliseconds)
    [info] - Multi stages (8 seconds, 557 milliseconds)
    [info] - SPARK-44501: Ignore checksum files (571 milliseconds)
    [info] - SPARK-44534: Handle only shuffle files (7 milliseconds)
    [info] - SPARK-58693: a local dir with fewer than three path components is 
skipped (6 milliseconds)
    [info] - SPARK-58693: an unlistable directory in the scan does not abort 
recovery (4 milliseconds)
    [info] Run completed in 42 seconds, 491 milliseconds.
    [info] Total number of tests run: 8
    [info] Suites: completed 1, aborted 0
    [info] Tests: succeeded 8, failed 0, canceled 0, ignored 0, pending 0
    ```
    
    `kubernetes/scalastyle` and `kubernetes/Test/scalastyle` report 0 errors.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    Closes #57905 from LuciferYang/fix-k8s-shuffle-recovery-listfiles.
    
    Authored-by: YangJie <[email protected]>
    Signed-off-by: yangjie01 <[email protected]>
    (cherry picked from commit 595a88249d6cc584ed2d2a728b9b2b94034d870f)
    Signed-off-by: yangjie01 <[email protected]>
---
 ...ernetesLocalDiskShuffleExecutorComponents.scala | 54 ++++++++++++++++++----
 .../KubernetesLocalDiskShuffleDataIOSuite.scala    | 52 +++++++++++++++++++++
 2 files changed, 97 insertions(+), 9 deletions(-)

diff --git 
a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleExecutorComponents.scala
 
b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleExecutorComponents.scala
index cbe215c3f218..3d3b10bcecba 100644
--- 
a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleExecutorComponents.scala
+++ 
b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleExecutorComponents.scala
@@ -71,6 +71,42 @@ class 
KubernetesLocalDiskShuffleExecutorComponents(sparkConf: SparkConf)
 }
 
 object KubernetesLocalDiskShuffleExecutorComponents extends Logging {
+  /**
+   * `File.listFiles` returns null when the directory cannot be read (an IO 
error, a permission
+   * denial, or the directory being removed during the walk). Skip such a 
directory with a warning
+   * instead of failing the whole recovery, so one unreadable entry cannot 
cost us every other
+   * recoverable file. This is the single-level counterpart of 
`SparkFileUtils.recursiveList`, and
+   * unlike `JavaUtils.listFilesSafely` it does not throw on an unlistable 
directory.
+   */
+  private def listEntriesOrEmpty(dir: File): Array[File] = {
+    val entries = dir.listFiles()
+    if (entries != null) {
+      entries
+    } else {
+      logWarning(log"Failed to list ${MDC(LogKeys.FILE_ABSOLUTE_PATH, 
dir.getAbsolutePath)}; " +
+        log"skipping it during shuffle data recovery.")
+      Array.empty[File]
+    }
+  }
+
+  /**
+   * Walks two levels up from a local directory to reach the volume root 
shared by all executor
+   * generations. Returns None for a path with fewer than three components, 
where `getParent`
+   * yields null.
+   */
+  private def volumeRootOf(localDir: String): Option[File] = {
+    val root = Option(new File(localDir).getParentFile).flatMap(p => 
Option(p.getParentFile))
+    if (root.isEmpty) {
+      logWarning(log"Cannot locate the volume root of local directory " +
+        log"${MDC(LogKeys.PATH, localDir)}; skipping it during shuffle data 
recovery. Recovery " +
+        log"requires a local directory nested at least two levels below the 
volume root, such as " +
+        log"/data/spark-x/executor-y. Set the mount path of the 
spark-local-dir-* volume, or " +
+        log"${MDC(LogKeys.CONFIG, "spark.local.dir")} when no such volume is 
mounted, " +
+        log"accordingly.")
+    }
+    root
+  }
+
   /**
    * This tries to recover shuffle data of dead executors' local dirs if 
exists.
    * Since the executors are already dead, we cannot use `getHostLocalDirs`.
@@ -79,18 +115,18 @@ object KubernetesLocalDiskShuffleExecutorComponents 
extends Logging {
   def recoverDiskStore(conf: SparkConf, bm: BlockManager): Unit = {
     // Find All files
     val (checksumFiles, files) = Utils.getConfiguredLocalDirs(conf)
-      .filter(_ != null)
-      .map(s => new File(new File(new File(s).getParent).getParent))
+      .filter(s => s != null && s.nonEmpty)
+      .flatMap(volumeRootOf)
       .flatMap { dir =>
-        val oldDirs = dir.listFiles().filter { f =>
+        val oldDirs = listEntriesOrEmpty(dir).filter { f =>
           f.isDirectory && f.getName.startsWith("spark-")
         }
-        val files = oldDirs
-          .flatMap(_.listFiles).filter(_.isDirectory) // executor-xxx
-          .flatMap(_.listFiles).filter(_.isDirectory) // blockmgr-xxx
-          .flatMap(_.listFiles).filter(_.isDirectory) // 00
-          .flatMap(_.listFiles)
-        if (files != null) files.toImmutableArraySeq else Seq.empty
+        oldDirs
+          .flatMap(listEntriesOrEmpty).filter(_.isDirectory) // executor-xxx
+          .flatMap(listEntriesOrEmpty).filter(_.isDirectory) // blockmgr-xxx
+          .flatMap(listEntriesOrEmpty).filter(_.isDirectory) // 00
+          .flatMap(listEntriesOrEmpty)
+          .toImmutableArraySeq
       }
       .partition(_.getName.contains(".checksum"))
     val (indexFiles, dataFiles) = files.partition(_.getName.endsWith(".index"))
diff --git 
a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleDataIOSuite.scala
 
b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleDataIOSuite.scala
index 25b2cad9ddd2..ba1777719afb 100644
--- 
a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleDataIOSuite.scala
+++ 
b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/shuffle/KubernetesLocalDiskShuffleDataIOSuite.scala
@@ -252,4 +252,56 @@ class KubernetesLocalDiskShuffleDataIOSuite extends 
SparkFunSuite with LocalRoot
     when(bm.TempFileBasedBlockStoreUpdater).thenAnswer(_ => throw new 
Exception())
     KubernetesLocalDiskShuffleExecutorComponents.recoverDiskStore(sparkConf, 
bm)
   }
+
+  // A sentinel thrown from the block-store updater. Reaching it proves the 
scan actually
+  // walked down to a recoverable shuffle file, so the test cannot pass 
vacuously.
+  private val sentinelMessage = "reached a shuffle file"
+
+  private def sentinelBlockManager(): BlockManager = {
+    val bm = mock(classOf[BlockManager])
+    when(bm.TempFileBasedBlockStoreUpdater)
+      .thenAnswer(_ => throw new IllegalStateException(sentinelMessage))
+    bm
+  }
+
+  private def createRecoverableShuffleFile(localDir: String): Unit = {
+    val dir = new File(localDir, "blockmgr-z/00")
+    Files.createDirectories(dir.toPath())
+    Files.write(new File(dir, "shuffle_0_0_0.index").toPath(), Array[Byte](0, 
0, 0, 0))
+  }
+
+  test("SPARK-58693: a local dir with fewer than three path components is 
skipped") {
+    val deepDir = conf.get("spark.local.dir") + "/spark-x/executor-y"
+    createRecoverableShuffleFile(deepDir)
+    // "/data" is what the Local Storage example in running-on-kubernetes.md 
configures, and
+    // walking two levels up from it yields null. The deep dir must still be 
recovered.
+    val sparkConf = conf.clone.set("spark.local.dir", s"/data,$deepDir")
+
+    val m = intercept[IllegalStateException] {
+      KubernetesLocalDiskShuffleExecutorComponents
+        .recoverDiskStore(sparkConf, sentinelBlockManager())
+    }.getMessage
+    assert(m.contains(sentinelMessage))
+  }
+
+  test("SPARK-58693: an unlistable directory in the scan does not abort 
recovery") {
+    val deepDir = conf.get("spark.local.dir") + "/spark-x/executor-y"
+    createRecoverableShuffleFile(deepDir)
+    // Shaped like the ext4 lost+found that sits at the root of a freshly 
formatted PVC: a
+    // directory the executor can see but not list, so listFiles() returns 
null.
+    val unlistable = new File(conf.get("spark.local.dir") + 
"/spark-x/lost+found")
+    Files.createDirectories(unlistable.toPath())
+    assert(unlistable.setReadable(false, false))
+    assume(!unlistable.canRead, "requires an unreadable directory; skipped 
when run as root")
+
+    try {
+      val m = intercept[IllegalStateException] {
+        KubernetesLocalDiskShuffleExecutorComponents
+          .recoverDiskStore(conf.clone.set("spark.local.dir", deepDir), 
sentinelBlockManager())
+      }.getMessage
+      assert(m.contains(sentinelMessage))
+    } finally {
+      unlistable.setReadable(true, false)
+    }
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to