This is an automated email from the ASF dual-hosted git repository.
cloud-fan 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 0417f91069cd [SPARK-57354][SQL] Add ignoredPathSegmentRegex data
source option and config
0417f91069cd is described below
commit 0417f91069cd216783ea0a4c968b03125c2a2622
Author: Calvin Qin <[email protected]>
AuthorDate: Tue Jun 23 13:28:40 2026 -0700
[SPARK-57354][SQL] Add ignoredPathSegmentRegex data source option and config
### What changes were proposed in this pull request?
Add an `ignoredPathSegmentRegex` data source option plus a
`spark.sql.files.ignoredPathSegmentRegex` session config (default `^[._]`) for
file-based sources. The value is a Java regex matched with find semantics
against each individual directory/file name during listing; matching names are
skipped from file listing, partition discovery, and reads (a matching directory
excludes its whole subtree). The per-read option overrides the session config.
Both batch and Structured Streaming rea [...]
Regardless of the regex, three rules are hardcoded:
- `_metadata/_common_metadata` (Parquet summary files) are always listed
- `*._COPYING_` (in-flight Hadoop FS-shell copies) are always skipped
- `_-`prefixed partition dirs containing `=` are always kept.
A regex that never matches (`(?!)`, or empty string -- pending decision
below) disables the generic hidden-file filter.
Two carve-outs: table statistics (CommandUtils) pin the default regex so
ANALYZE sizes are unaffected by the session conf; and Parquet schema inference
now excludes zero-length files from footer candidates so a directory containing
a 0-byte _SUCCESS marker stays readable when hidden files are surfaced.
User documentation is added to `sql-data-sources-generic-options.md` and
the Structured Streaming guide.
### Why are the changes needed?
Hidden files listing is being requested as a feature. We want to enable a
user-facing option for this.
### Does this PR introduce _any_ user-facing change?
Yes. By setting the new option `ignorePathSegmentRegex = ""`, hidden files
will now show up inside all file listing operations. Default value preserves
current behavior of skipping hidden files.
### How was this patch tested?
Added unit tests covering the data source option and session conf,
exercising the file-listing path, partition discovery, batch reads, and
structured streaming. Also added tests for the session conf in streaming,
partition discovery with a hidden directory next to partition dirs, and reading
an unmodified Spark-written parquet directory with the option enabled.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
Closes #56374 from CalvQ/list-hidden-files-option.
Lead-authored-by: Calvin Qin <[email protected]>
Co-authored-by: Calvin Qin <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../org/apache/spark/util/HadoopFSUtils.scala | 100 +++++++-----
.../org/apache/spark/util/HadoopFSUtilsSuite.scala | 132 ++++++++++++++-
docs/sql-data-sources-generic-options.md | 40 +++++
docs/sql-migration-guide.md | 1 +
docs/streaming/apis-on-dataframes-and-datasets.md | 2 +
.../examples/sql/JavaSQLDataSourceExample.java | 7 +
examples/src/main/python/sql/datasource.py | 8 +
examples/src/main/r/RSparkSQLExample.R | 6 +
.../spark/examples/sql/SQLDataSourceExample.scala | 7 +
.../spark/sql/catalyst/FileSourceOptions.scala | 41 ++++-
.../org/apache/spark/sql/internal/SQLConf.scala | 26 ++-
.../spark/sql/execution/command/CommandUtils.scala | 11 +-
.../sql/execution/datasources/ArchiveReader.scala | 33 ++--
.../sql/execution/datasources/DataSource.scala | 14 +-
.../execution/datasources/FileIndexOptions.scala | 1 +
.../execution/datasources/InMemoryFileIndex.scala | 29 +++-
.../datasources/PartitioningAwareFileIndex.scala | 20 ++-
.../execution/datasources/csv/CSVDataSource.scala | 29 ++--
.../execution/datasources/csv/CSVFileFormat.scala | 5 +-
.../datasources/parquet/ParquetUtils.scala | 5 +-
.../sql/execution/datasources/v2/FileTable.scala | 6 +-
.../spark/sql/FileBasedDataSourceSuite.scala | 164 +++++++++++++++++++
.../execution/datasources/ArchiveReaderSuite.scala | 16 ++
.../sql/execution/datasources/FileIndexSuite.scala | 179 ++++++++++++++++++++-
.../sql/streaming/FileStreamSourceSuite.scala | 79 +++++++++
25 files changed, 880 insertions(+), 81 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/util/HadoopFSUtils.scala
b/core/src/main/scala/org/apache/spark/util/HadoopFSUtils.scala
index f84184f7c1fa..2f5b02dbf577 100644
--- a/core/src/main/scala/org/apache/spark/util/HadoopFSUtils.scala
+++ b/core/src/main/scala/org/apache/spark/util/HadoopFSUtils.scala
@@ -18,9 +18,9 @@
package org.apache.spark.util
import java.io.FileNotFoundException
+import java.util.regex.Pattern
import scala.collection.mutable
-import scala.util.matching.Regex
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs._
@@ -37,6 +37,18 @@ import org.apache.spark.util.ArrayImplicits._
* Utility functions to simplify and speed-up file listing.
*/
private[spark] object HadoopFSUtils extends Logging {
+ /**
+ * The default value of the `ignoredPathSegmentRegex` option: a regex
evaluated with find
+ * semantics against each individual directory and file name during file
listing. It hides names
+ * that start with '_' or '.'; see `shouldFilterOutPathName` for the
carve-outs that apply
+ * regardless of the regex.
+ */
+ val DEFAULT_IGNORED_PATH_SEGMENT_REGEX = "^[._]"
+
+ /** The compiled form of [[DEFAULT_IGNORED_PATH_SEGMENT_REGEX]]. */
+ val defaultIgnoredPathSegmentRegexPattern: Pattern =
+ Pattern.compile(DEFAULT_IGNORED_PATH_SEGMENT_REGEX)
+
/**
* Lists a collection of paths recursively. Picks the listing strategy
adaptively depending
* on the number of paths to list.
@@ -49,6 +61,10 @@ private[spark] object HadoopFSUtils extends Logging {
* @param filter Path filter used to exclude leaf files from result
* @param ignoreMissingFiles Ignore missing files that happen during
recursive listing
* (e.g., due to race conditions)
+ * @param ignoredPathSegmentRegex Regex evaluated with find semantics
against each directory and
+ * file name below the input paths; matching names
are skipped and a
+ * matching directory name excludes its subtree.
See
+ * `shouldFilterOutPathName` for the carve-outs
that always apply.
* @param ignoreLocality Whether to fetch data locality info when listing
leaf files. If false,
* this will return `FileStatus` without
`BlockLocation` info.
* @param parallelismThreshold The threshold to enable parallelism. If the
number of input paths
@@ -65,11 +81,13 @@ private[spark] object HadoopFSUtils extends Logging {
hadoopConf: Configuration,
filter: PathFilter,
ignoreMissingFiles: Boolean,
+ ignoredPathSegmentRegex: Pattern,
ignoreLocality: Boolean,
parallelismThreshold: Int,
parallelismMax: Int): Seq[(Path, Seq[FileStatus])] = {
parallelListLeafFilesInternal(sc, paths, hadoopConf, filter, isRootLevel =
true,
- ignoreMissingFiles, ignoreLocality, parallelismThreshold, parallelismMax)
+ ignoreMissingFiles, ignoredPathSegmentRegex, ignoreLocality,
parallelismThreshold,
+ parallelismMax)
}
/**
@@ -81,12 +99,17 @@ private[spark] object HadoopFSUtils extends Logging {
* @param path a path to list
* @param hadoopConf Hadoop configuration
* @param filter Path filter used to exclude leaf files from result
+ * @param ignoredPathSegmentRegex Regex evaluated with find semantics
against each directory and
+ * file name below `path`; matching names are
skipped. See
+ * `shouldFilterOutPathName` for the carve-outs
that always apply.
* @return the set of discovered files for the path
*/
def listFiles(
path: Path,
hadoopConf: Configuration,
- filter: PathFilter): Seq[(Path, Seq[FileStatus])] = {
+ filter: PathFilter,
+ ignoredPathSegmentRegex: Pattern = defaultIgnoredPathSegmentRegexPattern)
+ : Seq[(Path, Seq[FileStatus])] = {
logInfo(log"Listing ${MDC(PATH, path)} with listFiles API")
try {
val prefixLength = path.toString.length
@@ -94,7 +117,9 @@ private[spark] object HadoopFSUtils extends Logging {
val statues = new Iterator[LocatedFileStatus]() {
def next(): LocatedFileStatus = remoteIter.next
def hasNext: Boolean = remoteIter.hasNext
- }.filterNot(status =>
shouldFilterOutPath(status.getPath.toString.substring(prefixLength)))
+ }.filterNot(status =>
+ shouldFilterOutPath(
+ status.getPath.toString.substring(prefixLength),
ignoredPathSegmentRegex))
.filter(f => filter.accept(f.getPath))
.toArray
Seq((path, statues.toImmutableArraySeq))
@@ -113,6 +138,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter: PathFilter,
isRootLevel: Boolean,
ignoreMissingFiles: Boolean,
+ ignoredPathSegmentRegex: Pattern,
ignoreLocality: Boolean,
parallelismThreshold: Int,
parallelismMax: Int): Seq[(Path, Seq[FileStatus])] = {
@@ -126,6 +152,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter,
Some(sc),
ignoreMissingFiles = ignoreMissingFiles,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
ignoreLocality = ignoreLocality,
isRootPath = isRootLevel,
parallelismThreshold = parallelismThreshold,
@@ -166,6 +193,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter = filter,
contextOpt = None, // Can't execute parallel scans on workers
ignoreMissingFiles = ignoreMissingFiles,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
ignoreLocality = ignoreLocality,
isRootPath = isRootLevel,
parallelismThreshold = Int.MaxValue,
@@ -193,6 +221,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter: PathFilter,
contextOpt: Option[SparkContext],
ignoreMissingFiles: Boolean,
+ ignoredPathSegmentRegex: Pattern,
ignoreLocality: Boolean,
isRootPath: Boolean,
parallelismThreshold: Int,
@@ -251,7 +280,8 @@ private[spark] object HadoopFSUtils extends Logging {
}
val filteredStatuses =
- statuses.filterNot(status =>
shouldFilterOutPathName(status.getPath.getName))
+ statuses.filterNot(status =>
+ shouldFilterOutPathName(status.getPath.getName,
ignoredPathSegmentRegex))
val allLeafStatuses = {
val (dirs, topLevelFiles) = filteredStatuses.partition(_.isDirectory)
@@ -264,6 +294,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter = filter,
isRootLevel = false,
ignoreMissingFiles = ignoreMissingFiles,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
ignoreLocality = ignoreLocality,
parallelismThreshold = parallelismThreshold,
parallelismMax = parallelismMax
@@ -276,6 +307,7 @@ private[spark] object HadoopFSUtils extends Logging {
filter = filter,
contextOpt = contextOpt,
ignoreMissingFiles = ignoreMissingFiles,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
ignoreLocality = ignoreLocality,
isRootPath = false,
parallelismThreshold = parallelismThreshold,
@@ -342,39 +374,35 @@ private[spark] object HadoopFSUtils extends Logging {
}
// scalastyle:on argcount
- /** Checks if we should filter out this path name. */
- def shouldFilterOutPathName(pathName: String): Boolean = {
- // We filter follow paths:
- // 1. everything that starts with _ and ., except _common_metadata and
_metadata
- // because Parquet needs to find those metadata files from leaf files
returned by this method.
+ /**
+ * Checks if we should filter out this path name, i.e. whether
`ignoredPathSegmentRegex` finds a
+ * match in it. Regardless of the regex, names starting with
`_common_metadata` or `_metadata`
+ * are never filtered, names ending with `._COPYING_` are always filtered,
and '_'-prefixed
+ * names containing '=' (partition directories) are never filtered.
+ */
+ def shouldFilterOutPathName(
+ pathName: String,
+ ignoredPathSegmentRegex: Pattern =
defaultIgnoredPathSegmentRegexPattern): Boolean = {
+ // Parquet summary files must stay visible to listing regardless of the
filter, because
+ // Parquet needs to find those metadata files from leaf files returned by
this method.
// We should refactor this logic to not mix metadata files with data files.
- // 2. everything that ends with `._COPYING_`, because this is a
intermediate state of file. we
- // should skip this file in case of double reading.
- val exclude = (pathName.startsWith("_") && !pathName.contains("=")) ||
- pathName.startsWith(".") || pathName.endsWith("._COPYING_")
- val include = pathName.startsWith("_common_metadata") ||
pathName.startsWith("_metadata")
- exclude && !include
+ if (pathName.startsWith("_common_metadata") ||
pathName.startsWith("_metadata")) return false
+ // In-flight copy files (hadoop fs -put) are always skipped to avoid
double reads.
+ if (pathName.endsWith("._COPYING_")) return true
+ // '_'-prefixed names containing '=' are partition directories, never
hidden.
+ if (pathName.startsWith("_") && pathName.contains("=")) return false
+ ignoredPathSegmentRegex.matcher(pathName).find()
}
- private val underscore: Regex = "/_[^=/]*/".r
- private val underscoreEnd: Regex = "/_[^=/]*$".r
-
- /** Checks if we should filter out this path. */
- @scala.annotation.tailrec
- def shouldFilterOutPath(path: String): Boolean = {
- if (path.contains("/.") || path.endsWith("._COPYING_")) return true
- underscoreEnd.findFirstIn(path) match {
- case Some(dir) if dir.equals("/_metadata") ||
dir.equals("/_common_metadata") => false
- case Some(_) => true
- case None =>
- underscore.findFirstIn(path) match {
- case Some(dir) if dir.equals("/_metadata/") =>
- shouldFilterOutPath(path.replaceFirst("/_metadata", ""))
- case Some(dir) if dir.equals("/_common_metadata/") =>
- shouldFilterOutPath(path.replaceFirst("/_common_metadata", ""))
- case Some(_) => true
- case None => false
- }
- }
+ /**
+ * Checks if we should filter out this path, i.e. whether any of its
individual directory or
+ * file name components below the listed base path is filtered by
`ignoredPathSegmentRegex`
+ * according to [[shouldFilterOutPathName]].
+ */
+ def shouldFilterOutPath(
+ path: String,
+ ignoredPathSegmentRegex: Pattern =
defaultIgnoredPathSegmentRegexPattern): Boolean = {
+ path.split("/").exists(name =>
+ name.nonEmpty && shouldFilterOutPathName(name, ignoredPathSegmentRegex))
}
}
diff --git a/core/src/test/scala/org/apache/spark/util/HadoopFSUtilsSuite.scala
b/core/src/test/scala/org/apache/spark/util/HadoopFSUtilsSuite.scala
index e715d9fb293a..1ee96b90e3cb 100644
--- a/core/src/test/scala/org/apache/spark/util/HadoopFSUtilsSuite.scala
+++ b/core/src/test/scala/org/apache/spark/util/HadoopFSUtilsSuite.scala
@@ -17,9 +17,52 @@
package org.apache.spark.util
-import org.apache.spark.SparkFunSuite
+import java.io.File
+import java.nio.file.Files
+import java.util.regex.Pattern
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.{FileStatus, Path, PathFilter}
+
+import org.apache.spark.{SparkContext, SparkFunSuite}
+import org.apache.spark.LocalSparkContext.withSpark
class HadoopFSUtilsSuite extends SparkFunSuite {
+
+ // Accept everything; hidden-file filtering is exercised via the
ignoredPathSegmentRegex regex.
+ private val acceptAllFilter: PathFilter = AcceptAllPathFilter
+
+ // Never matches any name: disables generic hidden-file filtering, leaving
only the carve-outs.
+ private val neverMatch: Pattern = Pattern.compile("(?!)")
+
+ /**
+ * Builds a tree with one regular file, hidden entries ('_'-, '.'-,
'._COPYING_'-named) and a
+ * hidden subdir with its own file.
+ *
+ * @return a tuple of (root path to pass to the listing APIs, name of the
only non-hidden file
+ * in the tree).
+ */
+ private def createHiddenFileTree(root: File): (Path, String) = {
+ def writeFile(parent: File, name: String): Unit = {
+ val file = new File(parent, name)
+ Files.write(file.toPath, "content".getBytes)
+ }
+ writeFile(root, "data.parquet")
+ writeFile(root, "_hidden")
+ writeFile(root, ".dot")
+ writeFile(root, "x._COPYING_")
+ val hiddenDir = new File(root, "_tmp")
+ assert(hiddenDir.mkdir())
+ writeFile(hiddenDir, "nested.parquet")
+ // Use getCanonicalPath, not toURI: toURI's trailing slash breaks
HadoopFSUtils' prefix
+ // stripping and defeats shouldFilterOutPath's leading-'/' match.
+ (new Path(root.getCanonicalPath), "data.parquet")
+ }
+
+ // The set of leaf-file names surfaced for the root path.
+ private def leafFileNames(listing: Seq[(Path, Seq[FileStatus])]):
Set[String] =
+ listing.flatMap(_._2).map(_.getPath.getName).toSet
+
test("HadoopFSUtils - file filtering") {
assert(!HadoopFSUtils.shouldFilterOutPathName("abcd"))
assert(HadoopFSUtils.shouldFilterOutPathName(".ab"))
@@ -29,6 +72,30 @@ class HadoopFSUtilsSuite extends SparkFunSuite {
assert(HadoopFSUtils.shouldFilterOutPathName("_ab_metadata"))
assert(HadoopFSUtils.shouldFilterOutPathName("_cd_common_metadata"))
assert(HadoopFSUtils.shouldFilterOutPathName("a._COPYING_"))
+ // A never-matching regex surfaces generically hidden names...
+ assert(!HadoopFSUtils.shouldFilterOutPathName(".ab", neverMatch))
+ assert(!HadoopFSUtils.shouldFilterOutPathName("_cd", neverMatch))
+ assert(!HadoopFSUtils.shouldFilterOutPath("/.ab", neverMatch))
+ // ... but the hardcoded carve-outs are not overridable: '._COPYING_'
files stay hidden and
+ // metadata-prefixed files stay visible regardless of the regex.
+ assert(HadoopFSUtils.shouldFilterOutPathName("a._COPYING_", neverMatch))
+ assert(!HadoopFSUtils.shouldFilterOutPathName("_metadata", neverMatch))
+ }
+
+ test("HadoopFSUtils - file filtering with a custom regex") {
+ val backup = Pattern.compile("^backup")
+ // The custom regex hides matching names.
+ assert(HadoopFSUtils.shouldFilterOutPathName("backup_1", backup))
+ // Names not matching the custom regex are listed, even '_'- or
'.'-prefixed ones: the
+ // default '^[._]' regex is replaced, not combined, and no carve-out hides
them.
+ assert(!HadoopFSUtils.shouldFilterOutPathName("_hidden", backup))
+ assert(!HadoopFSUtils.shouldFilterOutPathName(".dot", backup))
+ assert(!HadoopFSUtils.shouldFilterOutPathName("abcd", backup))
+ // The carve-outs still apply around the custom regex: '._COPYING_' names
stay hidden, and
+ // '_'-prefixed names containing '=' (partition dirs) stay visible even
when the regex
+ // finds a match (note find semantics: unanchored "backup" matches inside
"_backup=1").
+ assert(HadoopFSUtils.shouldFilterOutPathName("backup_1._COPYING_", backup))
+ assert(!HadoopFSUtils.shouldFilterOutPathName("_backup=1",
Pattern.compile("backup")))
}
test("SPARK-45452: HadoopFSUtils - path filtering") {
@@ -61,5 +128,68 @@ class HadoopFSUtilsSuite extends SparkFunSuite {
assert(HadoopFSUtils.shouldFilterOutPath("/ab/_cd/part=1"))
assert(HadoopFSUtils.shouldFilterOutPath("/_ab_metadata"))
assert(HadoopFSUtils.shouldFilterOutPath("/_cd_common_metadata"))
+ // Case 4: the per-name-component walk unifies this predicate with the
recursive-descent
+ // listing path. The verdicts below intentionally differ from the old
path-based logic:
+ // a '_metadata' leaf no longer rescues a hidden parent directory...
+ assert(HadoopFSUtils.shouldFilterOutPath("/_foo/_metadata"))
+ // ... the metadata exemption is prefix-based, matching
shouldFilterOutPathName...
+ assert(!HadoopFSUtils.shouldFilterOutPath("/x/_metadata.json"))
+ // ... and a '._COPYING_' directory component hides its subtree.
+ assert(HadoopFSUtils.shouldFilterOutPath("/d._COPYING_/x"))
}
+
+ test("listFiles - default ignoredPathSegmentRegex hides hidden files and
dirs") {
+ withTempDir { root =>
+ val (path, regularFile) = createHiddenFileTree(root)
+ val hadoopConf = new Configuration()
+ val names = leafFileNames(
+ HadoopFSUtils.listFiles(path, hadoopConf, acceptAllFilter))
+ // Only the regular file survives; every hidden entry is filtered out.
+ assert(names === Set(regularFile))
+ }
+ }
+
+ test("listFiles - never-matching ignoredPathSegmentRegex surfaces hidden
files and dirs") {
+ withTempDir { root =>
+ val (path, regularFile) = createHiddenFileTree(root)
+ val hadoopConf = new Configuration()
+ val names = leafFileNames(
+ HadoopFSUtils.listFiles(path, hadoopConf, acceptAllFilter, neverMatch))
+ // 'x._COPYING_' stays hidden even with a never-matching regex: the
carve-out for
+ // in-flight copy files is not overridable.
+ assert(names === Set(regularFile, "_hidden", ".dot", "nested.parquet"))
+ }
+ }
+
+ test("parallelListLeafFiles - ignoredPathSegmentRegex toggles hidden file
visibility") {
+ withTempDir { root =>
+ val (path, regularFile) = createHiddenFileTree(root)
+ val hadoopConf = new Configuration()
+ withSpark(new SparkContext("local", "HadoopFSUtilsSuite")) { sc =>
+ def listNames(ignoredPathSegmentRegex: Pattern): Set[String] =
+ leafFileNames(HadoopFSUtils.parallelListLeafFiles(
+ sc,
+ Seq(path),
+ hadoopConf,
+ acceptAllFilter,
+ ignoreMissingFiles = false,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
+ ignoreLocality = true,
+ // Use 0 so the parallel (Spark job) code path is exercised rather
than the
+ // serial short-circuit.
+ parallelismThreshold = 0,
+ parallelismMax = 1))
+
+ val defaultFilter =
Pattern.compile(HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX)
+ assert(listNames(defaultFilter) === Set(regularFile))
+ // 'x._COPYING_' stays hidden even with a never-matching regex (see
the listFiles test).
+ assert(listNames(neverMatch) ===
+ Set(regularFile, "_hidden", ".dot", "nested.parquet"))
+ }
+ }
+ }
+}
+
+private object AcceptAllPathFilter extends PathFilter with Serializable {
+ override def accept(path: Path): Boolean = true
}
diff --git a/docs/sql-data-sources-generic-options.md
b/docs/sql-data-sources-generic-options.md
index d180f15de27a..f1066648cc71 100644
--- a/docs/sql-data-sources-generic-options.md
+++ b/docs/sql-data-sources-generic-options.md
@@ -97,6 +97,46 @@ you can use:
</div>
</div>
+### Ignored Path Segment Regex
+
+Spark allows you to use the configuration
`spark.sql.files.ignoredPathSegmentRegex` or the data source option
`ignoredPathSegmentRegex` to control which files are treated as
+hidden during file listing. The value is a Java regular expression that is
matched (with find semantics, i.e. `java.util.regex.Matcher.find`) against each
individual
+directory and file name below the path being read; names in which the regex
finds a match are skipped from file listing, partition discovery, and reads,
and a matching
+directory name excludes its whole subtree. The default value is `^[._]`, which
skips files and directories whose names start with `_` or `.`. The data source
option
+takes precedence over the configuration when both are set.
+
+Regardless of the regex, three rules always apply: names starting with
`_metadata` or `_common_metadata` (Parquet summary files) are always listed,
names ending in
+`._COPYING_` (in-flight copies) are always skipped, and `_`-prefixed names
containing `=` (partition directories) are always kept.
+
+An empty string disables the generic hidden-file filtering (an empty regex
matches nothing) and surfaces hidden files, including Spark-internal marker
files such as
+`_SUCCESS` and files under `_temporary` directories (the three rules above
still apply). Note the difference from `pathGlobFilter`: `pathGlobFilter` is an
+include-style glob applied to leaf file names only, while
`ignoredPathSegmentRegex` is an exclude-style regex applied to every directory
and file name component; the two
+can be combined, e.g. using `pathGlobFilter` to narrow the results of a
relaxed `ignoredPathSegmentRegex`.
+
+Note that directories surfaced by a relaxed regex also participate in
partition discovery, so a hidden directory next to partition directories causes
a conflicting
+directory structures error unless
`spark.sql.files.ignoreInvalidPartitionPaths` is enabled.
+
+To surface files that are hidden by default, you can use:
+
+<div class="codetabs">
+
+<div data-lang="python" markdown="1">
+{% include_example ignored_path_segment_regex python/sql/datasource.py %}
+</div>
+
+<div data-lang="scala" markdown="1">
+{% include_example ignored_path_segment_regex
scala/org/apache/spark/examples/sql/SQLDataSourceExample.scala %}
+</div>
+
+<div data-lang="java" markdown="1">
+{% include_example ignored_path_segment_regex
java/org/apache/spark/examples/sql/JavaSQLDataSourceExample.java %}
+</div>
+
+<div data-lang="r" markdown="1">
+{% include_example ignored_path_segment_regex r/RSparkSQLExample.R %}
+</div>
+</div>
+
### Recursive File Lookup
`recursiveFileLookup` is used to recursively load files and it disables
partition inferring. Its default value is `false`.
If data source explicitly specifies the `partitionSpec` when
`recursiveFileLookup` is true, exception will be thrown.
diff --git a/docs/sql-migration-guide.md b/docs/sql-migration-guide.md
index 1ad7c97ef5e0..460a4d4219bf 100644
--- a/docs/sql-migration-guide.md
+++ b/docs/sql-migration-guide.md
@@ -24,6 +24,7 @@ license: |
## Upgrading from Spark SQL 4.2 to 4.3
+- Since Spark 4.3, zero-length files are skipped during Parquet schema
inference instead of failing with a `FAILED_READ_FILE.CANNOT_READ_FILE_FOOTER`
error.
- Since Spark 4.3, the configuration key
`spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled` has
been renamed to
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` to
reflect that it now applies to storage-partitioned joins, aggregates, and
windows. The old key continues to work as an alias.
- Since Spark 4.3, the Spark Thrift Server rejects setting JVM system
properties through the `set:system:` session configuration overlay (for
example, in a JDBC connection string). To restore the previous behavior, set
`spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`.
diff --git a/docs/streaming/apis-on-dataframes-and-datasets.md
b/docs/streaming/apis-on-dataframes-and-datasets.md
index 85815b54d426..86585caead51 100644
--- a/docs/streaming/apis-on-dataframes-and-datasets.md
+++ b/docs/streaming/apis-on-dataframes-and-datasets.md
@@ -75,6 +75,8 @@ Here are the details of all the sources in Spark.
"s3n://a/b/dataset.txt"<br/>
"s3a://a/b/c/dataset.txt"
<br/>
+ <code>ignoredPathSegmentRegex</code>: a Java regular expression
matched against each directory and file name during file listing; matching
names are treated as hidden and skipped (default: "^[._]", which skips names
starting with '_' or '.')
+ <br/>
<code>maxFileAge</code>: Maximum age of a file that can be found in
this directory, before it is ignored. For the first batch all files will be
considered valid. If <code>latestFirst</code> is set to `true` and
<code>maxFilesPerTrigger</code> or <code>maxBytesPerTrigger</code> is set, then
this parameter will be ignored, because old files that are valid, and should be
processed, may be ignored. The max age is specified with respect to the
timestamp of the latest file, and not the [...]
<br/>
<code>maxCachedFiles</code>: maximum number of files to cache to be
processed in subsequent batches (default: 10000). If files are available in
the cache, they will be read from first before listing from the input source.
diff --git
a/examples/src/main/java/org/apache/spark/examples/sql/JavaSQLDataSourceExample.java
b/examples/src/main/java/org/apache/spark/examples/sql/JavaSQLDataSourceExample.java
index e7d52ce03e86..3dcb0ff05001 100644
---
a/examples/src/main/java/org/apache/spark/examples/sql/JavaSQLDataSourceExample.java
+++
b/examples/src/main/java/org/apache/spark/examples/sql/JavaSQLDataSourceExample.java
@@ -156,6 +156,13 @@ public class JavaSQLDataSourceExample {
// |file2.parquet|
// +-------------+
// $example off:recursive_file_lookup$
+ // $example on:ignored_path_segment_regex$
+ // An empty regex surfaces files hidden by default (e.g. names starting
with "_" or ".")
+ Dataset<Row> surfacedDF = spark.read().format("parquet")
+ .option("ignoredPathSegmentRegex", "")
+ .load("examples/src/main/resources/dir1");
+ surfacedDF.show();
+ // $example off:ignored_path_segment_regex$
spark.sql("set spark.sql.files.ignoreCorruptFiles=false");
// $example on:load_with_path_glob_filter$
Dataset<Row> testGlobFilterDF = spark.read().format("parquet")
diff --git a/examples/src/main/python/sql/datasource.py
b/examples/src/main/python/sql/datasource.py
index 8747bd11f8f2..0a76376e2c81 100644
--- a/examples/src/main/python/sql/datasource.py
+++ b/examples/src/main/python/sql/datasource.py
@@ -67,6 +67,14 @@ def generic_file_source_options_example(spark: SparkSession)
-> None:
# |file2.parquet|
# +-------------+
# $example off:recursive_file_lookup$
+
+ # $example on:ignored_path_segment_regex$
+ # An empty regex surfaces files that are hidden by default (e.g. names
starting with "_" or ".")
+ surfaced_df = spark.read.format("parquet")\
+ .option("ignoredPathSegmentRegex", "")\
+ .load("examples/src/main/resources/dir1")
+ surfaced_df.show()
+ # $example off:ignored_path_segment_regex$
spark.sql("set spark.sql.files.ignoreCorruptFiles=false")
# $example on:load_with_path_glob_filter$
diff --git a/examples/src/main/r/RSparkSQLExample.R
b/examples/src/main/r/RSparkSQLExample.R
index a7d3ae766c5a..15e9c8174659 100644
--- a/examples/src/main/r/RSparkSQLExample.R
+++ b/examples/src/main/r/RSparkSQLExample.R
@@ -126,6 +126,12 @@ head(recursiveLoadedDF)
# 1 file1.parquet
# 2 file2.parquet
# $example off:recursive_file_lookup$
+
+# $example on:ignored_path_segment_regex$
+# An empty regex surfaces files that are hidden by default (e.g. names
starting with "_" or ".")
+surfacedDF <- read.df("examples/src/main/resources/dir1", "parquet",
ignoredPathSegmentRegex = "")
+head(surfacedDF)
+# $example off:ignored_path_segment_regex$
sql("set spark.sql.files.ignoreCorruptFiles=false")
# $example on:generic_load_save_functions$
diff --git
a/examples/src/main/scala/org/apache/spark/examples/sql/SQLDataSourceExample.scala
b/examples/src/main/scala/org/apache/spark/examples/sql/SQLDataSourceExample.scala
index 33121586fe10..a6282ab230a0 100644
---
a/examples/src/main/scala/org/apache/spark/examples/sql/SQLDataSourceExample.scala
+++
b/examples/src/main/scala/org/apache/spark/examples/sql/SQLDataSourceExample.scala
@@ -85,6 +85,13 @@ object SQLDataSourceExample {
// |file2.parquet|
// +-------------+
// $example off:recursive_file_lookup$
+ // $example on:ignored_path_segment_regex$
+ // An empty regex surfaces files hidden by default (e.g. names starting
with "_" or ".")
+ val surfacedDF = spark.read.format("parquet")
+ .option("ignoredPathSegmentRegex", "")
+ .load("examples/src/main/resources/dir1")
+ surfacedDF.show()
+ // $example off:ignored_path_segment_regex$
spark.sql("set spark.sql.files.ignoreCorruptFiles=false")
// $example on:load_with_path_glob_filter$
val testGlobFilterDF = spark.read.format("parquet")
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/FileSourceOptions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/FileSourceOptions.scala
index 0747a8045e7d..62db2d90ec8f 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/FileSourceOptions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/FileSourceOptions.scala
@@ -16,7 +16,9 @@
*/
package org.apache.spark.sql.catalyst
-import org.apache.spark.sql.catalyst.FileSourceOptions.{IGNORE_CORRUPT_FILES,
IGNORE_MISSING_FILES}
+import java.util.regex.{Pattern, PatternSyntaxException}
+
+import org.apache.spark.sql.catalyst.FileSourceOptions.{IGNORE_CORRUPT_FILES,
IGNORE_MISSING_FILES, IGNORED_PATH_SEGMENT_REGEX}
import org.apache.spark.sql.catalyst.util.{CaseInsensitiveMap, DateFormatter}
import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
@@ -53,9 +55,46 @@ class FileSourceOptions(
* executors. Only the CSV data source currently honors this.
*/
val archiveFormatEnabled: Boolean =
SQLConf.get.getConf(SQLConf.ARCHIVE_FORMAT_READER_ENABLED)
+
+ val ignoredPathSegmentRegex: String =
+ parameters.getOrElse(IGNORED_PATH_SEGMENT_REGEX,
SQLConf.get.ignoredPathSegmentRegex)
+
+ /**
+ * The effective [[ignoredPathSegmentRegex]] compiled and validated once
here, so the ~5 listing
+ * call sites can reuse a single [[Pattern]] instead of re-compiling. An
empty value disables the
+ * generic hidden-file filter (see
[[FileSourceOptions.compileIgnoredPathSegmentRegex]]).
+ */
+ lazy val ignoredPathSegmentRegexPattern: Pattern =
+ FileSourceOptions.compileIgnoredPathSegmentRegex(ignoredPathSegmentRegex)
}
object FileSourceOptions {
val IGNORE_CORRUPT_FILES = "ignoreCorruptFiles"
val IGNORE_MISSING_FILES = "ignoreMissingFiles"
+ val IGNORED_PATH_SEGMENT_REGEX = "ignoredPathSegmentRegex"
+
+ // A regex that never matches any name, used when the filter is disabled by
an empty value.
+ private val DISABLED_FILTER_PATTERN = Pattern.compile("(?!)")
+
+ /**
+ * Compiles the effective `ignoredPathSegmentRegex`. An empty string
disables the generic
+ * hidden-file filter (logically an "empty" regex matches nothing, so the
returned pattern never
+ * matches). A non-empty value must be a valid Java regular expression; an
invalid one is reported
+ * as a clear [[IllegalArgumentException]] rather than a raw
[[PatternSyntaxException]] surfacing
+ * deep in file listing.
+ */
+ def compileIgnoredPathSegmentRegex(regex: String): Pattern = {
+ if (regex.isEmpty) {
+ DISABLED_FILTER_PATTERN
+ } else {
+ try {
+ Pattern.compile(regex)
+ } catch {
+ case e: PatternSyntaxException =>
+ throw new IllegalArgumentException(
+ s"The '$IGNORED_PATH_SEGMENT_REGEX' value '$regex' is not a valid
Java regular " +
+ "expression. Use an empty string to disable hidden-file
filtering.", e)
+ }
+ }
+ }
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 05043b3107bf..bda3f1cf1772 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -21,6 +21,7 @@ import java.util.{Locale, Properties, TimeZone}
import java.util
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
+import java.util.regex.Pattern
import scala.collection.immutable
import scala.jdk.CollectionConverters._
@@ -52,7 +53,7 @@ import org.apache.spark.sql.errors.{QueryCompilationErrors,
QueryExecutionErrors
import org.apache.spark.sql.types.{AtomicType, TimestampNTZType, TimestampType}
import org.apache.spark.storage.{StorageLevel, StorageLevelMapper}
import org.apache.spark.unsafe.array.ByteArrayMethods
-import org.apache.spark.util.{Utils, VersionUtils}
+import org.apache.spark.util.{HadoopFSUtils, Utils, VersionUtils}
////////////////////////////////////////////////////////////////////////////////////////////////////
// This file defines the configuration options for Spark SQL.
@@ -2785,6 +2786,27 @@ object SQLConf {
.booleanConf
.createWithDefault(false)
+ val IGNORED_PATH_SEGMENT_REGEX =
buildConf("spark.sql.files.ignoredPathSegmentRegex")
+ .doc("Java regular expression matched (with find semantics) against each
directory and " +
+ "file name during file listing; matching names are skipped from listing,
partition " +
+ "discovery, and reads. The default '^[._]' skips names starting with '_'
or '.' (hidden " +
+ "files). Regardless of the regex, names starting with '_metadata' or
'_common_metadata' " +
+ "are always listed, names ending in '._COPYING_' are always skipped, and
'_'-prefixed " +
+ "names containing '=' (partition directories) are always kept. This
configuration is " +
+ "effective only when using file-based sources such as Parquet, JSON and
ORC. It can be " +
+ "overridden per read by the 'ignoredPathSegmentRegex' data source
option. An empty string " +
+ "disables generic hidden-file filtering (an empty regex matches
nothing); note that " +
+ "directories it surfaces also participate in partition discovery, so a
hidden directory " +
+ "next to partition directories causes a conflicting directory structures
error unless " +
+ "'spark.sql.files.ignoreInvalidPartitionPaths' is enabled.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .stringConf
+ .checkValue(v => v.isEmpty || Try(Pattern.compile(v)).isSuccess,
+ "The value of spark.sql.files.ignoredPathSegmentRegex must be empty (to
disable " +
+ "hidden-file filtering) or a valid Java regular expression.")
+ .createWithDefault(HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX)
+
val IGNORE_INVALID_PARTITION_PATHS =
buildConf("spark.sql.files.ignoreInvalidPartitionPaths")
.doc("Whether to ignore invalid partition paths that do not match
<column>=<value>. When " +
"the option is enabled, table with two partition directories
'table/invalid' and " +
@@ -7883,6 +7905,8 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def ignoreMissingFiles: Boolean = getConf(IGNORE_MISSING_FILES)
+ def ignoredPathSegmentRegex: String = getConf(IGNORED_PATH_SEGMENT_REGEX)
+
def ignoreInvalidPartitionPaths: Boolean =
getConf(IGNORE_INVALID_PARTITION_PATHS)
def maxRecordsPerFile: Long = getConf(MAX_RECORDS_PER_FILE)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/command/CommandUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/command/CommandUtils.scala
index b9ff24139f07..3eb2b14539ff 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/command/CommandUtils.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/command/CommandUtils.scala
@@ -26,7 +26,7 @@ import org.apache.hadoop.fs.{FileStatus, FileSystem, Path,
PathFilter}
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys.{COUNT, DATABASE_NAME, ERROR,
TABLE_NAME, TIME}
-import org.apache.spark.sql.catalyst.{InternalRow, TableIdentifier}
+import org.apache.spark.sql.catalyst.{FileSourceOptions, InternalRow,
TableIdentifier}
import org.apache.spark.sql.catalyst.analysis.EliminateSubqueryAliases
import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier
import org.apache.spark.sql.catalyst.catalog.{CatalogStatistics, CatalogTable,
CatalogTablePartition, ExternalCatalogUtils}
@@ -44,6 +44,7 @@ import
org.apache.spark.sql.execution.datasources.v2.ExtractV2CatalogAndIdentifi
import org.apache.spark.sql.functions.{col, lit}
import org.apache.spark.sql.internal.{SessionState, SQLConf}
import org.apache.spark.sql.types._
+import org.apache.spark.util.HadoopFSUtils
import org.apache.spark.util.collection.Utils
/**
@@ -197,8 +198,14 @@ object CommandUtils extends Logging {
val stagingDir = sparkSession.sessionState.conf
.getConfString("hive.exec.stagingdir", ".hive-staging")
val filter = new PathFilterIgnoreNonData(stagingDir)
+ // Pin the default ignoredPathSegmentRegex: stats must always skip hidden
dirs (immune to the
+ // session conf), matching calculateSingleLocationSize.
val sizes = InMemoryFileIndex.bulkListLeafFiles(paths.flatten,
- sparkSession.sessionState.newHadoopConf(), filter, sparkSession).map {
+ sparkSession.sessionState.newHadoopConf(), filter, sparkSession,
+ parameters = Map(
+ FileSourceOptions.IGNORED_PATH_SEGMENT_REGEX ->
+ HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX)
+ ).map {
case (_, files) => files.map(_.getLen).sum
}
// the size is 0 where paths(i) is not defined and sizes(i) where it is
defined
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
index c0b34fd9ef9e..84daeb92fe8e 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/ArchiveReader.scala
@@ -19,6 +19,7 @@ package org.apache.spark.sql.execution.datasources
import java.io.{Closeable, InputStream}
import java.util.Locale
+import java.util.regex.Pattern
import java.util.zip.GZIPInputStream
import scala.util.control.NonFatal
@@ -59,9 +60,13 @@ abstract class ArchiveReader(path: Path) {
* only once the current entry's iterator is exhausted, so nothing is
buffered to disk and at most
* one entry's bytes are read at a time. The archive stream is closed when
the returned iterator
* is exhausted, when [[Closeable.close]] is called on it, and (defensively)
on task completion.
+ * Entry skipping mirrors Spark's file listing: `ignoredPathSegmentRegex` is
the effective filter
+ * (the `ignoredPathSegmentRegex` data source option), so callers reading
with a custom filter
+ * must pass its compiled form here for archive entries to be filtered like
loose files.
*/
def readEntries[T](
- conf: Configuration)(
+ conf: Configuration,
+ ignoredPathSegmentRegex: Pattern =
HadoopFSUtils.defaultIgnoredPathSegmentRegexPattern)(
parseEntry: (String, InputStream) => Iterator[T]): Iterator[T]
}
@@ -147,16 +152,19 @@ class TarArchiveReader(path: Path) extends
ArchiveReader(path) {
/**
* Whether an entry is not a real data file and must be skipped: a
directory, or a name Spark's
* own file listing would filter out. Applying
[[HadoopFSUtils.shouldFilterOutPathName]] (the
- * `InMemoryFileIndex` filter) to every path component keeps archive reads
in parity with reading
- * the same entries as loose files: `.`-prefixed sidecars (macOS `._x`,
`.DS_Store`), `_`-prefixed
- * markers (`_SUCCESS`, `_committed_*`), and anything under a
`.`/`_`-prefixed directory (e.g. a
- * leftover `_temporary/` from a failed write) are skipped, while data files
are kept. The
- * per-component check matters because `InMemoryFileIndex` never recurses
into such directories,
- * so a basename-only filter would read `_temporary/part-0.csv` that a
loose-file scan drops.
+ * `InMemoryFileIndex` filter) with the same effective
`ignoredPathSegmentRegex` to every path
+ * component keeps archive reads in parity with reading the same entries as
loose files,
+ * including when the user supplies a custom `ignoredPathSegmentRegex`
option: under the default
+ * filter, `.`-prefixed sidecars (macOS `._x`, `.DS_Store`), `_`-prefixed
markers (`_SUCCESS`,
+ * `_committed_*`), and anything under a `.`/`_`-prefixed directory (e.g. a
leftover
+ * `_temporary/` from a failed write) are skipped, while data files are
kept. The per-component
+ * check matters because `InMemoryFileIndex` never recurses into such
directories, so a
+ * basename-only filter would read `_temporary/part-0.csv` that a loose-file
scan drops.
*/
- private def shouldSkipEntry(entry: TarArchiveEntry): Boolean = {
+ private def shouldSkipEntry(entry: TarArchiveEntry, ignoredPathSegmentRegex:
Pattern): Boolean = {
if (entry.isDirectory) return true
- entry.getName.split("/").exists(c => c.nonEmpty &&
HadoopFSUtils.shouldFilterOutPathName(c))
+ entry.getName.split("/").exists(c =>
+ c.nonEmpty && HadoopFSUtils.shouldFilterOutPathName(c,
ignoredPathSegmentRegex))
}
/** Opens the archive as a tar stream, transparently decompressing `.tar.gz`
/ `.tgz`. */
@@ -176,7 +184,8 @@ class TarArchiveReader(path: Path) extends
ArchiveReader(path) {
CloseShieldInputStream.wrap(tar)
override def readEntries[T](
- conf: Configuration)(
+ conf: Configuration,
+ ignoredPathSegmentRegex: Pattern)(
parseEntry: (String, InputStream) => Iterator[T]): Iterator[T] = {
val tar = openTarStream(conf)
var closed = false
@@ -207,7 +216,9 @@ class TarArchiveReader(path: Path) extends
ArchiveReader(path) {
case _ =>
}
var entry = tar.getNextEntry
- while (entry != null && shouldSkipEntry(entry)) entry =
tar.getNextEntry
+ while (entry != null && shouldSkipEntry(entry,
ignoredPathSegmentRegex)) {
+ entry = tar.getNextEntry
+ }
if (entry == null) {
done = true
cleanup()
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
index 4a95f681fb6e..e249e8cfa4cf 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
@@ -18,6 +18,7 @@
package org.apache.spark.sql.execution.datasources
import java.util.{Locale, ServiceConfigurationError, ServiceLoader}
+import java.util.regex.Pattern
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}
@@ -30,7 +31,7 @@ import org.apache.spark.deploy.SparkHadoopUtil
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys.{CLASS_NAME, DATA_SOURCE,
DATA_SOURCES, PATHS}
import org.apache.spark.sql.{AnalysisException, SaveMode, SparkSession}
-import org.apache.spark.sql.catalyst.DataSourceOptions
+import org.apache.spark.sql.catalyst.{DataSourceOptions, FileSourceOptions}
import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute
import org.apache.spark.sql.catalyst.catalog.{BucketSpec,
CatalogStorageFormat, CatalogTable, CatalogUtils}
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
@@ -589,8 +590,11 @@ case class DataSource(
checkEmptyGlobPath: Boolean,
checkFilesExist: Boolean): Seq[Path] = {
val allPaths = caseInsensitiveOptions.get("path") ++ paths
+ val ignoredPathSegmentRegex =
+ new
FileSourceOptions(caseInsensitiveOptions).ignoredPathSegmentRegexPattern
DataSource.checkAndGlobPathIfNecessary(allPaths.toSeq,
newHadoopConfiguration(),
- checkEmptyGlobPath, checkFilesExist, enableGlobbing = globPaths)
+ checkEmptyGlobPath, checkFilesExist, enableGlobbing = globPaths,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex)
}
private def disallowWritingIntervals(
@@ -797,7 +801,9 @@ object DataSource extends Logging {
checkEmptyGlobPath: Boolean,
checkFilesExist: Boolean,
numThreads: Integer = 40,
- enableGlobbing: Boolean): Seq[Path] = {
+ enableGlobbing: Boolean,
+ ignoredPathSegmentRegex: Pattern =
HadoopFSUtils.defaultIgnoredPathSegmentRegexPattern)
+ : Seq[Path] = {
val qualifiedPaths = pathStrings.map { pathString =>
val path = new Path(pathString)
val fs = path.getFileSystem(hadoopConf)
@@ -844,7 +850,7 @@ object DataSource extends Logging {
val allPaths = globbedPaths ++ nonGlobPaths
if (checkFilesExist) {
val (filteredOut, filteredIn) = allPaths.partition { path =>
- HadoopFSUtils.shouldFilterOutPathName(path.getName)
+ HadoopFSUtils.shouldFilterOutPathName(path.getName,
ignoredPathSegmentRegex)
}
if (filteredIn.isEmpty) {
logWarning(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileIndexOptions.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileIndexOptions.scala
index 5a300dae4daa..0a7a775b6469 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileIndexOptions.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileIndexOptions.scala
@@ -22,6 +22,7 @@ import org.apache.spark.sql.catalyst.util.DateTimeUtils
object FileIndexOptions extends DataSourceOptions {
val IGNORE_MISSING_FILES = newOption(FileSourceOptions.IGNORE_MISSING_FILES)
+ val IGNORED_PATH_SEGMENT_REGEX =
newOption(FileSourceOptions.IGNORED_PATH_SEGMENT_REGEX)
val IGNORE_INVALID_PARTITION_PATHS = newOption("ignoreInvalidPartitionPaths")
val TIME_ZONE = newOption(DateTimeUtils.TIMEZONE_OPTION)
val RECURSIVE_FILE_LOOKUP = newOption("recursiveFileLookup")
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/InMemoryFileIndex.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/InMemoryFileIndex.scala
index 2d68faa3ff52..05273c2a8f95 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/InMemoryFileIndex.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/InMemoryFileIndex.scala
@@ -121,8 +121,14 @@ class InMemoryFileIndex(
val startTime = System.nanoTime()
val output = mutable.LinkedHashSet[FileStatus]()
val pathsToFetch = mutable.ArrayBuffer[Path]()
+ // The file status cache is keyed by path only, so cached entries must
always hold the
+ // default-filtered listing. Bypass the cache when a non-default
ignoredPathSegmentRegex is in
+ // effect, to neither serve nor store listings produced with a different
filter.
+ val useFileStatusCache =
+ ignoredPathSegmentRegex ==
HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX
for (path <- paths) {
- fileStatusCache.getLeafFiles(path) match {
+ val cachedFiles = if (useFileStatusCache)
fileStatusCache.getLeafFiles(path) else None
+ cachedFiles match {
case Some(files) =>
HiveCatalogMetrics.incrementFileCacheHits(files.length)
output ++= files
@@ -135,7 +141,9 @@ class InMemoryFileIndex(
pathsToFetch.toSeq, hadoopConf, filter, sparkSession, parameters)
discovered.foreach { case (path, leafFiles) =>
HiveCatalogMetrics.incrementFilesDiscovered(leafFiles.size)
- fileStatusCache.putLeafFiles(path, leafFiles.toArray)
+ if (useFileStatusCache) {
+ fileStatusCache.putLeafFiles(path, leafFiles.toArray)
+ }
output ++= leafFiles
}
logInfo(log"It took ${MDC(ELAPSED_TIME, (System.nanoTime() - startTime) /
(1000 * 1000))} ms" +
@@ -154,8 +162,9 @@ object InMemoryFileIndex extends Logging {
parameters: Map[String, String] = Map.empty): Seq[(Path,
Seq[FileStatus])] = {
val fileSystemList =
sparkSession.sessionState.conf.useListFilesFileSystemList.split(",").map(_.trim)
- val ignoreMissingFiles =
- new FileSourceOptions(CaseInsensitiveMap(parameters)).ignoreMissingFiles
+ val fileSourceOptions = new
FileSourceOptions(CaseInsensitiveMap(parameters))
+ val ignoreMissingFiles = fileSourceOptions.ignoreMissingFiles
+ val ignoredPathSegmentRegex =
fileSourceOptions.ignoredPathSegmentRegexPattern
val useListFiles = try {
val scheme = paths.head.getFileSystem(hadoopConf).getScheme
paths.size == 1 && fileSystemList.contains(scheme)
@@ -166,7 +175,8 @@ object InMemoryFileIndex extends Logging {
HadoopFSUtils.listFiles(
path = paths.head,
hadoopConf = hadoopConf,
- filter = new PathFilterWrapper(filter))
+ filter = new PathFilterWrapper(filter),
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex)
} else {
HadoopFSUtils.parallelListLeafFiles(
sc = sparkSession.sparkContext,
@@ -174,6 +184,7 @@ object InMemoryFileIndex extends Logging {
hadoopConf = hadoopConf,
filter = new PathFilterWrapper(filter),
ignoreMissingFiles = ignoreMissingFiles,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex,
ignoreLocality = sparkSession.sessionState.conf.ignoreDataLocality,
parallelismThreshold =
sparkSession.sessionState.conf.parallelPartitionDiscoveryThreshold,
parallelismMax =
sparkSession.sessionState.conf.parallelPartitionDiscoveryParallelism)
@@ -182,8 +193,10 @@ object InMemoryFileIndex extends Logging {
}
+// Delegates to the input-path filter only (with a null guard). The
hidden-file filtering is owned
+// by HadoopFSUtils.listFiles / parallelListLeafFiles via their
`ignoredPathSegmentRegex` argument,
+// which already evaluates the regex against every directory and file name, so
re-applying it here
+// would be redundant.
private class PathFilterWrapper(val filter: PathFilter) extends PathFilter
with Serializable {
- override def accept(path: Path): Boolean = {
- (filter == null || filter.accept(path)) &&
!HadoopFSUtils.shouldFilterOutPathName(path.getName)
- }
+ override def accept(path: Path): Boolean = filter == null ||
filter.accept(path)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/PartitioningAwareFileIndex.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/PartitioningAwareFileIndex.scala
index 8cea2c95e694..7e6b733e6aaf 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/PartitioningAwareFileIndex.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/PartitioningAwareFileIndex.scala
@@ -18,6 +18,7 @@
package org.apache.spark.sql.execution.datasources
import java.io.FileNotFoundException
+import java.util.regex.Pattern
import scala.collection.mutable
@@ -28,7 +29,7 @@ import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys.{COUNT, PERCENT, TOTAL}
import org.apache.spark.paths.SparkPath
import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.catalyst.{expressions, InternalRow}
+import org.apache.spark.sql.catalyst.{expressions, FileSourceOptions,
InternalRow}
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.util.CaseInsensitiveMap
import org.apache.spark.sql.errors.QueryCompilationErrors
@@ -78,6 +79,15 @@ abstract class PartitioningAwareFileIndex(
.getOrElse(sparkSession.sessionState.conf.ignoreInvalidPartitionPaths)
}
+ protected lazy val ignoredPathSegmentRegex: String = {
+ caseInsensitiveMap
+ .get(FileIndexOptions.IGNORED_PATH_SEGMENT_REGEX)
+ .getOrElse(sparkSession.sessionState.conf.ignoredPathSegmentRegex)
+ }
+
+ private lazy val ignoredPathSegmentRegexPattern: Pattern =
+ FileSourceOptions.compileIgnoredPathSegmentRegex(ignoredPathSegmentRegex)
+
override def listFiles(
partitionFilters: Seq[Expression], dataFilters: Seq[Expression]):
Seq[PartitionDirectory] = {
def isNonEmptyFile(f: FileStatus): Boolean = {
@@ -265,6 +275,12 @@ abstract class PartitioningAwareFileIndex(
// counted as data files, so that they shouldn't participate partition
discovery.
private def isDataPath(path: Path): Boolean = {
val name = path.getName
- !((name.startsWith("_") && !name.contains("=")) || name.startsWith("."))
+ // Partition directories ('_'-prefixed names containing '=') are always
data paths.
+ if (name.startsWith("_") && name.contains("=")) return true
+ // Metadata summary files are never data paths, regardless of the
ignoredPathSegmentRegex regex
+ // (they always survive listing via the shouldFilterOutPathName carve-out).
+ if (name.startsWith("_common_metadata") || name.startsWith("_metadata"))
return false
+ // For all other names the ignoredPathSegmentRegex regex decides.
+ !ignoredPathSegmentRegexPattern.matcher(name).find()
}
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
index 7424d43341b2..10771114bc70 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVDataSource.scala
@@ -19,6 +19,7 @@ package org.apache.spark.sql.execution.datasources.csv
import java.io.{FileNotFoundException, InputStream, IOException}
import java.nio.charset.{Charset, StandardCharsets}
+import java.util.regex.Pattern
import scala.util.control.NonFatal
@@ -119,13 +120,16 @@ abstract class CSVDataSource extends Serializable with
Logging {
*
* @param getParser builds a fresh [[UnivocityParser]].
* @param getHeaderChecker builds a fresh [[CSVHeaderChecker]] for
`(isStartOfFile, source)`.
+ * @param ignoredPathSegmentRegex the compiled effective
`ignoredPathSegmentRegex` option, so
+ * hidden entries are skipped exactly like Spark's
file listing would.
*/
def readArchive(
conf: Configuration,
file: PartitionedFile,
getParser: () => UnivocityParser,
getHeaderChecker: (Boolean, String) => CSVHeaderChecker,
- requiredSchema: StructType): Iterator[InternalRow]
+ requiredSchema: StructType,
+ ignoredPathSegmentRegex: Pattern): Iterator[InternalRow]
/**
* Shared driver used by the [[readArchive]] implementations: streams each
non-skipped entry's
@@ -137,10 +141,11 @@ abstract class CSVDataSource extends Serializable with
Logging {
conf: Configuration,
file: PartitionedFile,
getParser: () => UnivocityParser,
- getHeaderChecker: (Boolean, String) => CSVHeaderChecker)(
+ getHeaderChecker: (Boolean, String) => CSVHeaderChecker,
+ ignoredPathSegmentRegex: Pattern)(
parseEntry: (UnivocityParser, CSVHeaderChecker, InputStream) =>
Iterator[InternalRow])
: Iterator[InternalRow] = {
- ArchiveReader(file.toPath).readEntries(conf) { (entryName, in) =>
+ ArchiveReader(file.toPath).readEntries(conf, ignoredPathSegmentRegex) {
(entryName, in) =>
val headerChecker =
getHeaderChecker(true, s"CSV archive entry:
${file.urlEncodedPath}!/$entryName")
val parser = getParser()
@@ -296,12 +301,14 @@ object TextInputCSVDataSource extends CSVDataSource {
file: PartitionedFile,
getParser: () => UnivocityParser,
getHeaderChecker: (Boolean, String) => CSVHeaderChecker,
- requiredSchema: StructType): Iterator[InternalRow] =
+ requiredSchema: StructType,
+ ignoredPathSegmentRegex: Pattern): Iterator[InternalRow] =
// Stream each tar entry through the line-based parser, treating the entry
exactly like a
// standalone CSV file (a fresh parser/header checker is built per entry).
- streamArchiveEntries(conf, file, getParser, getHeaderChecker) { (parser,
headerChecker, in) =>
- UnivocityParser.parseIterator(
- entryLines(in, parser.options), parser, headerChecker, requiredSchema)
+ streamArchiveEntries(conf, file, getParser, getHeaderChecker,
ignoredPathSegmentRegex) {
+ (parser, headerChecker, in) =>
+ UnivocityParser.parseIterator(
+ entryLines(in, parser.options), parser, headerChecker,
requiredSchema)
}
/**
@@ -425,11 +432,13 @@ object MultiLineCSVDataSource extends CSVDataSource {
file: PartitionedFile,
getParser: () => UnivocityParser,
getHeaderChecker: (Boolean, String) => CSVHeaderChecker,
- requiredSchema: StructType): Iterator[InternalRow] =
+ requiredSchema: StructType,
+ ignoredPathSegmentRegex: Pattern): Iterator[InternalRow] =
// Stream each tar entry whole through the multi-line parser (a fresh
parser/header checker is
// built per entry).
- streamArchiveEntries(conf, file, getParser, getHeaderChecker) { (parser,
headerChecker, in) =>
- UnivocityParser.parseStream(in, parser, headerChecker, requiredSchema)
+ streamArchiveEntries(conf, file, getParser, getHeaderChecker,
ignoredPathSegmentRegex) {
+ (parser, headerChecker, in) =>
+ UnivocityParser.parseStream(in, parser, headerChecker, requiredSchema)
}
override protected def tokenizeForInference(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
index f4515c7530a6..5cc71d3d5cfb 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
@@ -107,6 +107,9 @@ case class CSVFileFormat() extends TextBasedFileFormat with
DataSourceRegister {
SerializableConfiguration.broadcast(sparkSession.sparkContext,
hadoopConf)
val parsedOptions = getCsvOptions(sparkSession, options)
val isColumnPruningEnabled =
parsedOptions.isColumnPruningEnabled(requiredSchema)
+ // The effective `ignoredPathSegmentRegex` for skipping hidden archive
entries. Reused from the
+ // options' lazily-compiled Pattern (Serializable) instead of re-compiling
per file or entry.
+ val ignoredPathSegmentRegex = parsedOptions.ignoredPathSegmentRegexPattern
// Check a field requirement for corrupt records here to throw an
exception in a driver side
ExprUtils.verifyColumnNameOfCorruptRecord(dataSchema,
parsedOptions.columnNameOfCorruptRecord)
@@ -141,7 +144,7 @@ case class CSVFileFormat() extends TextBasedFileFormat with
DataSourceRegister {
// archive reads are enabled; otherwise the file is parsed directly.
if (parsedOptions.archiveFormatEnabled &&
ArchiveReader.isArchivePath(file.toPath)) {
CSVDataSource(parsedOptions).readArchive(
- conf, file, () => newParser(), getHeaderChecker, requiredSchema)
+ conf, file, () => newParser(), getHeaderChecker, requiredSchema,
ignoredPathSegmentRegex)
} else {
val parser = newParser()
val headerChecker = getHeaderChecker(file.start == 0, s"CSV file:
${file.urlEncodedPath}")
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
index bafc3a9ea52f..f87e155d41ab 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
@@ -144,7 +144,10 @@ object ParquetUtils extends Logging {
val leaves = allFiles.toArray.sortBy(_.getPath.toString)
FileTypes(
- data = leaves.filterNot(f =>
isSummaryFile(f.getPath)).toImmutableArraySeq,
+ // Zero-length files (e.g. `_SUCCESS` markers surfaced by a relaxed
`ignoredPathSegmentRegex`)
+ // cannot contain a Parquet footer, so exclude them from schema
inference candidates.
+ data = leaves.filter(_.getLen > 0).filterNot(f =>
isSummaryFile(f.getPath))
+ .toImmutableArraySeq,
metadata =
leaves.filter(_.getPath.getName ==
ParquetFileWriter.PARQUET_METADATA_FILE)
.toImmutableArraySeq,
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
index 0af728c1958d..8941a4c8d8c7 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
@@ -23,6 +23,7 @@ import scala.jdk.CollectionConverters._
import org.apache.hadoop.fs.{FileStatus, Path}
import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.FileSourceOptions
import org.apache.spark.sql.connector.catalog.{SupportsRead, SupportsWrite,
Table, TableCapability}
import org.apache.spark.sql.connector.catalog.TableCapability._
import org.apache.spark.sql.connector.expressions.Transform
@@ -56,8 +57,11 @@ abstract class FileTable(
options.asScala.toMap, userSpecifiedSchema)
} else {
// This is a non-streaming file based datasource.
+ val ignoredPathSegmentRegex =
+ new FileSourceOptions(caseSensitiveMap).ignoredPathSegmentRegexPattern
val rootPathsSpecified = DataSource.checkAndGlobPathIfNecessary(paths,
hadoopConf,
- checkEmptyGlobPath = true, checkFilesExist = true, enableGlobbing =
globPaths)
+ checkEmptyGlobPath = true, checkFilesExist = true, enableGlobbing =
globPaths,
+ ignoredPathSegmentRegex = ignoredPathSegmentRegex)
val fileStatusCache = FileStatusCache.getOrCreate(sparkSession)
new InMemoryFileIndex(
sparkSession, rootPathsSpecified, caseSensitiveMap,
userSpecifiedSchema, fileStatusCache)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
index 22f3ae6126ee..34cc2088c955 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -47,6 +47,7 @@ import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.sql.types._
import org.apache.spark.unsafe.types.TimestampNanosVal
+import org.apache.spark.util.HadoopFSUtils
class FileBasedDataSourceSuite extends SharedSparkSession
with AdaptiveSparkPlanHelper {
@@ -1429,6 +1430,169 @@ class FileBasedDataSourceSuite extends
SharedSparkSession
}
}
}
+
+ // Asserts the ignoredPathSegmentRegex contract for `format`: the default
regex hides the
+ // '_'-prefixed file; a never-matching per-read option or session conf each
surface it; the
+ // option overrides the conf.
+ private def checkIgnoredPathSegmentRegexContract(format: String, basePath:
String): Unit = {
+ val defaultRegex = HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX
+
+ // Default: the hidden file is filtered out.
+ checkAnswer(spark.read.format(format).load(basePath), Seq(Row("visible")))
+
+ // A never-matching per-read option surfaces the hidden data.
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex",
"(?!)").format(format).load(basePath),
+ Seq(Row("visible"), Row("hidden")))
+
+ // A never-matching session conf surfaces the hidden data too.
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> "(?!)") {
+ checkAnswer(
+ spark.read.format(format).load(basePath),
+ Seq(Row("visible"), Row("hidden")))
+ }
+
+ // Precedence: the per-read option overrides the session conf in both
directions.
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> defaultRegex) {
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex",
"(?!)").format(format).load(basePath),
+ Seq(Row("visible"), Row("hidden")))
+ }
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> "(?!)") {
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex",
defaultRegex).format(format).load(basePath),
+ Seq(Row("visible")))
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: surfaces hidden files only when
relaxed - json") {
+ withTempDir { dir =>
+ val basePath = dir.getCanonicalPath
+ // Write JSON files directly: a visible record and a '_'-prefixed hidden
record.
+ Files.write(new File(dir, "visible.json").toPath,
"""{"a":"visible"}""".getBytes)
+ Files.write(new File(dir, "_hidden.json").toPath,
"""{"a":"hidden"}""".getBytes)
+
+ checkIgnoredPathSegmentRegexContract("json", basePath)
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: surfaces hidden files only when
relaxed - parquet") {
+ withTempDir { dir =>
+ // Copy only the part-*.parquet from a scratch write into basePath --
this drops Spark's own
+ // _SUCCESS / _temporary marker files, so a relaxed
ignoredPathSegmentRegex surfaces only the
+ // intended hidden file. Flat layout (files directly under basePath)
mirrors the JSON test.
+ val basePath = dir.getCanonicalPath
+
+ def copyPartFile(value: String, destName: String): Unit = {
+ withTempDir { scratch =>
+ // withTempDir pre-creates the directory, so overwrite to avoid
PATH_ALREADY_EXISTS.
+ Seq(value).toDF("a").write.format("parquet").mode("overwrite")
+ .save(scratch.getCanonicalPath)
+ val partFile = scratch.listFiles()
+ .find(f => f.isFile && f.getName.startsWith("part-") &&
f.getName.endsWith(".parquet"))
+ .getOrElse(fail(s"no part file produced under
${scratch.getCanonicalPath}"))
+ Files.copy(partFile.toPath, new File(dir, destName).toPath)
+ }
+ }
+ copyPartFile("visible", "visible.parquet")
+ copyPartFile("hidden", "_hidden.parquet")
+
+ checkIgnoredPathSegmentRegexContract("parquet", basePath)
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: reads an unmodified Spark-written
parquet dir") {
+ withTempDir { dir =>
+ // A normal Spark write leaves a zero-length _SUCCESS marker next to the
part files. With
+ // a never-matching ignoredPathSegmentRegex, the marker is surfaced by
listing but must
+ // neither be picked as a schema inference footer candidate nor scanned
as data.
+ val basePath = dir.getCanonicalPath
+ // withTempDir pre-creates the directory, so overwrite to avoid
PATH_ALREADY_EXISTS.
+ Seq("a", "b").toDF("col").write.mode("overwrite").parquet(basePath)
+ assert(new File(dir, "_SUCCESS").exists())
+
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex", "(?!)").parquet(basePath),
+ Seq(Row("a"), Row("b")))
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: reads a partitioned Spark-written
parquet dir") {
+ withTempDir { dir =>
+ // partitionBy leaves the zero-length _SUCCESS marker at the table root
next to the 'p='
+ // partition dirs. Under a never-matching ignoredPathSegmentRegex the
marker survives listing,
+ // but partition discovery still works (parsePartition stops at the base
path) and the
+ // zero-length marker is neither a schema inference footer candidate nor
scanned as data.
+ val basePath = dir.getCanonicalPath
+ // withTempDir pre-creates the directory, so overwrite to avoid
PATH_ALREADY_EXISTS.
+ Seq((1, "a"), (2, "b")).toDF("p",
"v").write.mode("overwrite").partitionBy("p")
+ .parquet(basePath)
+ assert(new File(dir, "_SUCCESS").exists())
+
+ // The read-back schema appends the discovered partition column after
the data column.
+ val expected = Seq(Row("a", 1), Row("b", 2))
+ checkAnswer(spark.read.parquet(basePath), expected)
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex", "(?!)").parquet(basePath),
+ expected)
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: custom regex replaces the default
filter - json") {
+ withTempDir { dir =>
+ val basePath = dir.getCanonicalPath
+ // The default '^[._]' hides both the '_'- and '.'-prefixed files. A
custom '^_' regex
+ // replaces it entirely: it keeps hiding the '_'-prefixed file but
surfaces the
+ // '.'-prefixed one.
+ Files.write(new File(dir, "visible.json").toPath,
"""{"a":"visible"}""".getBytes)
+ Files.write(new File(dir, "_under.json").toPath,
"""{"a":"under"}""".getBytes)
+ Files.write(new File(dir, ".dot.json").toPath,
"""{"a":"dot"}""".getBytes)
+
+ checkAnswer(spark.read.format("json").load(basePath),
Seq(Row("visible")))
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex",
"^_").format("json").load(basePath),
+ Seq(Row("visible"), Row("dot")))
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: invalid regexes are rejected") {
+ withTempDir { dir =>
+ val basePath = dir.getCanonicalPath
+ Files.write(new File(dir, "visible.json").toPath,
"""{"a":"visible"}""".getBytes)
+
+ // An invalid per-read regex is surfaced as a clear
IllegalArgumentException when the read
+ // resolves the relation, instead of a raw PatternSyntaxException deep
in file listing.
+ val optionError = intercept[IllegalArgumentException] {
+ spark.read.option("ignoredPathSegmentRegex",
"[").format("json").load(basePath)
+ }
+ assert(optionError.getMessage.contains("ignoredPathSegmentRegex"))
+
+ // The session conf rejects invalid values eagerly via checkValue.
+ val confError = intercept[IllegalArgumentException] {
+ spark.conf.set(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key, "[")
+ }
+ assert(confError.getMessage.contains("valid Java regular expression"))
+ }
+ }
+
+ test("Option ignoredPathSegmentRegex: an empty value disables hidden-file
filtering") {
+ withTempDir { dir =>
+ val basePath = dir.getCanonicalPath
+ Files.write(new File(dir, "visible.json").toPath,
"""{"a":"visible"}""".getBytes)
+ Files.write(new File(dir, "_hidden.json").toPath,
"""{"a":"hidden"}""".getBytes)
+
+ // The default '^[._]' hides the '_'-prefixed file.
+ checkAnswer(spark.read.format("json").load(basePath),
Seq(Row("visible")))
+ // An empty per-read option disables the generic hidden-file filter and
surfaces it.
+ checkAnswer(
+ spark.read.option("ignoredPathSegmentRegex",
"").format("json").load(basePath),
+ Seq(Row("visible"), Row("hidden")))
+ // An empty session conf does too.
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> "") {
+ checkAnswer(spark.read.format("json").load(basePath),
Seq(Row("visible"), Row("hidden")))
+ }
+ }
+ }
}
object TestingUDT {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
index 792e5779d49b..2cbd1ddd9fd3 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/ArchiveReaderSuite.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql.execution.datasources
import java.io.{ByteArrayOutputStream, Closeable, File, FileOutputStream,
InputStream, OutputStream}
import java.nio.charset.StandardCharsets
import java.util.Properties
+import java.util.regex.Pattern
import java.util.zip.GZIPOutputStream
import scala.collection.mutable.ArrayBuffer
@@ -167,6 +168,21 @@ class ArchiveReaderSuite extends SparkFunSuite {
}
}
+ test("readEntries: a custom ignoredPathSegmentRegex pattern surfaces hidden
entries") {
+ withTempDir { dir =>
+ val tar = new File(dir, "custom-filter.tar")
+ writeTar(tar, Seq(textEntry("_SUCCESS", "marker"), textEntry("real.csv",
"kept")))
+ // "(?!)" matches nothing, so hidden-name filtering is disabled and only
the carve-outs in
+ // HadoopFSUtils.shouldFilterOutPathName still apply -- mirroring a
loose-file listing with
+ // the ignoredPathSegmentRegex option set to the same regex.
+ val entries = ArchiveReader(new Path(tar.toURI))
+ .readEntries(new Configuration(), Pattern.compile("(?!)")) { (name,
in) =>
+ Iterator.single((name, new String(readAll(in),
StandardCharsets.UTF_8)))
+ }.toList
+ assert(entries == Seq("_SUCCESS" -> "marker", "real.csv" -> "kept"))
+ }
+ }
+
test("readEntries: advances lazily, one entry at a time") {
withTempDir { dir =>
val tar = new File(dir, "lazy.tar")
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileIndexSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileIndexSuite.scala
index f4de8a52810e..6f72c31c6d4d 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileIndexSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileIndexSuite.scala
@@ -38,7 +38,7 @@ import org.apache.spark.sql.functions.col
import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.sql.types.{IntegerType, StringType, StructField,
StructType}
-import org.apache.spark.util.KnownSizeEstimation
+import org.apache.spark.util.{HadoopFSUtils, KnownSizeEstimation}
class FileIndexSuite extends SharedSparkSession {
@@ -646,9 +646,10 @@ class FileIndexSuite extends SharedSparkSession {
}
test("SPARK-40667: validate FileIndex Options") {
- assert(FileIndexOptions.getAllOptions.size == 8)
+ assert(FileIndexOptions.getAllOptions.size == 9)
// Please add validation on any new FileIndex options here
assert(FileIndexOptions.isValidOption("ignoreMissingFiles"))
+ assert(FileIndexOptions.isValidOption("ignoredPathSegmentRegex"))
assert(FileIndexOptions.isValidOption("ignoreInvalidPartitionPaths"))
assert(FileIndexOptions.isValidOption("timeZone"))
assert(FileIndexOptions.isValidOption("recursiveFileLookup"))
@@ -703,6 +704,180 @@ class FileIndexSuite extends SharedSparkSession {
assert(fileIndex2a != fileIndex2b)
}
}
+
+ test("InMemoryFileIndex: ignoredPathSegmentRegex option surfaces hidden
files in allFiles()") {
+ withTempDir { dir =>
+ stringToFile(new File(dir, "data.txt"), "data")
+ stringToFile(new File(dir, "_hidden"), "hidden")
+ stringToFile(new File(dir, ".dot"), "dot")
+ stringToFile(new File(dir, "x._COPYING_"), "copying")
+ val path = new Path(dir.getCanonicalPath)
+
+ // Default: hidden files are filtered out, only the regular file is
listed.
+ val defaultIndex = new InMemoryFileIndex(spark, Seq(path), Map.empty,
None)
+ assert(defaultIndex.allFiles().map(_.getPath.getName).toSet ===
Set("data.txt"))
+
+ // With a never-matching regex, the hidden file and dot file surface,
but the copying
+ // file stays hidden: the '._COPYING_' carve-out is not overridable.
+ val hiddenIndex = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "(?!)"), None)
+ assert(hiddenIndex.allFiles().map(_.getPath.getName).toSet ===
+ Set("data.txt", "_hidden", ".dot"))
+
assert(!hiddenIndex.allFiles().map(_.getPath.getName).contains("x._COPYING_"))
+ }
+ }
+
+ test("InMemoryFileIndex: ignoredPathSegmentRegex option controls partition
dirs with hidden " +
+ "data files") {
+ withTempDir { dir =>
+ // 'part=1' holds a single hidden ('_'-prefixed) file: filtered out by
default (so no
+ // partition is discovered), but surfaced and counted as data with a
never-matching regex.
+ val partitionDir = new File(dir, "part=1")
+ assert(partitionDir.mkdir())
+ stringToFile(new File(partitionDir, "_data.txt"), "data")
+ val path = new Path(dir.getCanonicalPath)
+
+ // Default: the hidden data file is filtered out, so no files /
partitions.
+ val defaultIndex = new InMemoryFileIndex(spark, Seq(path), Map.empty,
None)
+ assert(defaultIndex.allFiles().isEmpty)
+ assert(defaultIndex.partitionSpec().partitions.isEmpty)
+
+ // With the option set, '_data.txt' is a data file (covers isDataPath)
and 'part=1' is a
+ // partition directory.
+ val hiddenIndex = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "(?!)"), None)
+ assert(hiddenIndex.allFiles().map(_.getPath.getName).toSet ===
Set("_data.txt"))
+ val partitionValues =
hiddenIndex.partitionSpec().partitions.map(_.values)
+ assert(partitionValues.length == 1)
+ assert(partitionValues(0).numFields == 1)
+ assert(partitionValues(0).getInt(0) == 1)
+ assert(hiddenIndex.partitionSpec().partitionColumns.fieldNames.toSeq ===
Seq("part"))
+ }
+ }
+
+ test("InMemoryFileIndex: ignoredPathSegmentRegex with a hidden dir next to
partition dirs") {
+ withTempDir { dir =>
+ // A hidden directory holding files next to 'part=1' becomes a leaf data
dir when the
+ // regex never matches, so partition discovery sees conflicting base
paths.
+ val partitionDir = new File(dir, "part=1")
+ assert(partitionDir.mkdir())
+ stringToFile(new File(partitionDir, "data.txt"), "data")
+ val strayDir = new File(dir, "_stray")
+ assert(strayDir.mkdir())
+ stringToFile(new File(strayDir, "extra.txt"), "extra")
+ val path = new Path(dir.getCanonicalPath)
+
+ // Default: the hidden directory is filtered out and only the partition
data is listed.
+ val defaultIndex = new InMemoryFileIndex(spark, Seq(path), Map.empty,
None)
+ assert(defaultIndex.allFiles().map(_.getPath.getName).toSet ===
Set("data.txt"))
+ assert(defaultIndex.partitionSpec().partitionColumns.fieldNames.toSeq
=== Seq("part"))
+
+ // Never-matching regex: '_stray' is treated as a leaf data dir and
discovered as its own
+ // base path, so partition discovery fails with conflicting directory
structures.
+ val hiddenIndex = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "(?!)"), None)
+ val ex = intercept[SparkRuntimeException] {
+ hiddenIndex.partitionSpec()
+ }
+ assert(ex.getMessage.contains("Conflicting directory structures
detected"))
+
+ // Never-matching regex + ignoreInvalidPartitionPaths=true: the invalid
'_stray' base path
+ // is ignored, 'part' is still discovered, and both files surface.
+ val recoveredIndex = new InMemoryFileIndex(
+ spark,
+ Seq(path),
+ Map("ignoredPathSegmentRegex" -> "(?!)", "ignoreInvalidPartitionPaths"
-> "true"),
+ None)
+ assert(recoveredIndex.partitionSpec().partitionColumns.fieldNames.toSeq
=== Seq("part"))
+ assert(recoveredIndex.allFiles().map(_.getPath.getName).toSet ===
+ Set("data.txt", "extra.txt"))
+ }
+ }
+
+ test("InMemoryFileIndex: ignoredPathSegmentRegex with a _SUCCESS marker next
to partition dirs") {
+ withTempDir { dir =>
+ // Unlike the hidden directory above, a hidden file at the table root
surfaces in the
+ // listing under a never-matching regex but does not break partition
discovery:
+ // parsePartition stops at the base path, so 'part' is still inferred.
+ val partitionDir = new File(dir, "part=1")
+ assert(partitionDir.mkdir())
+ stringToFile(new File(partitionDir, "data.txt"), "data")
+ assert(new File(dir, "_SUCCESS").createNewFile())
+ val path = new Path(dir.getCanonicalPath)
+
+ val hiddenIndex = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "(?!)"), None)
+ assert(hiddenIndex.allFiles().map(_.getPath.getName).toSet ===
Set("data.txt", "_SUCCESS"))
+ assert(hiddenIndex.partitionSpec().partitionColumns.fieldNames.toSeq ===
Seq("part"))
+ }
+ }
+
+ test("InMemoryFileIndex: ignoredPathSegmentRegex option overrides the SQL
conf") {
+ withTempDir { dir =>
+ stringToFile(new File(dir, "data.txt"), "data")
+ stringToFile(new File(dir, "_hidden"), "hidden")
+ val path = new Path(dir.getCanonicalPath)
+
+ val defaultRegex = HadoopFSUtils.DEFAULT_IGNORED_PATH_SEGMENT_REGEX
+
+ def hiddenListed(sqlConf: String, options: Map[String, String]): Boolean
= {
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> sqlConf) {
+ val index = new InMemoryFileIndex(spark, Seq(path), options, None)
+ index.allFiles().map(_.getPath.getName).contains("_hidden")
+ }
+ }
+
+ // conf=default, option=never-matching -> option wins, hidden file
listed.
+ assert(hiddenListed(defaultRegex, Map("ignoredPathSegmentRegex" ->
"(?!)")))
+ // conf=never-matching, option=default -> option wins, hidden file not
listed.
+ assert(!hiddenListed("(?!)", Map("ignoredPathSegmentRegex" ->
defaultRegex)))
+ // conf=never-matching, no option -> conf takes effect, hidden file
listed.
+ assert(hiddenListed("(?!)", Map.empty))
+ // conf=default, no option -> default, hidden file not listed.
+ assert(!hiddenListed(defaultRegex, Map.empty))
+ }
+ }
+
+ test("InMemoryFileIndex: non-default ignoredPathSegmentRegex bypasses the
FileStatusCache") {
+ withTempDir { dir =>
+ stringToFile(new File(dir, "data.txt"), "data")
+ stringToFile(new File(dir, "_hidden.txt"), "hidden")
+ val path = new Path(dir.getCanonicalPath)
+ val fileStatusCache = FileStatusCache.getOrCreate(spark)
+
+ // Populate the cache with the default-filtered listing.
+ val defaultIndex = new InMemoryFileIndex(spark, Seq(path), Map.empty,
None, fileStatusCache)
+ assert(defaultIndex.allFiles().map(_.getPath.getName).toSet ===
Set("data.txt"))
+
+ // A non-default filter over the same path and cache must not serve the
cached
+ // default-filtered listing: the hidden file surfaces.
+ val hiddenIndex = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "(?!)"), None,
fileStatusCache)
+ assert(hiddenIndex.allFiles().map(_.getPath.getName).toSet ===
+ Set("data.txt", "_hidden.txt"))
+ }
+ }
+
+ test("InMemoryFileIndex: metadata summary files are never data paths
regardless of " +
+ "ignoredPathSegmentRegex") {
+ withTempDir { dir =>
+ stringToFile(new File(dir, "data.txt"), "data")
+ stringToFile(new File(dir, "_metadata"), "metadata")
+ stringToFile(new File(dir, ".dot"), "dot")
+ val path = new Path(dir.getCanonicalPath)
+
+ // Custom regex matching only dot files: '_metadata' still surfaces in
the listing (the
+ // shouldFilterOutPathName carve-out is not overridable), while '.dot'
stays hidden.
+ val index = new InMemoryFileIndex(
+ spark, Seq(path), Map("ignoredPathSegmentRegex" -> "^[.]"), None)
+ assert(index.allFiles().map(_.getPath.getName).toSet === Set("data.txt",
"_metadata"))
+
+ // Although listed, the summary file must never be scanned as data, even
when the regex
+ // does not classify it as hidden.
+ val dataFiles = index.listFiles(Nil,
Nil).flatMap(_.files).map(_.getPath.getName)
+ assert(dataFiles.toSet === Set("data.txt"))
+ }
+ }
}
object DeletionRaceFileSystem {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSourceSuite.scala
index 4955a32c49b1..40a333407896 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSourceSuite.scala
@@ -2619,6 +2619,85 @@ class FileStreamSourceSuite extends FileStreamSourceTest
{
}
}
}
+
+ test("ignoredPathSegmentRegex option: hidden files are not streamed by
default") {
+ withTempDirs { case (src, tmp) =>
+ val textStream = createFileStream("text", src.getCanonicalPath)
+ val filtered = textStream.filter($"value" contains "keep")
+
+ // The '_'-prefixed file is a hidden file and must not be picked up by
the stream.
+ testStream(filtered)(
+ AddTextFileData("drop1\nkeep2", src, tmp, tmpFilePrefix = "_hidden"),
+ AddTextFileData("keep3", src, tmp),
+ CheckAnswer("keep3")
+ )
+ }
+ }
+
+ test("ignoredPathSegmentRegex option: hidden files are streamed with a
never-matching regex") {
+ withTempDirs { case (src, tmp) =>
+ val textStream = createFileStream(
+ "text", src.getCanonicalPath, options = Map("ignoredPathSegmentRegex"
-> "(?!)"))
+ val filtered = textStream.filter($"value" contains "keep")
+
+ // With a never-matching regex, the '_'-prefixed file participates in
streaming.
+ testStream(filtered)(
+ AddTextFileData("drop1\nkeep2", src, tmp, tmpFilePrefix = "_hidden"),
+ CheckAnswer("keep2"),
+ AddTextFileData("keep3", src, tmp),
+ CheckAnswer("keep2", "keep3")
+ )
+ }
+ }
+
+ test("ignoredPathSegmentRegex SQL conf: hidden files are streamed with a
never-matching regex") {
+ withSQLConf(SQLConf.IGNORED_PATH_SEGMENT_REGEX.key -> "(?!)") {
+ withTempDirs { case (src, tmp) =>
+ val textStream = createFileStream("text", src.getCanonicalPath)
+ val filtered = textStream.filter($"value" contains "keep")
+
+ // With the session conf set, the '_'-prefixed file participates in
streaming even
+ // though the ignoredPathSegmentRegex option is not set.
+ testStream(filtered)(
+ AddTextFileData("drop1\nkeep2", src, tmp, tmpFilePrefix = "_hidden"),
+ CheckAnswer("keep2"),
+ AddTextFileData("keep3", src, tmp),
+ CheckAnswer("keep2", "keep3")
+ )
+ }
+ }
+ }
+
+ test("ignoredPathSegmentRegex option does not surface another query's
_spark_metadata as data") {
+ // Regression: ignoredPathSegmentRegex must not change _spark_metadata
handling -- a reader on a
+ // sink's output dir still consults the metadata log rather than surfacing
the log files as
+ // data.
+ withSQLConf(SQLConf.FILESTREAM_SINK_METADATA_IGNORED.key -> "false") {
+ withTempDirs { case (outputDir, checkpointDir) =>
+ val source = MemoryStream[String]
+ val writer = source
+ .toDF()
+ .writeStream
+ .option("checkpointLocation", checkpointDir.getCanonicalPath)
+ .format("parquet")
+ .start(outputDir.getCanonicalPath)
+ try {
+ source.addData("keep1", "keep2")
+ writer.processAllAvailable()
+ } finally {
+ writer.stop()
+ }
+
+ // The sink must have created a _spark_metadata directory.
+ assert(new File(outputDir, FileStreamSink.metadataDir).exists())
+
+ // A never-matching regex returns the sink's data, not the
_spark_metadata log files.
+ val df = spark.read.option("ignoredPathSegmentRegex", "(?!)")
+ .parquet(outputDir.getCanonicalPath)
+ checkAnswer(df, Seq(Row("keep1"), Row("keep2")))
+ }
+ }
+ }
}
@SlowSQLTest
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]