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

Yang Jie updated SPARK-59107:
-----------------------------
    Description: 
On the DataSource V1 file source path, subplan merging can widen the set of 
columns a scan reads, and for CSV and JSON that changes which records the 
parser treats as malformed. One subquery's result then depends on what a 
sibling subquery projects.

Reproduction, spark-shell on default configurations, so every read below is V1. 
Each shape is run twice, once as-is and once with subplan merging switched off, 
and every value is measured.

{code}
import java.nio.file.{Files, Paths}
def merging(on: Boolean): Unit = 
spark.conf.set("spark.sql.optimizer.excludedRules",
  if (on) "" else "org.apache.spark.sql.execution.planmerging.MergeSubplans")

val csv = "/tmp/spark-v1-merge-csv"
Files.createDirectories(Paths.get(csv))
Files.write(Paths.get(csv, "data.csv"), "0,0\n1,10\n2,BAD\n3,30\n4,40".getBytes)

// 1. DROPMALFORMED: the record malformed in b is dropped for sum(a) too.
spark.read.schema("a long, b long").option("mode", "DROPMALFORMED").csv(csv)
  .createOrReplaceTempView("t1")
val q1 = "SELECT (SELECT sum(a) FROM t1), (SELECT sum(b) FROM t1)"
merging(true);  sql(q1).show()   // [8, 80]
merging(false); sql(q1).show()   // [10, 80]

// 2. PERMISSIVE, the default mode, with the corrupt-record column in the 
schema.
spark.read.schema("a long, b long, _corrupt_record string").option("mode", 
"PERMISSIVE")
  .option("columnNameOfCorruptRecord", "_corrupt_record").csv(csv)
  .createOrReplaceTempView("t2")
val q2 = "SELECT (SELECT count(_corrupt_record) FROM t2 WHERE a >= 0), " +
  "(SELECT sum(b) FROM t2 WHERE a >= 0)"
merging(true);  sql(q2).show()   // [1, 80]
merging(false); sql(q2).show()   // [0, 80]

// 3. FAILFAST with a row carrying fewer tokens than the schema has columns.
val short = "/tmp/spark-v1-merge-short"
Files.createDirectories(Paths.get(short))
Files.write(Paths.get(short, "data.csv"), "0,0\n1,10\n2\n3,30\n4,40".getBytes)
spark.read.schema("a long, b long").option("mode", "FAILFAST").csv(short)
  .createOrReplaceTempView("t3")
val q3 = "SELECT (SELECT sum(a) FROM t3), (SELECT sum(b) FROM t3)"
merging(true);  sql(q3).show()   // throws MALFORMED_RECORD_IN_PARSING
merging(false); sql(q3).show()   // [10, 80]

// 4. ignoreCorruptFiles, parquet: not about parsing, so this reaches every 
format.
val pq = "/tmp/spark-v1-merge-parquet"
spark.range(0, 10).selectExpr("id AS a", "cast(id AS string) AS b")
  .write.mode("overwrite").parquet(pq)
spark.conf.set("spark.sql.files.ignoreCorruptFiles", "true")
spark.read.schema("a long, b long").parquet(pq).createOrReplaceTempView("t4")
val q4 = "SELECT (SELECT sum(a) FROM t4), (SELECT count(b) FROM t4)"
merging(true);  sql(q4).show()   // [null, 0]
merging(false); sql(q4).show()   // [45, 0]
{code}

Shapes 1 to 3 are CSV, and JSON behaves the same way with equivalent records. 
In shape 1 the scan that reads only a never parses b, so the record malformed 
in b is not dropped for sum(a) until the merge widens the read set. In shape 2 
the merged scan parses b, so the corrupt-record column is populated for a row 
the first subquery had counted as clean. In shape 3, with column pruning on, 
UnivocityParser.parsedSchema is the pruned schema, so a one-column scan matches 
a one-token row and only the merged two-column scan trips the tokens.length != 
parsedSchema.length branch. In shape 4 sum(a) touches only healthy data and is 
correct at 45 until the merge widens the read set; FilePartitionReader.next and 
FileScanRDD both route the failure through 
DataSourceUtils.shouldIgnoreCorruptFileException, which accepts any 
RuntimeException, so the rest of that file's rows go with it.

