Jorge Molina created SPARK-58518:
------------------------------------

             Summary: 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


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