[ 
https://issues.apache.org/jira/browse/SPARK-58518?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Josh Rosen updated SPARK-58518:
-------------------------------
    Labels: correctness  (was: )

> DataSource.checkAndGlobPathIfNecessary duplicates every input path when 
> globbing is disabled, so an unpartitioned relation returns duplicate rows
> -------------------------------------------------------------------------------------------------------------------------------------------------
>
>                 Key: SPARK-58518
>                 URL: https://issues.apache.org/jira/browse/SPARK-58518
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 5.0.0
>            Reporter: Jorge Molina
>            Priority: Major
>              Labels: correctness
>
> h2. Summary
> {{DataSource.checkAndGlobPathIfNecessary}} duplicates its input paths when 
> globbing is
> disabled. The duplicated paths become the {{rootPaths}} of the file index, 
> and for an
> unpartitioned relation they reach {{FileScanRDD}} intact, so *the same file 
> is read several
> times and the query returns duplicate rows*. No error, no warning.
> The highest-impact caller is the Structured Streaming file source, which 
> disables globbing
> unconditionally: a microbatch containing one file whose *name* holds a glob 
> metacharacter
> emits every other file in that batch more than once.
> h2. Root cause
> {{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala}},
>  in
> {{checkAndGlobPathIfNecessary}} — the lambda runs *per element* of 
> {{globPaths}}, but the
> {{else}} branch returns {{qualifiedPaths}}, the whole input list:
> {code:scala}
> val (globPaths, nonGlobPaths) = 
> qualifiedPaths.partition(SparkHadoopUtil.get.isGlobPath)
> val globbedPaths =
>   try {
>     ThreadUtils.parmap(globPaths, "globPath", numThreads) { globPath =>
>       val fs = globPath.getFileSystem(hadoopConf)
>       val globResult = if (enableGlobbing) {
>         SparkHadoopUtil.get.globPath(fs, globPath)
>       } else {
>         qualifiedPaths          // <-- the WHOLE list, once per glob-looking 
> path
>       }
>       ...
>       globResult
>     }.flatten
> {code}
> The result is then concatenated with {{nonGlobPaths}} again:
> {{val allPaths = globbedPaths ++ nonGlobPaths}}.
> So with {{G}} paths that *look* like globs and {{n}} that do not, 
> {{allPaths}} holds
> {{G*(G+n)+n}} entries instead of {{G+n}}.
> "Looks like a glob" is a bare character scan with no notion of escaping
> ({{SparkHadoopUtil.scala}}):
> {code:scala}
> def isGlobPath(pattern: Path): Boolean = {
>   pattern.toString.exists("{}[]*?\\".toSet.contains)
> }
> {code}
> Filenames containing {{[}}, {{]}}, {{\{}}, {{\}}} are legal on HDFS and S3 
> and ordinary in real
> ingest zones, so the branch is reached by data, not by misuse.
> h2. Reproduction
> Three single-row CSV files, one of them named {{weird_[x].csv}}:
> {code:python}
> paths = ["plain_a.csv", "plain_b.csv", "weird_[x].csv"]   # one row each
> spark.read.csv(paths).count()
> # AnalysisException: [PATH_NOT_FOUND] ... weird_[x].csv
> #   -- which is exactly why __globPaths__ exists (SPARK-32810)
> spark.read.options(**{"__globPaths__": "false"}).csv(paths).count()
> # 5      <-- three files, five rows
> #   x2  ('0', 'row_from_plain_a.csv')     DUPLICATED
> #   x2  ('1', 'row_from_plain_b.csv')     DUPLICATED
> #   x1  ('2', 'row_from_weird_[x].csv')
> {code}
> h3. The predicted count holds exactly
> {{G*(G+n)+n}} is not a description of one observation; it predicts every case 
> measured
> (pyspark 3.5.9):
> || G || n || files || predicted || observed ||
> | 1 | 0 | 1 | 1 | 1 |
> | 1 | 2 | 3 | 5 | 5 |
> | 2 | 0 | 2 | 4 | 4 |
> | 2 | 2 | 4 | 10 | 10 |
> | 3 | 1 | 4 | 13 | 13 |
> h3. Format-agnostic, as a defect in {{DataSource}} must be
> Three files, one with a metacharacter in its name, 
> {{\_\_globPaths\_\_=false}}:
> || format || rows || expected ||
> | text | 5 | 3 |
> | json | 5 | 3 |
> | csv | 5 | 3 |
> | parquet | 5 | 3 |
> h3. Scoped honestly: it needs an unpartitioned relation
> {{PartitioningAwareFileIndex.allFiles()}} takes a different branch when 
> partition columns are
> discovered, and that branch returns a {{Map}}'s values, which silently 
> absorbs the duplicates.
> Measured on the same data — three partition directories, one renamed to 
> {{part=[9]}}:
> || read || rows ||
> | without {{basePath}} (unpartitioned) | 15 — duplicated |
> | with {{basePath}} (partitions discovered) | 9 — correct |
> So the correctness impact is on unpartitioned relations. It is still a real 
> waste of I/O on
> partitioned ones.
> h3. Structured Streaming, the caller that makes this matter
> {{FileStreamSource}} sets {{DataSource.GLOB_PATHS_KEY -> "false"}} 
> unconditionally and hands
> the whole microbatch's file list to a {{DataSource}}. Measured with three 
> files in the source
> directory, one named {{s_weird_[z].txt}}:
> {code}
> rows emitted: 5   (expected 3)
>   x2  stream_line_0   DUPLICATED
>   x2  stream_line_1   DUPLICATED
>   x1  stream_line_2
> {code}
> Other callers that disable globbing and pass multiple paths — so schema 
> inference re-reads
> files as well — include {{MLUtils.parseLibSVMFile}} and the 
> {{TextInput*.infer}} paths.
> h2. Why it survived since 2020
> The branch was introduced by SPARK-32810 (PR #29659), whose tests all read a 
> *single* path —
> {{G=1, n=0}}, where {{qualifiedPaths == Seq(globPath)}} and the bug is 
> invisible. The table
> above confirms it: that row is the one case that comes back correct. There is 
> no test with
> {{enableGlobbing = false}} and more than one path.
> Nor is there any dedup downstream to mask it: {{DataSource.scala}} contains 
> no {{distinct}},
> and {{PartitioningAwareFileIndex}} does {{rootPaths.flatMap}} without one.
> h2. Suggested fix
> Return the path being processed rather than the whole list:
> {code:scala}
>       } else {
>         Seq(globPath)
>       }
> {code}
> That restores what the parameter's own documentation says 
> ({{DataSource.scala}}: "These will be
> globbed before if the '\_\_globPaths\_\_' option is true") and what a 
> per-element lambda must
> mean. For the case SPARK-32810 introduced the option to serve — a single path 
> — the two
> expressions are the same value, so every scenario it fixed behaves 
> identically.
> {{globResult}} stays non-empty, so the {{checkEmptyGlobPath}} check cannot 
> begin firing
> {{PATH_NOT_FOUND}} where it did not before. The only observable differences 
> are fewer rows —
> the correct count — and less I/O.
> h2. Prior art checked
> * SPARK-32810 / PR #29659 — introduced the branch. Not a report of this 
> defect.
> * SPARK-32815 — LibSVM with glob metacharacters; establishes that such 
> filenames are meant to
>   work, but a different bug.
> * SPARK-28266 "data duplication when {{path}} serde property is present" — 
> *related in symptom,
>   different in cause*, and worth naming so it is not mistaken for a 
> duplicate: there a Hive
>   serde property repeated the table LOCATION, and it was fixed in PR #33328
>   ({{convertToLogicalRelation should not interpret {{path}} property}}), 
> which does not touch
>   {{checkAndGlobPathIfNecessary}}. It does establish that Spark treats 
> duplicated input paths
>   as a genuine correctness bug.
> * SPARK-47833, SPARK-29089, SPARK-56919 all touch this method for unrelated 
> reasons.
> * No JIRA matches {{checkAndGlobPathIfNecessary}} + duplication, and no PR in 
> {{apache/spark}}
>   mentions {{enableGlobbing}} apart from #29659.
> PR to follow, with a regression test in {{DataSourceSuite}} — which currently 
> has no case
> exercising {{enableGlobbing = false}} at all.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to