Mechanism. There is no scan-merge operation on the V1 path. Two 
LogicalRelations over the same paths are canonically equal regardless of 
projection, because top-level column pruning happens in physical planning 
(FileSourceStrategy computes readDataColumns from filterAttributes ++ 
projects), so PlanMerger's identical-plan path reuses one of them and the union 
of the columns is produced one level up in mergeNamedExpressions. The 
planmerging package has no per-source predicate on the V1 path; the 
SCAN_MERGING capability gate in PlanMerger applies only to DSv2 scans.

Scope. Declining the merge needs two things. First, a signal reachable from 
HadoopFsRelation.fileFormat saying the format's parsing depends on the 
projected columns; DataSourceUtils.supportNestedPredicatePushdown and 
SchemaPruning.canPruneDataSchema are the closest existing shapes. Second, the 
decline point has to see whether the projections actually differ: the most 
local seam, a (LogicalRelation, LogicalRelation) arm in 
PlanMerger.tryMergePlans, cannot, because only MergeContext threads downward 
and both leaves carry the full schema. Without that distinction a decline also 
gives up the identical-projection case, which is zero-IO pure reuse. Note also 
that "the projections differ" is not the same as "the scan reads more columns", 
since symmetric filter propagation can add columns independently of the 
projections.

The alternative fix, making malformed-record detection independent of the 
projection, changes user-visible behavior for a plain df.select("a") on the 
same file in three of four configurations and would have to be a config-gated 
mode; CSVSuite pins the current behavior for SPARK-29101 (count() is 4 with CSV 
column pruning on and 3 with it off). Turning 
spark.sql.csv.parser.columnPruning.enabled off is not a fix either: it 
addresses only the arity check, while the type-error path is decided by a 
conversion loop over the required schema.

Context. SPARK-57205 declares SCAN_MERGING per format on the DSv2 read path and 
deliberately leaves CSV and JSON out, and withholds it from any file table 
whose reads are not strict, so V2 does not copy any of these behaviours. That 
leaves V1 and V2 disagreeing on these shapes until this is fixed.

The merge itself is not new: non-correlated scalar subqueries have been merged 
since SPARK-34079 (3.4.0), and SPARK-44571 handed that to MergeSubplans. 
Everything measured above is on master.

  was:
On the DataSource V1 file source path, subplan merging can widen the set of 
columns a scan reads, and for CSV and JSON that changes which records the 
parser treats as malformed. One subquery's result then depends on what a 
sibling subquery projects.

Reproduction, spark-shell on default configurations, so every read below is V1. 
Each shape is run twice, once as-is and once with subplan merging switched off, 
and every value is measured.

{code}
import java.nio.file.{Files, Paths}
def merging(on: Boolean): Unit = 
spark.conf.set("spark.sql.optimizer.excludedRules",
  if (on) "" else "org.apache.spark.sql.execution.planmerging.MergeSubplans")

val csv = "/tmp/spark-v1-merge-csv"
Files.createDirectories(Paths.get(csv))
Files.write(Paths.get(csv, "data.csv"), "0,0\n1,10\n2,BAD\n3,30\n4,40".getBytes)

// 1. DROPMALFORMED: the record malformed in b is dropped for sum(a) too.
spark.read.schema("a long, b long").option("mode", "DROPMALFORMED").csv(csv)
  .createOrReplaceTempView("t1")
val q1 = "SELECT (SELECT sum(a) FROM t1), (SELECT sum(b) FROM t1)"
merging(true);  sql(q1).show()   // [8, 80]
merging(false); sql(q1).show()   // [10, 80]

