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

LuciferYang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 2f1536def224 [SPARK-57530][CORE] Make SparkFileUtils.recursiveList 
null-safe and linear-time
2f1536def224 is described below

commit 2f1536def224155eb48d5e5263735dbe7644b2d9
Author: YangJie <[email protected]>
AuthorDate: Tue Jun 30 17:10:30 2026 +0800

    [SPARK-57530][CORE] Make SparkFileUtils.recursiveList null-safe and 
linear-time
    
    ### What changes were proposed in this pull request?
    
    `SparkFileUtils.recursiveList` walks a directory tree by reading 
`File.listFiles` and draining a buffer used as a work queue. Two problems:
    
    - It calls `listFiles` without a null check. `File.listFiles` returns null 
when a directory cannot be read (an IO error, or the directory being removed 
during the walk), so the walk could throw `NullPointerException`.
    - It takes the next directory to visit with `Buffer.remove(0)`, which is 
O(n); on a wide tree the whole walk is O(n^2).
    
    This PR rewrites the traversal to:
    
    - null-guard both `listFiles` calls, skipping (and logging) a directory 
that cannot be listed instead of throwing;
    - use a `Queue` with O(1) dequeue, so the walk is linear in the number of 
entries.
    
    It also drops the now-clearly-dead `Option(...).getOrElse(Array.empty)` 
guard at the `RocksDBFileManager` call site, since `recursiveList` never 
returns null. For a readable tree the set of returned entries is unchanged.
    
    ### Why are the changes needed?
    
    `recursiveList` runs on real paths, such as the RocksDB state-store file 
manager and `LocalSparkCluster`. A directory that becomes unreadable mid-walk 
would crash the walk with an NPE instead of returning what it could, and the 
O(n^2) `remove(0)` is needless overhead on directories with many entries.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. For a readable directory tree the returned entries are identical; the 
only difference is that a directory which cannot be listed is now skipped with 
a warning instead of raising an internal `NullPointerException`.
    
    ### How was this patch tested?
    
    Added `SparkFileUtilsSuite` with three cases: a nested listing, a root 
whose `listFiles` returns null, and a subdirectory whose `listFiles` returns 
null mid-walk (with a spy confirming non-directories are not recursed into). 
The two null cases throw on the old code and pass with the fix. `build/sbt 
'common-utils/testOnly *SparkFileUtilsSuite'` passes.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56590 from LuciferYang/SPARK-recursivelist-robustness.
    
    Authored-by: YangJie <[email protected]>
    Signed-off-by: yangjie01 <[email protected]>
---
 .../org/apache/spark/util/SparkFileUtils.scala     | 29 ++++++--
 common/utils/src/test/resources/log4j2.properties  | 12 +++
 .../apache/spark/util/SparkFileUtilsSuite.scala    | 86 ++++++++++++++++++++++
 .../streaming/state/RocksDBFileManager.scala       |  2 +-
 4 files changed, 121 insertions(+), 8 deletions(-)

diff --git 
a/common/utils/src/main/scala/org/apache/spark/util/SparkFileUtils.scala 
b/common/utils/src/main/scala/org/apache/spark/util/SparkFileUtils.scala
index 3f1f9c1f9df7..7124ca30bc61 100644
--- a/common/utils/src/main/scala/org/apache/spark/util/SparkFileUtils.scala
+++ b/common/utils/src/main/scala/org/apache/spark/util/SparkFileUtils.scala
@@ -21,6 +21,8 @@ import java.net.{URI, URISyntaxException, URL}
 import java.nio.file.{Files, Path, StandardCopyOption}
 import java.nio.file.attribute.FileTime
 
+import scala.collection.mutable
+
 import org.apache.spark.internal.{Logging, LogKeys}
 import org.apache.spark.network.util.JavaUtils
 
@@ -59,16 +61,29 @@ private[spark] trait SparkFileUtils extends Logging {
 
   /**
    * Lists files recursively.
+   *
+   * A directory that cannot be listed (`File.listFiles` returns null, e.g. an 
IO error or the
+   * directory being removed during the walk) is skipped with a warning rather 
than throwing, so
+   * the result may be partial when part of the tree is unreadable.
    */
   def recursiveList(f: File): Array[File] = {
     require(f.isDirectory)
-    val result = f.listFiles.toBuffer
-    val dirList = result.filter(_.isDirectory)
-    while (dirList.nonEmpty) {
-      val curDir = dirList.remove(0)
-      val files = curDir.listFiles()
-      result ++= files
-      dirList ++= files.filter(_.isDirectory)
+    val result = mutable.ArrayBuffer[File]()
+    // Use a queue with O(1) dequeue rather than removing from the head of a 
buffer (O(n)), so the
+    // walk stays linear in the number of entries.
+    val dirs = mutable.Queue[File](f)
+    while (dirs.nonEmpty) {
+      val dir = dirs.dequeue()
+      // `File.listFiles` returns null when the directory cannot be read (an 
IO error, or it was
+      // removed during the walk); skip it instead of throwing an NPE, but log 
it so a partial
+      // result is traceable.
+      val entries = dir.listFiles()
+      if (entries != null) {
+        result ++= entries
+        dirs ++= entries.filter(_.isDirectory)
+      } else {
+        logWarning(log"Failed to list directory ${MDC(LogKeys.PATH, dir)}; 
skipping it.")
+      }
     }
     result.toArray
   }
diff --git a/common/utils/src/test/resources/log4j2.properties 
b/common/utils/src/test/resources/log4j2.properties
index cb38f5b55a0b..e23c60e58ae0 100644
--- a/common/utils/src/test/resources/log4j2.properties
+++ b/common/utils/src/test/resources/log4j2.properties
@@ -38,6 +38,13 @@ appender.pattern.fileName = target/pattern.log
 appender.pattern.layout.type = PatternLayout
 appender.pattern.layout.pattern = %d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n%ex
 
+# SparkFileUtils Logging Appender (used by SparkFileUtilsSuite to assert on 
warnings)
+appender.spark_file_utils.type = File
+appender.spark_file_utils.name = spark_file_utils
+appender.spark_file_utils.fileName = target/spark-file-utils.log
+appender.spark_file_utils.layout.type = PatternLayout
+appender.spark_file_utils.layout.pattern = %d{yy/MM/dd HH:mm:ss} %p %c{1}: 
%m%n%ex
+
 # Custom loggers
 logger.structured_logging.name = org.apache.spark.util.StructuredLoggingSuite
 logger.structured_logging.level = trace
@@ -58,3 +65,8 @@ logger.pattern_logger.name = 
org.apache.spark.util.PatternSparkLoggerSuite
 logger.pattern_logger.level = trace
 logger.pattern_logger.appenderRefs = pattern
 logger.pattern_logger.appenderRef.pattern.ref = pattern
+
+logger.spark_file_utils.name = org.apache.spark.util.SparkFileUtils
+logger.spark_file_utils.level = trace
+logger.spark_file_utils.appenderRefs = spark_file_utils
+logger.spark_file_utils.appenderRef.spark_file_utils.ref = spark_file_utils
diff --git 
a/common/utils/src/test/scala/org/apache/spark/util/SparkFileUtilsSuite.scala 
b/common/utils/src/test/scala/org/apache/spark/util/SparkFileUtilsSuite.scala
new file mode 100644
index 000000000000..02b09dbc24d2
--- /dev/null
+++ 
b/common/utils/src/test/scala/org/apache/spark/util/SparkFileUtilsSuite.scala
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.util
+
+import java.io.File
+import java.nio.file.Files
+
+import org.scalatest.funsuite.AnyFunSuite // scalastyle:ignore funsuite
+
+class SparkFileUtilsSuite extends AnyFunSuite { // scalastyle:ignore funsuite
+
+  // Returns the log content appended to `target/spark-file-utils.log` 
(configured for the
+  // SparkFileUtils logger in log4j2.properties) while running `f`.
+  private def captureLogOutput(f: () => Unit): String = {
+    val logFile = new File(new File(".").getCanonicalPath, 
"target/spark-file-utils.log")
+    val before = if (logFile.exists()) Files.readString(logFile.toPath) else ""
+    f()
+    val after = if (logFile.exists()) Files.readString(logFile.toPath) else ""
+    after.substring(before.length)
+  }
+
+  test("recursiveList lists all nested files and directories") {
+    val root = Files.createTempDirectory("spark-recursive-list").toFile
+    try {
+      val sub = new File(root, "sub")
+      assert(sub.mkdir())
+      val nested = new File(sub, "nested")
+      assert(nested.mkdir())
+      val topFile = new File(root, "top.txt")
+      assert(topFile.createNewFile())
+      val subFile = new File(sub, "sub.txt")
+      assert(subFile.createNewFile())
+
+      assert(SparkFileUtils.recursiveList(root).toSet === Set(sub, nested, 
topFile, subFile))
+    } finally {
+      SparkFileUtils.deleteQuietly(root)
+    }
+  }
+
+  test("recursiveList returns empty and warns instead of throwing when a dir 
cannot be listed") {
+    // A directory whose listFiles returns null must yield an empty result, 
not an NPE, and the
+    // skipped directory must be logged so a partial result is traceable.
+    val unreadable = new File("spark-unreadable-dir") {
+      override def isDirectory: Boolean = true
+      override def listFiles(): Array[File] = null
+    }
+    val logOutput = captureLogOutput(() => 
assert(SparkFileUtils.recursiveList(unreadable).isEmpty))
+    assert(logOutput.contains("Failed to list directory"))
+    assert(logOutput.contains("spark-unreadable-dir"))
+  }
+
+  test("recursiveList skips a subdirectory whose listFiles returns null 
mid-walk") {
+    // The null directory is not the root but one discovered during the walk, 
so the guard must
+    // hold at depth > 0. `unreadableSub` is a directory whose listFiles 
returns null; `leaf` is a
+    // plain file that must be returned but never recursed into (a spy 
verifies that).
+    var leafListed = false
+    val leaf = new File("leaf.txt") {
+      override def isDirectory: Boolean = false
+      override def listFiles(): Array[File] = { leafListed = true; 
super.listFiles() }
+    }
+    val unreadableSub = new File("unreadable-sub") {
+      override def isDirectory: Boolean = true
+      override def listFiles(): Array[File] = null
+    }
+    val root = new File("root") {
+      override def isDirectory: Boolean = true
+      override def listFiles(): Array[File] = Array(leaf, unreadableSub)
+    }
+    assert(SparkFileUtils.recursiveList(root).toSet === Set(leaf, 
unreadableSub))
+    assert(!leafListed, "a non-directory entry must not be recursed into")
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBFileManager.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBFileManager.scala
index 374cfc5ed0cd..8284f50ec1a8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBFileManager.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/state/RocksDBFileManager.scala
@@ -1086,7 +1086,7 @@ class RocksDBFileManager(
 
   /** Log the files present in a directory. This is useful for debugging. */
   private def logFilesInDir(dir: File, msg: MessageWithContext): Unit = {
-    lazy val files = 
Option(Utils.recursiveList(dir)).getOrElse(Array.empty).map { f =>
+    lazy val files = Utils.recursiveList(dir).map { f =>
       s"${f.getAbsolutePath} - ${f.length()} bytes"
     }
     logDebug(msg + log" - ${MDC(LogKeys.NUM_FILES, files.length)} files\n\t" +


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

Reply via email to