// 2. PERMISSIVE, the default mode, with the corrupt-record column in the 
schema.
spark.read.schema("a long, b long, _corrupt_record string").option("mode", 
"PERMISSIVE")
  .option("columnNameOfCorruptRecord", "_corrupt_record").csv(csv)
  .createOrReplaceTempView("t2")
val q2 = "SELECT (SELECT count(_corrupt_record) FROM t2 WHERE a >= 0), " +
  "(SELECT sum(b) FROM t2 WHERE a >= 0)"
merging(true);  sql(q2).show()   // [1, 80]
merging(false); sql(q2).show()   // [0, 80]

// 3. FAILFAST with a row carrying fewer tokens than the schema has columns.
val short = "/tmp/spark-v1-merge-short"
Files.createDirectories(Paths.get(short))
Files.write(Paths.get(short, "data.csv"), "0,0\n1,10\n2\n3,30\n4,40".getBytes)
spark.read.schema("a long, b long").option("mode", "FAILFAST").csv(short)
  .createOrReplaceTempView("t3")
val q3 = "SELECT (SELECT sum(a) FROM t3), (SELECT sum(b) FROM t3)"
merging(true);  sql(q3).show()   // throws MALFORMED_RECORD_IN_PARSING
merging(false); sql(q3).show()   // [10, 80]

// 4. ignoreCorruptFiles, parquet: not about parsing, so this reaches every 
format.
val pq = "/tmp/spark-v1-merge-parquet"
spark.range(0, 10).selectExpr("id AS a", "cast(id AS string) AS b")
  .write.mode("overwrite").parquet(pq)
spark.conf.set("spark.sql.files.ignoreCorruptFiles", "true")
spark.read.schema("a long, b long").parquet(pq).createOrReplaceTempView("t4")
val q4 = "SELECT (SELECT sum(a) FROM t4), (SELECT count(b) FROM t4)"
merging(true);  sql(q4).show()   // [null, 0]
merging(false); sql(q4).show()   // [45, 0]
{code}

Shapes 1 to 3 are CSV, and JSON behaves the same way with equivalent records. 
In shape 1 the scan that reads only a never parses b, so the record malformed 
in b is not dropped for sum(a) until the merge widens the read set. In shape 2 
the merged scan parses b, so the corrupt-record column is populated for a row 
the first subquery had counted as clean. In shape 3, with column pruning on, 
UnivocityParser.parsedSchema is the pruned schema, so a one-column scan matches 
a one-token row and only the merged two-column scan trips the tokens.length != 
parsedSchema.length branch. In shape 4 sum(a) touches only healthy data and is 
correct at 45 until the merge widens the read set; FilePartitionReader.next and 
FileScanRDD both route the failure through 
DataSourceUtils.shouldIgnoreCorruptFileException, which accepts any 
RuntimeException, so the rest of that file's rows go with it.

Mechanism. There is no scan-merge operation on the V1 path. Two 
LogicalRelations over the same paths are canonically equal regardless of 
projection, because top-level column pruning happens in physical planning 
(FileSourceStrategy computes readDataColumns from filterAttributes ++ 
projects), so PlanMerger's identical-plan path reuses one of them and the union 
of the columns is produced one level up in mergeNamedExpressions. The 
planmerging package has no per-source predicate on the V1 path; the 
SCAN_MERGING capability gate in PlanMerger applies only to DSv2 scans.

Scope. Declining the merge needs two things. First, a signal reachable from 
HadoopFsRelation.fileFormat saying the format's parsing depends on the 
projected columns; DataSourceUtils.supportNestedPredicatePushdown and 
SchemaPruning.canPruneDataSchema are the closest existing shapes. Second, the 
decline point has to see whether the projections actually differ: the most 
local seam, a (LogicalRelation, LogicalRelation) arm in 
PlanMerger.tryMergePlans, cannot, because only MergeContext threads downward 
and both leaves carry the full schema. Without that distinction a decline also 
gives up the identical-projection case, which is zero-IO pure reuse. Note also 
that "the projections differ" is not the same as "the scan reads more columns", 
since symmetric filter propagation can add columns independently of the 
projections.

The alternative fix, making malformed-record detection independent of the 
projection, changes user-visible behavior for a plain df.select("a") on the 
same file in three of four configurations and would have to be a config-gated 
mode; CSVSuite pins the current behavior for SPARK-29101 (count() is 4 with CSV 
column pruning on and 3 with it off). Turning 
spark.sql.csv.parser.columnPruning.enabled off is not a fix either: it 
addresses only the arity check, while the type-error path is decided by a 
conversion loop over the required schema.

Context. SPARK-57205 declares SCAN_MERGING per format on the DSv2 read path and 
deliberately leaves CSV and JSON out, and withholds it from any file table 
whose reads are not strict, so V2 does not copy any of these behaviours. That 
leaves V1 and V2 disagreeing on these shapes until this is fixed.



> Subplan merging must not widen the columns a projection-sensitive parser reads
> ------------------------------------------------------------------------------
>
>                 Key: SPARK-59107
>                 URL: https://issues.apache.org/jira/browse/SPARK-59107
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 5.0.0
>            Reporter: Yang Jie
>            Priority: Major
>
> On the DataSource V1 file source path, subplan merging can widen the set of 
> columns a scan reads, and for CSV and JSON that changes which records the 
> parser treats as malformed. One subquery's result then depends on what a 
> sibling subquery projects.
> Reproduction, spark-shell on default configurations, so every read below is 
> V1. Each shape is run twice, once as-is and once with subplan merging 
> switched off, and every value is measured.
> {code}
> import java.nio.file.{Files, Paths}
> def merging(on: Boolean): Unit = 
> spark.conf.set("spark.sql.optimizer.excludedRules",
>   if (on) "" else "org.apache.spark.sql.execution.planmerging.MergeSubplans")
> val csv = "/tmp/spark-v1-merge-csv"
> Files.createDirectories(Paths.get(csv))
> Files.write(Paths.get(csv, "data.csv"), 
> "0,0\n1,10\n2,BAD\n3,30\n4,40".getBytes)
> // 1. DROPMALFORMED: the record malformed in b is dropped for sum(a) too.
> spark.read.schema("a long, b long").option("mode", "DROPMALFORMED").csv(csv)
>   .createOrReplaceTempView("t1")
> val q1 = "SELECT (SELECT sum(a) FROM t1), (SELECT sum(b) FROM t1)"
> merging(true);  sql(q1).show()   // [8, 80]
> merging(false); sql(q1).show()   // [10, 80]
> // 2. PERMISSIVE, the default mode, with the corrupt-record column in the 
> schema.
> spark.read.schema("a long, b long, _corrupt_record string").option("mode", 
> "PERMISSIVE")
>   .option("columnNameOfCorruptRecord", "_corrupt_record").csv(csv)
>   .createOrReplaceTempView("t2")
> val q2 = "SELECT (SELECT count(_corrupt_record) FROM t2 WHERE a >= 0), " +
>   "(SELECT sum(b) FROM t2 WHERE a >= 0)"
> merging(true);  sql(q2).show()   // [1, 80]
> merging(false); sql(q2).show()   // [0, 80]
> // 3. FAILFAST with a row carrying fewer tokens than the schema has columns.
> val short = "/tmp/spark-v1-merge-short"
> Files.createDirectories(Paths.get(short))
> Files.write(Paths.get(short, "data.csv"), "0,0\n1,10\n2\n3,30\n4,40".getBytes)
> spark.read.schema("a long, b long").option("mode", "FAILFAST").csv(short)
>   .createOrReplaceTempView("t3")
> val q3 = "SELECT (SELECT sum(a) FROM t3), (SELECT sum(b) FROM t3)"
> merging(true);  sql(q3).show()   // throws MALFORMED_RECORD_IN_PARSING
> merging(false); sql(q3).show()   // [10, 80]
> // 4. ignoreCorruptFiles, parquet: not about parsing, so this reaches every 
> format.
> val pq = "/tmp/spark-v1-merge-parquet"
> spark.range(0, 10).selectExpr("id AS a", "cast(id AS string) AS b")
>   .write.mode("overwrite").parquet(pq)
> spark.conf.set("spark.sql.files.ignoreCorruptFiles", "true")
> spark.read.schema("a long, b long").parquet(pq).createOrReplaceTempView("t4")
> val q4 = "SELECT (SELECT sum(a) FROM t4), (SELECT count(b) FROM t4)"
> merging(true);  sql(q4).show()   // [null, 0]
> merging(false); sql(q4).show()   // [45, 0]
> {code}
> Shapes 1 to 3 are CSV, and JSON behaves the same way with equivalent records. 
> In shape 1 the scan that reads only a never parses b, so the record malformed 
> in b is not dropped for sum(a) until the merge widens the read set. In shape 
> 2 the merged scan parses b, so the corrupt-record column is populated for a 
> row the first subquery had counted as clean. In shape 3, with column pruning 
> on, UnivocityParser.parsedSchema is the pruned schema, so a one-column scan 
> matches a one-token row and only the merged two-column scan trips the 
> tokens.length != parsedSchema.length branch. In shape 4 sum(a) touches only 
> healthy data and is correct at 45 until the merge widens the read set; 
> FilePartitionReader.next and FileScanRDD both route the failure through 
> DataSourceUtils.shouldIgnoreCorruptFileException, which accepts any 
> RuntimeException, so the rest of that file's rows go with it.
> Mechanism. There is no scan-merge operation on the V1 path. Two 
> LogicalRelations over the same paths are canonically equal regardless of 
> projection, because top-level column pruning happens in physical planning 
> (FileSourceStrategy computes readDataColumns from filterAttributes ++ 
> projects), so PlanMerger's identical-plan path reuses one of them and the 
> union of the columns is produced one level up in mergeNamedExpressions. The 
> planmerging package has no per-source predicate on the V1 path; the 
> SCAN_MERGING capability gate in PlanMerger applies only to DSv2 scans.
> Scope. Declining the merge needs two things. First, a signal reachable from 
> HadoopFsRelation.fileFormat saying the format's parsing depends on the 
> projected columns; DataSourceUtils.supportNestedPredicatePushdown and 
> SchemaPruning.canPruneDataSchema are the closest existing shapes. Second, the 
> decline point has to see whether the projections actually differ: the most 
> local seam, a (LogicalRelation, LogicalRelation) arm in 
> PlanMerger.tryMergePlans, cannot, because only MergeContext threads downward 
> and both leaves carry the full schema. Without that distinction a decline 
> also gives up the identical-projection case, which is zero-IO pure reuse. 
> Note also that "the projections differ" is not the same as "the scan reads 
> more columns", since symmetric filter propagation can add columns 
> independently of the projections.
> The alternative fix, making malformed-record detection independent of the 
> projection, changes user-visible behavior for a plain df.select("a") on the 
> same file in three of four configurations and would have to be a config-gated 
> mode; CSVSuite pins the current behavior for SPARK-29101 (count() is 4 with 
> CSV column pruning on and 3 with it off). Turning 
> spark.sql.csv.parser.columnPruning.enabled off is not a fix either: it 
> addresses only the arity check, while the type-error path is decided by a 
> conversion loop over the required schema.
> Context. SPARK-57205 declares SCAN_MERGING per format on the DSv2 read path 
> and deliberately leaves CSV and JSON out, and withholds it from any file 
> table whose reads are not strict, so V2 does not copy any of these 
> behaviours. That leaves V1 and V2 disagreeing on these shapes until this is 
> fixed.
> The merge itself is not new: non-correlated scalar subqueries have been 
> merged since SPARK-34079 (3.4.0), and SPARK-44571 handed that to 
> MergeSubplans. Everything measured above is on master.



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