Uh oh!
There was an error while loading. Please reload this page.
[SPARK-59108][SQL] Fix Avro positional matching under column pruning - #58409
[SPARK-59108][SQL] Fix Avro positional matching under column pruning#58409LuciferYang wants to merge 10 commits into
Conversation
… data schema Positional field matching paired a catalyst field with the Avro field at the same position in the projection, so a pruned read took the wrong field. Pass the data schema the projection came from to AvroDeserializer and resolve the position there, the way OrcUtils.requestedColumnIds does for orc.force.positional.evolution.
Tests for a partition column in the schema, a nested record, the avroSchema option, a pruned read past the end of the Avro schema, a mispaired type, a pushed filter, mixed-case names, and the archive read path. The positional error message now names the data schema position rather than the projection's.
Dropping the default on the new parameter means a future read path that prunes columns cannot silently inherit the old behaviour; the callers whose Catalyst schema is not a projection pass None explicitly. The comments claimed a filter always runs inside the deserializer (only with pushdown on), that the archive path needs the schema because it builds a deserializer per entry (it needs it because it is a second construction site), and described a nested-record call that never happens. The five tests also move below the SPARK-34365 pair they had split.
peter-toth
left a comment
There was a problem hiding this comment.
Thanks for the PR, @LuciferYang!
This is the read-side root cause behind #58340's Avro gate, and the shape looks right. A column's Avro field is now fixed by its position in the data schema, so it no longer depends on what the query projects.
I re-ran it in a worktree at a3358708e6. The 11 new tests pass. With positionsInDataSchema returning empty, all 10 AvroSuite cases fail, five on each path, so your mutation check holds. I also re-ran the plan-merging repro from #58411 - SELECT (SELECT sum(a) FROM t), (SELECT sum(c) FROM t) is [10, 100000] both merged and unmerged here, against [10, 1000] and [10, 10] with the mapping off.
Nothing blocking from my side. Everything below is a description, coverage or naming item, and none of it changes the mechanism.
Non-blocking
- 1.Test coverage - one distinguishing projection is left out: The description says "every one-column and two-column projection", but the test covers
z,y,(x, z)and(z, x).xand(x, y)are prefixes and come back right on base too, which I measured, so(y, z)is the only shape the fix changes that nothing asserts. [inline:connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala:1757] - 2.A read that returned values can now fail, and the user-facing section doesn't say so: Repairing the pairing can land a projected column on an incompatible Avro field. That fails the read where it used to return a neighbouring field's values. Your "fails a mispaired type" test is exactly that shape. [inline:
connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala:1865] - 3.This also retires the open Avro request on #58411: The mapping makes the read projection-independent, which is stronger than making a pruned read correct. Worth naming #58411 beside #58340 so the three land in a consistent state. [inline:
sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:476]
Minor
- 4.The reason given for the pre-existing gap is not the one that breaks it: Differing lengths are fine on their own -
AvroSuite.scala:1701reads two Catalyst fields out oftest.avro's eleven Avro fields and is correct. What breaks a gapped schema is where the dropped field sits. [inline:sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:473] - 5.Drop the
dataSchemaPositionsdefault: Two call sites, both in this file, and you already madeAvroDeserializer.dataSchemaexplicit for the same reason. [inline:sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:498]
| checkAnswer(df.select("z"), rows.map(r => Row(r.get(2)))) | ||
| checkAnswer(df.select("y"), rows.map(r => Row(r.get(1)))) | ||
| checkAnswer(df.select("x", "z"), rows.map(r => Row(r.get(0), r.get(2)))) | ||
| checkAnswer(df.select("z", "x"), rows.map(r => Row(r.get(2), r.get(0)))) |
There was a problem hiding this comment.
Finding 1. The description says "with every one-column and two-column projection", but this covers four of the six: z, y, (x, z) and (z, x).
Of the three left out, two are prefixes of the field list and so come back right on base as well. I measured them with positionsInDataSchema returning empty: select("x") gives 0..4 and select("x", "y") gives (i, 100 * i), both correct. So they add nothing.
(y, z) is the one that does. On base it reads (i, 100 * i), on your head (100 * i, 10000 * i), so it is the last two-column shape the fix changes that nothing asserts.
| checkAnswer(df.select("z", "x"), rows.map(r =>Row(r.get(2), r.get(0)))) | |
| checkAnswer(df.select("z", "x"), rows.map(r =>Row(r.get(2), r.get(0)))) | |
| checkAnswer(df.select("y", "z"), rows.map(r =>Row(r.get(1), r.get(2)))) |
With that line in, the description's claim holds for the one-column and two-column projections that distinguish the fix, and it is worth saying that rather than "every".
| .schema("x long, y long, z long") | ||
| .load(path) | ||
| // y takes Avro field 1, which is a string, so the read fails instead of returning the values |
There was a problem hiding this comment.
Finding 2. This test is the one user-facing consequence the description's "Does this PR introduce any user-facing change?" section leaves out.
On base, df.select("y") pairs y with Avro field 0, a long, and returns a's values. On your head it pairs with field 1, a string, and the read throws. So a query that produced numbers before this change now fails.
That is the right behaviour and I am not asking you to soften it. It is also the shape most likely to come back as a regression report, so it belongs in that section rather than only in a test comment. One sentence, something like: a read whose projection previously took a type-compatible neighbouring field now pairs with its own field and fails when the types do not match.
| * Positional matching is already wrong for such a schema without this method, since the two | ||
| * schemas have different lengths. | ||
| */ | ||
| private def positionsInDataSchema(projection: StructType): Array[Int] = dataSchema match { |
There was a problem hiding this comment.
Finding 3. What this method buys is stronger than "a pruned read is now correct", and the stronger property is what two other PRs need.
A column's Avro field is derived from dataSchema alone, so it is the same whatever the projection contains. That holds even for the gapped schema you describe below: a recursiveFieldMaxDepth read stays wrong, but it is now consistently wrong rather than dependent on the projection.
Measured on a worktree at this head with fields a, b, c = id, 100 * id, 10000 * id, ids 0 to 4, positionalFieldMatching=true, V1:
SELECT (SELECT sum(a) FROM t), (SELECT sum(c) FROM t) merged | MergeSubplans excluded | |
|---|---|---|
| your head | [10, 100000] | [10, 100000] |
| mapping disabled | [10, 1000] | [10, 10] |
The description names #58340's AvroTable gate. #58411 has the same item open on the V1 side - a request to add V1 Avro to DataSourceUtils.isProjectionSensitiveRead because positionalFieldMatching makes the read projection-sensitive. This removes the need for that too, and the numbers above are the evidence. Worth naming both PRs so the three land in a consistent state, and worth saying which has to go first.
| * This takes a data schema position for an Avro field position, which `recursiveFieldMaxDepth` | ||
| * can break: `SchemaConverters` drops a field it will not recurse into, so the data schema is a | ||
| * gapped view of the Avro schema and every field after the gap resolves one position early. | ||
| * Positional matching is already wrong for such a schema without this method, since the two |
There was a problem hiding this comment.
Finding 4. The conclusion is right but the reason given is not the one that breaks it.
Two schemas of different lengths are fine on their own. AvroSuite.scala:1701 (SPARK-34365: support reading renamed schema using positionalFieldMatching) reads a two-field Catalyst schema out of test.avro, which has eleven Avro fields, and asserts the values are correct. Extra trailing Avro fields are simply ignored.
What breaks a gapped schema is where the dropped field sits, which is what the sentence above already says. Suggest:
*Positional matching is already wrong for such a schema without this method, because the
* fields after the gap shift by one whatever the projection is.| catalystPath: Seq[String], | ||
| applyFilters: Int => Boolean): (CatalystDataUpdater, GenericRecord) => Boolean = { | ||
| applyFilters: Int => Boolean, | ||
| dataSchemaPositions: Array[Int] = Array.empty) |
There was a problem hiding this comment.
Finding 5. You gave AvroDeserializer.dataSchema no default so every call site has to decide. The same argument applies here, and more so - the nested-record call at line 323 is the one place where getting it wrong returns a neighbouring field's value.
There are only two call sites and both are in this file, so the default buys nothing:
applyFilters: Int=>Boolean,
dataSchemaPositions: Array[Int])and at line 323:
valwriteRecord= getRecordWriter(
avroType, st, avroPath, catalystPath, applyFilters = _ =>false, Array.empty)Not a defect - your scaladoc already states the invariant and cites the two facts it rests on. And select("s.g2") in the nested-record test would fail loudly if Avro ever gained nested pruning without this being extended, since g2 is a string and Avro field 0 is a long.
The pairing repair can land a projected column on an incompatible Avro field, so a read that returned a neighbouring field's values now fails; the user-facing section says so. Differing schema lengths are not what breaks a gapped schema, the position of the gap is. getRecordWriter's positions lose their default, so the nested call states them like the root one does.
LuciferYang
commented
Aug 30, 2026
Thanks @peter-toth, and thanks for re-running it. All five taken. 1. Added 2. Added to the user-facing section: a read that used to land on a type-compatible neighbouring field now pairs with its own field and fails when the types do not match, so a query that returned values can return an error instead. It reads better there than in a test comment, and it is the shape most likely to come back as a report. 3. The description now names both, #58340 on the V2 path and #58411 on the V1 path, says that this makes the read projection-independent rather than only correct under pruning, and says this is the one to land first so the two gates can come off together. 4. You are right, and the example settles it: 5. Default dropped, and the nested call passes Locally: the ten |
### What changes were proposed in this pull request? `FileTable` gains a `supportsScanMerging` seam, and `ParquetTable`, `OrcTable`, `TextTable` and `AvroTable` override it. Those four then take part in the DSv2 scan merging added by #57360 (SPARK-40259): `PlanMerger` drives `V2ScanRelationPushDown.rebuildScan` to rebuild a merged scan, and a source supplies no merge logic of its own. It only declares that widening the set of columns pruned on its builder, with the scan options and pushed filters held constant, changes neither which rows the scan returns nor the values it returns for the columns it was already asked for; it may at most surface a read error. `TableCapability.SCAN_MERGING`'s javadoc stated only a determinism contract, which a CSV table satisfies as written, since its rows are fully determined by the pruned column set and that is exactly the dependence, so this monotonicity criterion is now stated there too, where a connector author will read it. `CSVTable` and `JsonTable` do not override it. Their parsers are handed the columns the scan asked for and decide from that set what counts as a malformed record, so a merged scan reading the union of two column sets can drop or rewrite rows the narrower scan returned. Measured, csv and json alike: with `mode=DROPMALFORMED` and a record malformed only in the other subquery's column, `sum(a)` is 8 where two separate scans give 10; with `PERMISSIVE`, the default, and `_corrupt_record` in the schema, the column is populated for a row the narrow scan counted as clean; with `FAILFAST` and a CSV row carrying fewer tokens than the schema has columns, the merged scan throws where the unmerged one returned rows. Those numbers come from the V1 path, which merges all three shapes today and is where SPARK-59107 (#58411) fixes them. The seam defaults to false, because a format that does not merge misses an optimization while a format that merges when its parser is projection-sensitive returns wrong rows. Two further gates keep the contract true of the formats that do declare it. `FileTable` withholds the capability when `spark.sql.files.ignoreCorruptFiles` or `spark.sql.files.ignoreMissingFiles` is set, matching `FileScanRDD.hasStrictFileReads` on the physical side. Under a non-strict read a failure in a column that only the sibling subquery projects is swallowed and the rest of that file's rows go with it, so the merged scan returns fewer rows than the narrow one did. Measured on parquet, V1 and V2 alike: `SELECT (SELECT sum(a) FROM t), (SELECT count(b) FROM t)` over data whose `b` is written as a string and read as a long returns `[45, 0]` with merging off and `[null, 0]` with it on. The gate is evaluated per call rather than cached, so a table built before the configuration was set still answers for the read that is running. `AvroTable` withholds it under `positionalFieldMatching`. `AvroPartitionReaderFactory` builds the deserializer from the pruned read schema while the Avro side stays the full Avro schema, so under that option catalyst field *i* of the projection takes Avro field *i* of that schema, and widening the projection changes the values a column comes back with. That is a bug in its own right, filed as SPARK-59108 (#58409), which lands after this one and deletes this gate with it. ORC needs no equivalent gate: `OrcUtils.requestedColumnIds` maps both the `_col*` case and `orc.force.positional.evolution` through `dataSchema.fieldIndex(name)` and disables pruning in that branch, so its positional path is projection-independent. The rest is a new test suite, a package-private `V2ScanMergingTestHelper` shared with `DSv2PlanMergingSuite`, one test in `AvroV2Suite`, and one documentation update. Landing order: #58411 (SPARK-59107) first, then this one, then #58409 (SPARK-59108), which deletes the `AvroTable` gate. Nothing here depends on the first step, since this PR no longer asserts anything about how V1 merges CSV and JSON. Once #58411 is in, `FileTable.hasStrictFileReads` should call the `FileSourceOptions.hasStrictFileReads` it adds rather than spell the predicate out a third time, beside `FileScanRDD` and the cache-repeatability check in `InMemoryRelation`. ### Why are the changes needed? Two scans of the same file table that differ only in their projected columns cannot be reused today. A file source folds its data filters into the `FileScan` object, where they are used to list files and prune row groups, and `FileScan.equals` compares them, so two subquery scans over the same path are not canonically identical and `PlanMerger`'s identical-plan fast path does not fire. On the V1 path those filters sit in a `Filter` above an identical `LogicalRelation`, so it does fire. Declaring the capability closes most of that gap for the formats where the merge is sound. On TPC-DS at scale factor 100, with `spark.sql.sources.useV1SourceList` cleared, two queries change: q9 goes from 16 distinct scans to 6 under the default configuration and to 2 with `dsv2SymmetricFilterPropagation` on, and q28 from 6 to 1 with that configuration on. Of the 99 v1.4 queries, 95 ran and the other 93 of those are unchanged. Wall clock for q9 went from 78.1s to 40.3s by default and to 21.6s with the configuration on, and q28 from 61.3s to 34.4s; measured on `local[1]` with AQE off, two runs per configuration, against a build with the overrides removed as the baseline. Two samples on one machine put the noise around 20%, so the scan counts are the reproducible part and the timings show the order of magnitude. Both queries read `store_sales`, which is parquet, so leaving CSV and JSON out does not affect these numbers. Four queries could not be measured here because `DataSourceV2Relation.computeStats` raises a testing-only assertion when stats are read before pushdown, and q30 does not run. ### Does this PR introduce _any_ user-facing change? Yes, on the V2 file source read path, which a format reaches only when it is removed from `spark.sql.sources.useV1SourceList`. Plan shape only: subqueries over the same Parquet, ORC, text or Avro table that differ only in their projected columns now collapse into a single scan reading the union of those columns. No query result changes, which is what the exclusions and the two gates above are for, and why this needs no migration-guide entry. The `dsv2SymmetricFilterPropagation` entry in `docs/sql-performance-tuning.md` said "no built-in source does", which this PR falsifies. The four formats and the two withholding rules now sit in the prose of the Merging Subplans section rather than in that entry, since they hold whatever the configuration is set to, and the prose sentence that said the leaves must read the same input is qualified there for a source that declares the capability. Four gaps are left in place. The first three are follow-ups #57360 already lists. 1. Two scans with different partition filters do not merge, while V1 merges them. A partition filter is fully enforced by the V2 scan and reported as strict, so widening it to `OR` would leave the merged scan returning rows nothing above it filters out. 2. Parquet and ORC nested columns do not merge while nested schema pruning is on. Each side narrows the struct to the field it reads, so the read column is no longer a same-type subset of the relation's column. V1 does not merge this shape either, because `SchemaPruning` rewrites each side's `dataSchema` and the two relations stop being canonically equal. 3. Differing data filters need `spark.sql.optimizer.mergeSubplans.filterPropagation.dsv2SymmetricFilterPropagation.enabled`, which defaults to false. On the V2 path that configuration alone is enough, where V1 needs the broader `symmetricFilterPropagation`. 4. CSV and JSON get no merging at all, which is deliberate rather than a follow-up. It costs them two shapes: scans whose projected columns differ, and scans reading the same columns under different filters, since `FileScan.equals` compares the normalized data filters so those are not canonically equal either and do not fall back to plain reuse. The V1 path merges both today and answers `[8, 80]` where two separate scans answer `[10, 80]`, so one subquery's result depends on what a sibling subquery projects. That is a V1 bug rather than a target to copy; SPARK-59107 (#58411) fixes it, and with that in both read paths decline these shapes alike. ### How was this patch tested? New suite `FileSourceV2PlanMergingSuite`, 16 tests. Every built-in file table is checked on the side it belongs to; a non-strict read is checked to withhold the capability on both strictness configurations, and separately to keep its scans separate and return `[45, 0]`, with the table and the temp view built outside the configuration scope so that a cached gate would fail the test. Those are two tests rather than one so that a mutation names which half it broke, the capability assertion having aborted before the rows could report. Scans differing only in projected columns merge for Parquet and ORC and decline for CSV and JSON in the same shape; text merges the one shape a single-column table can differ in, which also needs both aggregates to be hash-aggregatable or `PlanMerger.supportedAggregateMerge` declines above the scans; scans over the same partition filter merge with that filter still enforced on the rebuilt scan; three scans merge into one; differing data filters merge only with the dsv2 configuration on, and the merged scan is checked to carry the OR-widened predicate rather than only to exist. Declines are covered too: differing partition filters, nested-pruned columns, a pushed aggregate (with the aggregate itself asserted, not just the scan count), and two different tables holding different rows so that a cross-table merge would change the answer. Then V1/V2 parity on three shapes and the partition-filter gap pinned as the one shape where they disagree, both on parquet, whose reads this suite pins strict and whose parser does not depend on the projection, so SPARK-59107 leaves them merging on V1. The CSV and JSON parse behaviour is pinned on the V2 side alone, on three shapes: `DROPMALFORMED` gives `[10, 80]`, `PERMISSIVE` with `_corrupt_record` in the schema gives `[0, 80]`, and `FAILFAST` with a short CSV row returns rows rather than throwing. Subquery counts are asserted alongside the rows, so the result is attributed to the decline rather than inferred from the values, and the configurations the expectations depend on are pinned rather than assumed. These three used to assert the V1 numbers beside the V2 ones, and those arms pinned exactly what SPARK-59107 removes, so they are gone; its own suite covers that side. Measured at #58411's head, with this suite copied in: the CSV and JSON test passes there, and so does the parquet partition-filter one. Every test asserts which read path the plan took before asserting anything about merging. SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of `spark.sql.sources.useV1SourceList`, so a suite driven by that configuration alone would quietly run V1 on both sides. One test in `AvroV2Suite`, because `AvroTable` lives in the module that has it on the classpath: it asserts the capability, that `positionalFieldMatching` withholds it, and that two scans differing only in their projected columns fuse into one reading the union. Mutation checks. Turning the three sql/core overrides to `false` fails 8 of the 16 tests; of the 8 that pass, 6 assert a decline, one asserts that V1 merges differing partition filters where V2 does not and V2 declines under the mutation too, and one asserts that a non-strict read keeps its scans separate, which the mutation also produces. Dropping `&& hasStrictFileReads` from `capabilities` fails both strictness tests, the capability one at its first assertion and the rows one with `[null, 0]` against `[45, 0]`, which is what splitting them was for. Regression: the `planmerging` suites, `ExplainSuite` and `ExplainSuiteAE`, `FileBasedDataSourceSuite`, `FileTableSuite`, `OrcV2SchemaPruningSuite`, `ParquetV2SchemaPruningSuite`, `ParquetV2FilterSuite`, `SubquerySuite`, `SameResultSuite`, `ParquetV2AggregatePushDownSuite`, `OrcV2AggregatePushDownSuite`, `DataSourceV2Suite` and `AvroV2Suite`. No golden file or `PlanStabilitySuite` plan needed regenerating. Those build their tables with `CREATE TABLE ... USING <format>`, which resolves to the V1 `FileFormat`, so none of them reaches a V2 file scan. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58340 from LuciferYang/SPARK-57205. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com>
### What changes were proposed in this pull request? `FileTable` gains a `supportsScanMerging` seam, and `ParquetTable`, `OrcTable`, `TextTable` and `AvroTable` override it. Those four then take part in the DSv2 scan merging added by #57360 (SPARK-40259): `PlanMerger` drives `V2ScanRelationPushDown.rebuildScan` to rebuild a merged scan, and a source supplies no merge logic of its own. It only declares that widening the set of columns pruned on its builder, with the scan options and pushed filters held constant, changes neither which rows the scan returns nor the values it returns for the columns it was already asked for; it may at most surface a read error. `TableCapability.SCAN_MERGING`'s javadoc stated only a determinism contract, which a CSV table satisfies as written, since its rows are fully determined by the pruned column set and that is exactly the dependence, so this monotonicity criterion is now stated there too, where a connector author will read it. `CSVTable` and `JsonTable` do not override it. Their parsers are handed the columns the scan asked for and decide from that set what counts as a malformed record, so a merged scan reading the union of two column sets can drop or rewrite rows the narrower scan returned. Measured, csv and json alike: with `mode=DROPMALFORMED` and a record malformed only in the other subquery's column, `sum(a)` is 8 where two separate scans give 10; with `PERMISSIVE`, the default, and `_corrupt_record` in the schema, the column is populated for a row the narrow scan counted as clean; with `FAILFAST` and a CSV row carrying fewer tokens than the schema has columns, the merged scan throws where the unmerged one returned rows. Those numbers come from the V1 path, which merges all three shapes today and is where SPARK-59107 (#58411) fixes them. The seam defaults to false, because a format that does not merge misses an optimization while a format that merges when its parser is projection-sensitive returns wrong rows. Two further gates keep the contract true of the formats that do declare it. `FileTable` withholds the capability when `spark.sql.files.ignoreCorruptFiles` or `spark.sql.files.ignoreMissingFiles` is set, matching `FileScanRDD.hasStrictFileReads` on the physical side. Under a non-strict read a failure in a column that only the sibling subquery projects is swallowed and the rest of that file's rows go with it, so the merged scan returns fewer rows than the narrow one did. Measured on parquet, V1 and V2 alike: `SELECT (SELECT sum(a) FROM t), (SELECT count(b) FROM t)` over data whose `b` is written as a string and read as a long returns `[45, 0]` with merging off and `[null, 0]` with it on. The gate is evaluated per call rather than cached, so a table built before the configuration was set still answers for the read that is running. `AvroTable` withholds it under `positionalFieldMatching`. `AvroPartitionReaderFactory` builds the deserializer from the pruned read schema while the Avro side stays the full Avro schema, so under that option catalyst field *i* of the projection takes Avro field *i* of that schema, and widening the projection changes the values a column comes back with. That is a bug in its own right, filed as SPARK-59108 (#58409), which lands after this one and deletes this gate with it. ORC needs no equivalent gate: `OrcUtils.requestedColumnIds` maps both the `_col*` case and `orc.force.positional.evolution` through `dataSchema.fieldIndex(name)` and disables pruning in that branch, so its positional path is projection-independent. The rest is a new test suite, a package-private `V2ScanMergingTestHelper` shared with `DSv2PlanMergingSuite`, one test in `AvroV2Suite`, and one documentation update. Landing order: #58411 (SPARK-59107) first, then this one, then #58409 (SPARK-59108), which deletes the `AvroTable` gate. Nothing here depends on the first step, since this PR no longer asserts anything about how V1 merges CSV and JSON. Once #58411 is in, `FileTable.hasStrictFileReads` should call the `FileSourceOptions.hasStrictFileReads` it adds rather than spell the predicate out a third time, beside `FileScanRDD` and the cache-repeatability check in `InMemoryRelation`. ### Why are the changes needed? Two scans of the same file table that differ only in their projected columns cannot be reused today. A file source folds its data filters into the `FileScan` object, where they are used to list files and prune row groups, and `FileScan.equals` compares them, so two subquery scans over the same path are not canonically identical and `PlanMerger`'s identical-plan fast path does not fire. On the V1 path those filters sit in a `Filter` above an identical `LogicalRelation`, so it does fire. Declaring the capability closes most of that gap for the formats where the merge is sound. On TPC-DS at scale factor 100, with `spark.sql.sources.useV1SourceList` cleared, two queries change: q9 goes from 16 distinct scans to 6 under the default configuration and to 2 with `dsv2SymmetricFilterPropagation` on, and q28 from 6 to 1 with that configuration on. Of the 99 v1.4 queries, 95 ran and the other 93 of those are unchanged. Wall clock for q9 went from 78.1s to 40.3s by default and to 21.6s with the configuration on, and q28 from 61.3s to 34.4s; measured on `local[1]` with AQE off, two runs per configuration, against a build with the overrides removed as the baseline. Two samples on one machine put the noise around 20%, so the scan counts are the reproducible part and the timings show the order of magnitude. Both queries read `store_sales`, which is parquet, so leaving CSV and JSON out does not affect these numbers. Four queries could not be measured here because `DataSourceV2Relation.computeStats` raises a testing-only assertion when stats are read before pushdown, and q30 does not run. ### Does this PR introduce _any_ user-facing change? Yes, on the V2 file source read path, which a format reaches only when it is removed from `spark.sql.sources.useV1SourceList`. Plan shape only: subqueries over the same Parquet, ORC, text or Avro table that differ only in their projected columns now collapse into a single scan reading the union of those columns. No query result changes, which is what the exclusions and the two gates above are for, and why this needs no migration-guide entry. The `dsv2SymmetricFilterPropagation` entry in `docs/sql-performance-tuning.md` said "no built-in source does", which this PR falsifies. The four formats and the two withholding rules now sit in the prose of the Merging Subplans section rather than in that entry, since they hold whatever the configuration is set to, and the prose sentence that said the leaves must read the same input is qualified there for a source that declares the capability. Four gaps are left in place. The first three are follow-ups #57360 already lists. 1. Two scans with different partition filters do not merge, while V1 merges them. A partition filter is fully enforced by the V2 scan and reported as strict, so widening it to `OR` would leave the merged scan returning rows nothing above it filters out. 2. Parquet and ORC nested columns do not merge while nested schema pruning is on. Each side narrows the struct to the field it reads, so the read column is no longer a same-type subset of the relation's column. V1 does not merge this shape either, because `SchemaPruning` rewrites each side's `dataSchema` and the two relations stop being canonically equal. 3. Differing data filters need `spark.sql.optimizer.mergeSubplans.filterPropagation.dsv2SymmetricFilterPropagation.enabled`, which defaults to false. On the V2 path that configuration alone is enough, where V1 needs the broader `symmetricFilterPropagation`. 4. CSV and JSON get no merging at all, which is deliberate rather than a follow-up. It costs them two shapes: scans whose projected columns differ, and scans reading the same columns under different filters, since `FileScan.equals` compares the normalized data filters so those are not canonically equal either and do not fall back to plain reuse. The V1 path merges both today and answers `[8, 80]` where two separate scans answer `[10, 80]`, so one subquery's result depends on what a sibling subquery projects. That is a V1 bug rather than a target to copy; SPARK-59107 (#58411) fixes it, and with that in both read paths decline these shapes alike. ### How was this patch tested? New suite `FileSourceV2PlanMergingSuite`, 16 tests. Every built-in file table is checked on the side it belongs to; a non-strict read is checked to withhold the capability on both strictness configurations, and separately to keep its scans separate and return `[45, 0]`, with the table and the temp view built outside the configuration scope so that a cached gate would fail the test. Those are two tests rather than one so that a mutation names which half it broke, the capability assertion having aborted before the rows could report. Scans differing only in projected columns merge for Parquet and ORC and decline for CSV and JSON in the same shape; text merges the one shape a single-column table can differ in, which also needs both aggregates to be hash-aggregatable or `PlanMerger.supportedAggregateMerge` declines above the scans; scans over the same partition filter merge with that filter still enforced on the rebuilt scan; three scans merge into one; differing data filters merge only with the dsv2 configuration on, and the merged scan is checked to carry the OR-widened predicate rather than only to exist. Declines are covered too: differing partition filters, nested-pruned columns, a pushed aggregate (with the aggregate itself asserted, not just the scan count), and two different tables holding different rows so that a cross-table merge would change the answer. Then V1/V2 parity on three shapes and the partition-filter gap pinned as the one shape where they disagree, both on parquet, whose reads this suite pins strict and whose parser does not depend on the projection, so SPARK-59107 leaves them merging on V1. The CSV and JSON parse behaviour is pinned on the V2 side alone, on three shapes: `DROPMALFORMED` gives `[10, 80]`, `PERMISSIVE` with `_corrupt_record` in the schema gives `[0, 80]`, and `FAILFAST` with a short CSV row returns rows rather than throwing. Subquery counts are asserted alongside the rows, so the result is attributed to the decline rather than inferred from the values, and the configurations the expectations depend on are pinned rather than assumed. These three used to assert the V1 numbers beside the V2 ones, and those arms pinned exactly what SPARK-59107 removes, so they are gone; its own suite covers that side. Measured at #58411's head, with this suite copied in: the CSV and JSON test passes there, and so does the parquet partition-filter one. Every test asserts which read path the plan took before asserting anything about merging. SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of `spark.sql.sources.useV1SourceList`, so a suite driven by that configuration alone would quietly run V1 on both sides. One test in `AvroV2Suite`, because `AvroTable` lives in the module that has it on the classpath: it asserts the capability, that `positionalFieldMatching` withholds it, and that two scans differing only in their projected columns fuse into one reading the union. Mutation checks. Turning the three sql/core overrides to `false` fails 8 of the 16 tests; of the 8 that pass, 6 assert a decline, one asserts that V1 merges differing partition filters where V2 does not and V2 declines under the mutation too, and one asserts that a non-strict read keeps its scans separate, which the mutation also produces. Dropping `&& hasStrictFileReads` from `capabilities` fails both strictness tests, the capability one at its first assertion and the rows one with `[null, 0]` against `[45, 0]`, which is what splitting them was for. Regression: the `planmerging` suites, `ExplainSuite` and `ExplainSuiteAE`, `FileBasedDataSourceSuite`, `FileTableSuite`, `OrcV2SchemaPruningSuite`, `ParquetV2SchemaPruningSuite`, `ParquetV2FilterSuite`, `SubquerySuite`, `SameResultSuite`, `ParquetV2AggregatePushDownSuite`, `OrcV2AggregatePushDownSuite`, `DataSourceV2Suite` and `AvroV2Suite`. No golden file or `PlanStabilitySuite` plan needed regenerating. Those build their tables with `CREATE TABLE ... USING <format>`, which resolves to the V1 `FileFormat`, so none of them reaches a V2 file scan. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58340 from LuciferYang/SPARK-57205. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com> (cherry picked from commit 484866b) Signed-off-by: yangjie01 <yangjie01@baidu.com>
### What changes were proposed in this pull request? `PlanMerger` no longer merges two plans that read different sets of columns from a shared V1 file relation when the rows that relation returns depend on which columns the read asked for. `DataSourceUtils.isProjectionSensitiveRead` answers that question for a `HadoopFsRelation`, and `merge` compares, per shared relation and per occurrence of it, the columns each side reads before it tries to merge them. The two records have to match exactly rather than one containing the other: a cache entry's record stays true of it only because every plan merged in read the same columns, so admitting a narrower plan would make the record stale. Reads of the same columns still merge, so plain reuse is untouched. Two things make a read projection-sensitive. Its parser may resolve or validate a column against the set of columns it was asked for, which lets a wider read drop or rewrite rows the narrower one returned, or return different values for a column it was already reading: CSV, JSON and XML build their parser from the required schema and take `mode` and the corrupt-record column from it, and Avro under `positionalFieldMatching` pairs a column with the Avro field at its position in that schema, measured `[10, 100]` merged against `[10, 10]` unmerged. SPARK-59108 (#58409) removes that at the root, which retires the avro arm here along with the `AvroTable` gate #58340 adds on the V2 side; the landing order is below. Or the read may not be strict, in which case a failure in a column that only the wider read touches is swallowed together with the rest of that file's rows, whatever the format. The strictness half is `FileSourceOptions.hasStrictFileReads`, which this PR lifts out of `FileScanRDD` and points the other two spellings of it at, `InMemoryRelation`'s cache-repeatability check and the capability gate #58340 added to `FileTable`. It is evaluated per merge rather than cached, so a relation built before `ignoreCorruptFiles` was set still answers for the read that is running. The formats are named in `DataSourceUtils` rather than declared by each `FileFormat`, the way `SchemaPruning.canPruneDataSchema` names Parquet and ORC. A capability method on `FileFormat` would have to pick a default: "safe to widen" repeats this same list as the overrides, and "not safe" stops merging for every format that does not override, including Hive's ORC reader and every third-party format, for no correctness gain. A third-party format whose parser is projection-sensitive therefore keeps merging, as it does today. #58340 (SPARK-57205), now in master, recorded this V1 behaviour as a gap on its V2 side, where the same four shapes are declined; with this in, the two read paths agree on them. Landing order: this one, then #58409, which deletes the avro arm, the `org.apache.spark.sql.avro` import it needs, and the `AvroV1Suite` test. A branch cannot delete code that is not in master yet, which is the only reason the order matters. Neither direction breaks the build: that test compares the merged answer against the same query with `MergeSubplans` excluded rather than against a literal row, and asserts one column per scan, both of which hold whatever `sum(b)` returns once #58409 changes it, so #58409 landing first leaves the arm over-conservative rather than turning a test red. ### Why are the changes needed? Top-level column pruning for a V1 file source happens in physical planning, from the attributes referenced above the relation (`FileSourceStrategy` computes `readDataColumns` from `filterAttributes ++ projects`), so two `LogicalRelation`s over the same files canonicalize equal whatever each side projects. `PlanMerger`'s identical-plan path therefore reuses one of them and the union of the two column sets is formed one level up, which means one subquery's result can depend on what a sibling subquery projects. Measured on master, where all four shapes merge; each cell holds the two subqueries' values, and the second column is what this head returns: | shape | merged | not merged | |---|---|---| | `mode=DROPMALFORMED`, a record malformed only in `b`: `SELECT (SELECT sum(a) FROM t), (SELECT sum(b) FROM t)` | `[8, 80]` | `[10, 80]` | | `PERMISSIVE` with `_corrupt_record` in the schema: `count(_corrupt_record)` beside `sum(b)` | `[1, 80]` | `[0, 80]` | | `FAILFAST` with a CSV row carrying fewer tokens than the schema has columns | throws | `[10, 80]` | | `spark.sql.files.ignoreCorruptFiles=true`, parquet, `b` written as a string and read as a long: `sum(a)` beside `count(b)` | `[null, 0]` | `[45, 0]` | The first two reproduce for csv, json and xml alike, and the third does too with a format-appropriate malformation, a token count being a CSV notion. The fourth is not about parsing, so it reaches every format. The bug is not new. Measured on the maintenance branches with the same two csv shapes: on `branch-4.2`, where the rule is `catalyst.optimizer.MergeSubplans`, `DROPMALFORMED` answers `[8, 80]` against `[10, 80]` with the rule excluded and `PERMISSIVE` answers `[1, 80]` against `[0, 80]`; on `branch-3.5`, where it is `MergeScalarSubqueries`, the two-subquery query answers `[8, 80]` and `[1, 80]` while the first subquery run on its own answers `10` and `0`. `branch-4.0` and `branch-4.1` carry the same rule as 3.5 and were not run. This fix only reaches back to 4.3, though: from 4.2 down the rule lives in `sql/catalyst`, which cannot see `HadoopFsRelation`, so those branches would need a different seam. ### Does this PR introduce _any_ user-facing change? Yes. A query with two subqueries over the same CSV, JSON or XML relation that project different columns, over an avro relation read with `positionalFieldMatching`, or over any file relation under `ignoreCorruptFiles` or `ignoreMissingFiles`, now returns what two separate scans return, which is what the same query returned before subplan merging learned to merge it. The cost is one extra scan for those shapes. Everything else keeps merging, including two subqueries over the same CSV relation that read the same columns. ### How was this patch tested? New suite `FileSourceV1PlanMergingSuite`, 17 tests. The four shapes above, with `DROPMALFORMED` covered for csv, json and xml alike; a read that is not strict on both configurations, with the temp view built outside the configuration scope so that a cached answer would fail the test; a self join, where each of the two reads of the relation has to be compared on its own; a third subquery that reads a column the merged pair does not, and a fourth that joins the entry the refused third one opened; and six shapes that must keep merging: parquet subqueries projecting different columns, csv subqueries reading the same columns, an identical pair of subqueries, which are reused rather than merged, a third subquery that reads the same columns as the merged pair, a csv read where the two sides differ only in a partition column reference, which is not a column read at all, and a csv read the two sides merge through filter propagation, which rebuilds the projection above the relation. One test in `AvroV1Suite`, because `avro` does not resolve from the `sql/core` test classpath; it pins both strictness configurations rather than inheriting them, so that positional matching is the only reason its read is projection-sensitive, and it takes its expected values from the same query with `MergeSubplans` excluded, because the value `sum(b)` returns is a property of what its own subquery projects and SPARK-59108 changes it. Every test in the suite asserts the columns each `FileSourceScanExec` in the plan reads, rather than a scan count: the count depends on which scans physical reuse hid behind a leaf node, while the columns are the property this change is about. Finding a `FileSourceScanExec` at all is also what pins these tests to the V1 path. Filter propagation is on by default, and it merges two subqueries over such a relation by rebuilding the projection above it. Measured on six query shapes with symmetric propagation on and off, twelve runs in all, the merged answers match the answers with `MergeSubplans` excluded and no scan reads a column neither side asked for, because the rebuilt projection is over the pruned child. One of those shapes is the test above. Beyond the suite, ten shapes with three or four subqueries over one relation, including a self join, two different views, a mix of csv and parquet, the corrupt-record column, a partitioned table and a subquery that reads no column at all, were run with symmetric filter propagation on and off, twenty runs in all: every one returns what the same query returns with `MergeSubplans` excluded. Four mutation checks, each measured rather than inferred. Turning `isProjectionSensitiveRead` to false fails 11 of the 17 here and the avro test as well; the 6 that pass are the ones whose only assertion is that a merge still happens. Removing the avro case alone fails the avro test. Keeping one column set per relation instead of one per occurrence, which is what the first version of this did, fails only the self-join test, with `[84, 84]` where the fix gives `[85, 84]`. Keeping partition columns in the read set fails only the partition-reference test. Deriving the cached side's read set from the merged plan rather than from the record taken when it was cached fails the three-subquery and four-subquery tests, with `[14, 8, 88]` and `[14, 8, 88, 4]` where separate scans give `[18, 10, 88]` and `[18, 10, 88, 4]`. Regression, re-measured after merging master rather than carried over: the whole `planmerging` package, which now includes #58340's `FileSourceV2PlanMergingSuite`, plus `SubquerySuite`, `DataFrameSubquerySuite`, `ReuseExchangeAndSubquerySuite`, `ExplainSuite`, `ExplainSuiteAE`, `FileBasedDataSourceSuite` and `InMemoryColumnarQuerySuite`, 12 suites and 439 tests; the `datasources.csv`, `datasources.json` and `datasources.xml` packages, 19 suites and 1718 tests; `catalyst/scalastyle`, `sql/scalastyle`, `sql/Test/scalastyle` and `avro/Test/scalastyle`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58411 from LuciferYang/SPARK-59107. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com>
### What changes were proposed in this pull request? `PlanMerger` no longer merges two plans that read different sets of columns from a shared V1 file relation when the rows that relation returns depend on which columns the read asked for. `DataSourceUtils.isProjectionSensitiveRead` answers that question for a `HadoopFsRelation`, and `merge` compares, per shared relation and per occurrence of it, the columns each side reads before it tries to merge them. The two records have to match exactly rather than one containing the other: a cache entry's record stays true of it only because every plan merged in read the same columns, so admitting a narrower plan would make the record stale. Reads of the same columns still merge, so plain reuse is untouched. Two things make a read projection-sensitive. Its parser may resolve or validate a column against the set of columns it was asked for, which lets a wider read drop or rewrite rows the narrower one returned, or return different values for a column it was already reading: CSV, JSON and XML build their parser from the required schema and take `mode` and the corrupt-record column from it, and Avro under `positionalFieldMatching` pairs a column with the Avro field at its position in that schema, measured `[10, 100]` merged against `[10, 10]` unmerged. SPARK-59108 (#58409) removes that at the root, which retires the avro arm here along with the `AvroTable` gate #58340 adds on the V2 side; the landing order is below. Or the read may not be strict, in which case a failure in a column that only the wider read touches is swallowed together with the rest of that file's rows, whatever the format. The strictness half is `FileSourceOptions.hasStrictFileReads`, which this PR lifts out of `FileScanRDD` and points the other two spellings of it at, `InMemoryRelation`'s cache-repeatability check and the capability gate #58340 added to `FileTable`. It is evaluated per merge rather than cached, so a relation built before `ignoreCorruptFiles` was set still answers for the read that is running. The formats are named in `DataSourceUtils` rather than declared by each `FileFormat`, the way `SchemaPruning.canPruneDataSchema` names Parquet and ORC. A capability method on `FileFormat` would have to pick a default: "safe to widen" repeats this same list as the overrides, and "not safe" stops merging for every format that does not override, including Hive's ORC reader and every third-party format, for no correctness gain. A third-party format whose parser is projection-sensitive therefore keeps merging, as it does today. #58340 (SPARK-57205), now in master, recorded this V1 behaviour as a gap on its V2 side, where the same four shapes are declined; with this in, the two read paths agree on them. Landing order: this one, then #58409, which deletes the avro arm, the `org.apache.spark.sql.avro` import it needs, and the `AvroV1Suite` test. A branch cannot delete code that is not in master yet, which is the only reason the order matters. Neither direction breaks the build: that test compares the merged answer against the same query with `MergeSubplans` excluded rather than against a literal row, and asserts one column per scan, both of which hold whatever `sum(b)` returns once #58409 changes it, so #58409 landing first leaves the arm over-conservative rather than turning a test red. ### Why are the changes needed? Top-level column pruning for a V1 file source happens in physical planning, from the attributes referenced above the relation (`FileSourceStrategy` computes `readDataColumns` from `filterAttributes ++ projects`), so two `LogicalRelation`s over the same files canonicalize equal whatever each side projects. `PlanMerger`'s identical-plan path therefore reuses one of them and the union of the two column sets is formed one level up, which means one subquery's result can depend on what a sibling subquery projects. Measured on master, where all four shapes merge; each cell holds the two subqueries' values, and the second column is what this head returns: | shape | merged | not merged | |---|---|---| | `mode=DROPMALFORMED`, a record malformed only in `b`: `SELECT (SELECT sum(a) FROM t), (SELECT sum(b) FROM t)` | `[8, 80]` | `[10, 80]` | | `PERMISSIVE` with `_corrupt_record` in the schema: `count(_corrupt_record)` beside `sum(b)` | `[1, 80]` | `[0, 80]` | | `FAILFAST` with a CSV row carrying fewer tokens than the schema has columns | throws | `[10, 80]` | | `spark.sql.files.ignoreCorruptFiles=true`, parquet, `b` written as a string and read as a long: `sum(a)` beside `count(b)` | `[null, 0]` | `[45, 0]` | The first two reproduce for csv, json and xml alike, and the third does too with a format-appropriate malformation, a token count being a CSV notion. The fourth is not about parsing, so it reaches every format. The bug is not new. Measured on the maintenance branches with the same two csv shapes: on `branch-4.2`, where the rule is `catalyst.optimizer.MergeSubplans`, `DROPMALFORMED` answers `[8, 80]` against `[10, 80]` with the rule excluded and `PERMISSIVE` answers `[1, 80]` against `[0, 80]`; on `branch-3.5`, where it is `MergeScalarSubqueries`, the two-subquery query answers `[8, 80]` and `[1, 80]` while the first subquery run on its own answers `10` and `0`. `branch-4.0` and `branch-4.1` carry the same rule as 3.5 and were not run. This fix only reaches back to 4.3, though: from 4.2 down the rule lives in `sql/catalyst`, which cannot see `HadoopFsRelation`, so those branches would need a different seam. ### Does this PR introduce _any_ user-facing change? Yes. A query with two subqueries over the same CSV, JSON or XML relation that project different columns, over an avro relation read with `positionalFieldMatching`, or over any file relation under `ignoreCorruptFiles` or `ignoreMissingFiles`, now returns what two separate scans return, which is what the same query returned before subplan merging learned to merge it. The cost is one extra scan for those shapes. Everything else keeps merging, including two subqueries over the same CSV relation that read the same columns. ### How was this patch tested? New suite `FileSourceV1PlanMergingSuite`, 17 tests. The four shapes above, with `DROPMALFORMED` covered for csv, json and xml alike; a read that is not strict on both configurations, with the temp view built outside the configuration scope so that a cached answer would fail the test; a self join, where each of the two reads of the relation has to be compared on its own; a third subquery that reads a column the merged pair does not, and a fourth that joins the entry the refused third one opened; and six shapes that must keep merging: parquet subqueries projecting different columns, csv subqueries reading the same columns, an identical pair of subqueries, which are reused rather than merged, a third subquery that reads the same columns as the merged pair, a csv read where the two sides differ only in a partition column reference, which is not a column read at all, and a csv read the two sides merge through filter propagation, which rebuilds the projection above the relation. One test in `AvroV1Suite`, because `avro` does not resolve from the `sql/core` test classpath; it pins both strictness configurations rather than inheriting them, so that positional matching is the only reason its read is projection-sensitive, and it takes its expected values from the same query with `MergeSubplans` excluded, because the value `sum(b)` returns is a property of what its own subquery projects and SPARK-59108 changes it. Every test in the suite asserts the columns each `FileSourceScanExec` in the plan reads, rather than a scan count: the count depends on which scans physical reuse hid behind a leaf node, while the columns are the property this change is about. Finding a `FileSourceScanExec` at all is also what pins these tests to the V1 path. Filter propagation is on by default, and it merges two subqueries over such a relation by rebuilding the projection above it. Measured on six query shapes with symmetric propagation on and off, twelve runs in all, the merged answers match the answers with `MergeSubplans` excluded and no scan reads a column neither side asked for, because the rebuilt projection is over the pruned child. One of those shapes is the test above. Beyond the suite, ten shapes with three or four subqueries over one relation, including a self join, two different views, a mix of csv and parquet, the corrupt-record column, a partitioned table and a subquery that reads no column at all, were run with symmetric filter propagation on and off, twenty runs in all: every one returns what the same query returns with `MergeSubplans` excluded. Four mutation checks, each measured rather than inferred. Turning `isProjectionSensitiveRead` to false fails 11 of the 17 here and the avro test as well; the 6 that pass are the ones whose only assertion is that a merge still happens. Removing the avro case alone fails the avro test. Keeping one column set per relation instead of one per occurrence, which is what the first version of this did, fails only the self-join test, with `[84, 84]` where the fix gives `[85, 84]`. Keeping partition columns in the read set fails only the partition-reference test. Deriving the cached side's read set from the merged plan rather than from the record taken when it was cached fails the three-subquery and four-subquery tests, with `[14, 8, 88]` and `[14, 8, 88, 4]` where separate scans give `[18, 10, 88]` and `[18, 10, 88, 4]`. Regression, re-measured after merging master rather than carried over: the whole `planmerging` package, which now includes #58340's `FileSourceV2PlanMergingSuite`, plus `SubquerySuite`, `DataFrameSubquerySuite`, `ReuseExchangeAndSubquerySuite`, `ExplainSuite`, `ExplainSuiteAE`, `FileBasedDataSourceSuite` and `InMemoryColumnarQuerySuite`, 12 suites and 439 tests; the `datasources.csv`, `datasources.json` and `datasources.xml` packages, 19 suites and 1718 tests; `catalyst/scalastyle`, `sql/scalastyle`, `sql/Test/scalastyle` and `avro/Test/scalastyle`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58411 from LuciferYang/SPARK-59107. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com> (cherry picked from commit b82f987) Signed-off-by: yangjie01 <yangjie01@baidu.com>
There was a problem hiding this comment.
Re-checked through d75fd68e9ae — findings 1-5 all resolved, nothing regressed. All 11 new tests pass at this head, and select("y", "z") now pins the last two-column shape the fix changes.
The merge in this head is what moves the picture: both prerequisites are in master now, #58340 as 484866bd80d and #58411 as b82f9872d1c, and both are on branch-4.x too.
Blocking
- 6.Retire both Avro gates in this commit (new): The description schedules the removal for this commit and both prerequisites have landed, so it is due. It is more than the two predicates:
docs/sql-performance-tuning.mdstates the projection-position behaviour in two places, and that becomes false whether or not the gates come off. [inline:AvroPartitionReaderFactory.scala:110] - 7.The description still calls the two prerequisites open pull requests (new): "Two open pull requests gate avro on this option" and "it should land after them and remove them in one commit" describe the state before the merge. Both are merged, so that paragraph should say what this commit removes rather than what it will have to.
| options.stableIdPrefixForUnionType, | ||
| options.recursiveFieldMaxDepth) | ||
| options.recursiveFieldMaxDepth, | ||
| dataSchema =Some(dataSchema)) |
There was a problem hiding this comment.
Finding 6. Both prerequisites landed while this was open, so "it should land after them and remove them in one commit" now applies to this commit: #58340 is 484866bd80d on master and 69e1cb10f43 on branch-4.x, #58411 is b82f9872d1c and d9176bf31be. The merge at this head brings both in, and the diff still leaves both gates standing. Nothing fails while they stand, which is why it is easy to miss.
The part that makes this more than a tidy-up is the documentation. docs/sql-performance-tuning.md:343 lists "avro read with positionalFieldMatching, which pairs a column with the Avro field at its position in that schema" among the projection-sensitive V1 relations, and :345 ends with "Avro withholds it under positionalFieldMatching, which resolves a column by its position in the projection". Neither sentence survives this fix, since the position is the data schema's. Those two paragraphs have to change in this PR whether or not the predicates come off with them.
The rest, in the order I would do it:
AvroTable.scala:67—supportsScanMergingbecomesoverride protected def supportsScanMerging: Boolean = true, not a deleted override.FileTable's default isfalse, so dropping it would take the capability away from Avro V2 altogether. The second paragraph of the comment above it goes, the first one ("Avro has no record-level parse verdict") stays, andAvroOptionsleaves the import.DataSourceUtils.scala:203-205— drop thecase _: AvroFileFormatarm.optionsis then unused, sohasProjectionSensitiveParserloses that parameter and:193becomeshasProjectionSensitiveParser(hs.fileFormat). Theorg.apache.spark.sql.avroimport at:32goes with it, and the doc sentence at:179-181loses the Avro clause together with its "(SPARK-59108, which removes that at the root, so this case goes with it)".AvroSuite.scala:3873— theAvroV1Suitetest "SPARK-59107: positionalFieldMatching makes an avro read projection-sensitive" goes. ItsscanColumns === Seq(Seq("a"), Seq("b"))fails once the arm is gone. That is the one piece of this CI catches for you; everything above it is silent.AvroSuite.scala:4128-4130— the!positional.capabilities().contains(SCAN_MERGING)assertion in the SPARK-57205 test, plus the first two sentences of its comment. Keep thewithSQLConfblock and thehasStrictFileReadssentence.
FileTable's class doc names the same shape ("neither does one that resolves a column by its position in the projection") as a general statement of the contract, so that one can stay.
Both prerequisites are also on branch-4.x, so a backport of this fix has to carry the same removal there.
cloud-fan
left a comment
There was a problem hiding this comment.
Review summary
The projection-independent reader fix is sound for unique names, but supported duplicate schema names can collapse to one ordinal and return wrong values. The temporary V1/V2 scan-merging gates, their tests, and the performance guide must also be updated now that both prerequisites have landed; that issue is already covered by an active current-head thread. Two changed comments need minor grammar fixes.
Findings
4 total: 0 P0, 0 P1, 2 P2, 2 P3.
Non-blocking (P2)
- Preserve ordinals when the data schema has duplicate names —
sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:478— see inline. - Remove obsolete projection-sensitivity gates and documentation —
connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroPartitionReaderFactory.scala:110— remaining in an existing discussion.
Nit (P3)
- Fix the malformed Scaladoc sentence —
sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:51— see inline. - Delimit the two pushdown conditions —
connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala:1762— see inline.
Already raised
- Remaining: Remove obsolete projection-sensitivity gates and documentation — P2 at
connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroPartitionReaderFactory.scala:110— existing discussion
PR metadata suggestions
- Update the prerequisite paragraph to state that #58340 and #58411 have landed and that this PR removes their V1/V2 gates, obsolete tests, and stale performance-guide claims; the pinned body still calls them open and describes a future landing order.
- After preserving duplicate-name ordinals, add the supported duplicate-name positional-read case and its V1/V2 regression coverage to the change and testing descriptions so the claimed full-data-schema mapping is accurate.
| */ | ||
| private def positionsInDataSchema(projection: StructType): Array[Int] = dataSchema match { | ||
| case Some(schema) if positionalFieldMatch => | ||
| projection.map(field => schema.fieldIndex(field.name)).toArray |
There was a problem hiding this comment.
Non-blocking (P2): [P2] Preserve ordinals when the data schema has duplicate names
schema.fieldIndex(field.name) cannot distinguish duplicate field-name occurrences. Spark permits duplicate file-source schema names, so with positionalFieldMatching=true a two-field schema such as (x, x) maps both fields to one Avro ordinal and can silently duplicate a value, or fail when the physical types differ. Please carry each required field's source ordinal from the scan-pruning boundary into the deserializer and add V1 and V2 regression coverage with duplicate names and distinct values.
Recommended change: Preserve occurrence-specific source ordinals at the scan-pruning boundary and pass them into Avro deserialization instead of reconstructing them from StructField names.
Why this works: Capture the ordinal mapping while the pruned attributes still distinguish duplicate occurrences, thread it through the V1 and V2 reader construction paths, and let AvroSchemaHelper consume those ordinals directly.
Scope: Avro V1/V2 scan-pruning and reader/deserializer plumbing, plus focused duplicate-name regression tests in the Avro module.
Compatibility: Unique-name schemas and name-based matching retain their current behavior; supported duplicate-name positional reads are corrected to return distinct physical fields.
Risks: Ordinal plumbing could diverge between the V1 and V2 construction paths. Capturing positions after occurrence identity has already been reduced to StructField names would preserve the bug.
Constraints: Keep nested-record matching on explicit local positions. Do not change default name-based matching or broaden the separate recursiveFieldMaxDepth limitation.
Success: V1 and V2 positional reads return distinct physical values for duplicate-name user-schema fields, with regression coverage proving the mapping and no change to existing unique-name behavior.
| * | ||
| * @param dataSchema The schema `rootCatalystType` was projected from, for a read that prunes | ||
| * columns. A positional field match pairs a Catalyst field with the Avro field | ||
| * at the same position, and the position that means is the one in the full |
There was a problem hiding this comment.
Nit (P3): [P3] Fix the malformed Scaladoc sentence
the position that means is the one is not grammatical and obscures which ordinal is being described. Please change it to something like that position is the one in the full schema rather than in the projection.
| checkAnswer(df.select("z", "x"), rows.map(r => Row(r.get(2), r.get(0)))) | ||
| checkAnswer(df.select("y", "z"), rows.map(r => Row(r.get(1), r.get(2)))) | ||
| checkAnswer(df.selectExpr("sum(z)"), Row(100000L)) | ||
| // With pushdown on the filter runs inside the deserializer, with it off above the scan. |
There was a problem hiding this comment.
Nit (P3): [P3] Delimit the two pushdown conditions
As written, pushdown on the filter initially parses as a single phrase. Please write: With pushdown on, the filter runs inside the deserializer; with it off, the filter runs above the scan.
LuciferYang
commented
Sep 3, 2026
Thanks, both of you. Retiring the gates (@peter-toth's 6, and cloud-fan's P2 on the same thread). Done in the order you gave. Duplicate names (cloud-fan's P2 at
Nits. Both taken: The description now says what this commit removes rather than what it will have to, and names the two commits the prerequisites landed as. It does not claim duplicate-name coverage, for the reason above. A backport carries the same removal wherever both prerequisites are, which today is |
cloud-fan
left a comment
There was a problem hiding this comment.
Review summary
One existing P2 remains after narrowing it to the supported V1 schema(StructType) path, and one new P2 asks for option-specific positive plan-and-result coverage after the V1 and V2 scan-merging gates were removed.
Findings
2 total: 0 P0, 0 P1, 2 P2, 0 P3.
Non-blocking (P2)
- Preserve duplicate field occurrences on the V1 StructType path —
sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:478
The existing duplicate-name discussion remains valid on the narrower V1schema(StructType)path: both occurrences are resolved withfieldIndex, so(x, x)maps to[1, 1]and returns the second physical value twice. Preserve occurrence-specific ordinals and add a focused V1 regression with distinct values; V2 rejects duplicate names before Avro and needs no duplicate-schema test. - Keep positive positional scan-merging coverage for both read paths —
General
Please add positivepositionalFieldMatchingscan-merging regressions inAvroV1SuiteandAvroV2Suite. Each should run two scalar aggregates over different columns, assert the literal result, and assert one widened scan (FileSourceScanExecfor V1 andDataSourceV2ScanRelationfor V2); the V2 case should also assert that the positional table advertisesSCAN_MERGING. This keeps both the option-specific eligibility change and widened-read correctness observable.
Re-review status
4 addressed, 0 remaining, 2 new to this AI review.
New attribution: 0 newly introduced, 1 late catch, 0 previously raised, 1 unattributed.
Remaining findings
No prior AI findings remain.
There was a problem hiding this comment.
Re-checked through 9a7cde1ea1e — findings 6 and 7 resolved, nothing regressed. Both gates are gone, AvroTable.supportsScanMerging is an unconditional true rather than a deleted override, both docs/sql-performance-tuning.md sentences went with them, and the description says what this commit removes. The 13 SPARK-59108 cases pass at this head.
On the duplicate-name thread at r3913900478: your refutation holds and I would not add the assertion. DataFrameReader.schema(String) is schema(StructType.fromDDL(schemaString)) (sql/api/src/main/scala/org/apache/spark/sql/DataFrameReader.scala:77), so the four measurements you ran already are the schema(StructType) path. The rejection is on the data schema itself in both readers, DataSource.scala:462 for V1 and FileTable.scala:101 for V2, so nothing with two occurrences of a name can reach positionsInDataSchema.
Non-blocking
- 8.The two new merge tests pass with the position mapping removed (new): Both write a two-column file and read both columns, so the merged projection is the whole data schema and the mapping is the identity. Measured: with
positionsInDataSchemadisabled both still pass. A third column, with the two subqueries over the last two, makes the union a proper subset and pins both halves. inline:connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala:3886
Minor
- 9.The V1 merge test dropped the strictness pin its V2 twin keeps (new):
isProjectionSensitiveReadis also true when the read is not strict, so this test rests onignoreCorruptFilesandignoreMissingFilesbeing off exactly as the V2 one does, and the case it replaces pinned both. inline:connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala:3878 - 10.
AvroTable's comment now answers only half theSCAN_MERGINGcontract (new):FileTablenames two disqualifiers and the surviving paragraph answers one of them. The positional half is the one three PRs went into making true, and the removed paragraph was the only place that said so. inline:connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroTable.scala:59
| val df = sql(query) | ||
| checkAnswer(df, unmerged) | ||
| val df = sql("SELECT (SELECT sum(a) FROM t), (SELECT sum(b) FROM t)") | ||
| checkAnswer(df, Row(10L, 100L)) |
There was a problem hiding this comment.
Finding 8. This test and its AvroV2Suite twin pass with the position mapping removed, so between them they pin the gate removal but not the property that makes it safe.
The file has two columns and the query reads both, so the merged projection is the whole data schema. positionsInDataSchema then returns [0, 1], which is exactly what avroPosition computes when the array is empty, so this query runs the same code either way. Measured in a worktree at this head with the guard in positionsInDataSchema forced false: both cases still pass, 2/2.
A third column fixes it, because the union of the two subqueries is then a proper subset of the data schema:
spark.range(0, 5).selectExpr("id AS a", "id * 10 AS b", "id * 100 AS c")
.write.format("avro").save(path)
withTempView("t") {
spark.read.option("positionalFieldMatching", true.toString).format("avro").load(path)
.createOrReplaceTempView("t")
valdf= sql("SELECT (SELECT sum(b) FROM t), (SELECT sum(c) FROM t)")
checkAnswer(df, Row(100L, 1000L))with assert(scanColumns === Seq(Seq("b", "c"))) below, and the same edit on the V2 side. b and c sit at data schema positions 1 and 2, so the merged read has to resolve against the data schema to answer [100, 1000]. I ran that version both ways: with the mapping off both paths answer [10, 100] and fail, with it on both pass. The scan assertion still fails on base for the same reason the current one does, since the gate blocks merging whatever the column count.
| // They share one now, and the values are the file's either way because each column resolves | ||
| // against the data schema. AQE off because `AdaptiveSparkPlanExec` is a leaf node, so with it | ||
| // on the scan underneath is not reachable from the executed plan. | ||
| withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { |
There was a problem hiding this comment.
Finding 9.isProjectionSensitiveRead has two arms, !hasStrictFileReads and hasProjectionSensitiveParser, so this test needs the reads to be strict for the same reason the AvroV2Suite twin does. That one pins it and says why; this one inherits it, and the AvroV1Suite case it replaces pinned both.
withSQLConf(
SQLConf.IGNORE_CORRUPT_FILES.key ->"false",
SQLConf.IGNORE_MISSING_FILES.key ->"false",
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key ->"false") {It would fail loudly rather than pass quietly if a default moved, so this is only about not depending on a default the twin already declines to depend on.
| // it leniently so a malformed value still fails where Avro reports it rather than here. | ||
| override protected def supportsScanMerging: Boolean = | ||
| !"true".equalsIgnoreCase(options.get(AvroOptions.POSITIONAL_FIELD_MATCHING)) | ||
| override protected def supportsScanMerging: Boolean = true |
There was a problem hiding this comment.
Finding 10.FileTable's class doc names two things that disqualify a format: a parser that decides what counts as a malformed record from the columns it was asked for, and one that "resolves a column by its position in the projection". The comment above this line answers the first and no longer mentions the second.
The second is the half that took this PR plus #58340 and #58411 to settle, and the paragraph you removed was the only place that recorded it. A reader who then finds positionalFieldMatching has nothing here to say it was considered. One sentence, something like:
// `positionalFieldMatching` resolves a column against its position in the data schema rather// than in the projection (SPARK-59108), so widening the projection does not move a column's// Avro field either.OrcTable leaves orc.force.positional.evolution unsaid, so there is no convention to follow here. Avro's is the case that was the exception until this commit, which is why it is worth the line.
LuciferYang
commented
Sep 3, 2026
Thanks @peter-toth, and thanks for the All three are in at 8. You are right, and this was the gap I talked myself out of. Both cases read every column of a two-column file, so the merged projection was the whole data schema and 9. Pinned, same three configurations as the twin. 10. Added, close to your wording: the comment now says The description carries the new shape and both mutation results. |
peter-toth
left a comment
There was a problem hiding this comment.
Re-checked through 01a676f2575 — findings 8, 9, 10 resolved, nothing new.
The two merge tests are the three-column shape I measured at the previous head, and it discriminates in both directions there: [100, 1000] with the mapping on, [10, 100] with it off. The 13 SPARK-59108 cases pass at this head.
Thanks for working through all of these, @LuciferYang — nothing left open from my side.
uros-b
commented
Sep 3, 2026
Thank you @LuciferYang and @peter-toth! |
cloud-fan
left a comment
There was a problem hiding this comment.
Review summary
The implementation, tests, comments, and performance documentation are consistent with the projection-invariant positional-read contract. Earlier review requests are resolved, and the remaining scanner candidates are either unreachable or immaterial.
Findings
0 total: 0 P0, 0 P1, 0 P2, 0 P3.
No findings.
Re-review status
Prior AI findings: 2 addressed, 0 still present; other current findings: 0.
New attribution: 0 newly introduced, 0 late catch, 0 previously raised, 0 unattributed.
Remaining findings
No prior AI findings remain.
Verification
- V1 DataSource and V2 FileTable reject duplicate names within the data schema before constructing the Avro reader, so occurrence-collapsing duplicate schemas do not reach the changed mapping.
- The V1 and V2 scan-merging tests use a proper-subset union over non-leading columns, so their expected values require mapping through the unpruned data schema as well as removal of the eligibility gates.
### What changes were proposed in this pull request? `AvroDeserializer` now takes the schema its Catalyst schema was projected from, and under `positionalFieldMatching` it resolves a Catalyst field against that field's position in the data schema rather than its position in the projection. `AvroUtils.AvroSchemaHelper` takes the resulting positions; with none it keeps using a field's own position, which is what every caller whose Catalyst schema is not a projection needs (`from_avro`, the write path, the state-store encoder). The three read call sites pass the data schema: `AvroPartitionReaderFactory` on the V2 path, `AvroFileFormat.buildReader` and `AvroFileFormat.readArchive` on V1. A nested record keeps resolving by its own positions, since neither read path prunes nested fields: `FileScanBuilder.supportsNestedSchemaPruning` is false and `AvroScanBuilder` does not override it, and `SchemaPruning.canPruneDataSchema` covers only Parquet and ORC. ORC already does this for `orc.force.positional.evolution`: `OrcUtils.requestedColumnIds` maps the required schema through `dataSchema.fieldIndex(name)`, which makes its positional path projection-independent. Avro decodes the whole record whatever the projection asks for, so nothing extra is read. Two gates kept avro out of scan merging because merging widens the projection, and this commit retires both, since both landed while this was open: #58340 (SPARK-57205, `484866bd80d`) withheld the `SCAN_MERGING` capability from `AvroTable` on the V2 path, and #58411 (SPARK-59107, `b82f9872d1c`) named avro in `DataSourceUtils.isProjectionSensitiveRead` on the V1 path. `AvroTable.supportsScanMerging` becomes an unconditional `true` rather than a deleted override, because `FileTable` defaults it to false and dropping it would take the capability away from Avro V2 altogether; `hasProjectionSensitiveParser` loses its avro arm and, with it, its `options` parameter and the `org.apache.spark.sql.avro` import. Two tests go with them: the `AvroV1Suite` case #58411 added, whose assertion that each subquery keeps its own scan stops being true, and the capability assertion in the `AvroV2Suite` SPARK-57205 case. The documentation has to change whether or not the predicates come off, because it states the old behaviour in two places: `docs/sql-performance-tuning.md` listed avro among the projection-sensitive V1 relations and said Avro withholds the capability under `positionalFieldMatching`, and after this fix the position is the data schema's. `FileTable`'s class doc names the same shape as a general statement of the contract, so that one stays. A backport has to carry the same removal wherever both prerequisites are, which today is `master` and `branch-4.x`. One shape stays broken, with or without this change: `recursiveFieldMaxDepth` makes `SchemaConverters` drop a field it will not recurse into, so the data schema is a gapped view of the Avro schema and positional matching misaligns from the gap onwards. The code records that where the positions are computed. ### Why are the changes needed? With `positionalFieldMatching=true` the deserializer is built from the projected read schema while the Avro side stays the full Avro schema, and `AvroUtils.AvroSchemaHelper.getAvroField` pairs Catalyst field *i* with Avro field *i*, so a column-pruned read takes the wrong Avro field and returns wrong values with no error. Measured on a file whose fields `a`, `b`, `c` hold `id`, `100 * id`, `10000 * id` for ids 0 to 4, read with the option on: ``` sql("SELECT sum(a), sum(b), sum(c) FROM t").show() // 10, 1000, 100000 -- all correct sql("SELECT sum(c) FROM t").show() // 10 -- should be 100000 sql("SELECT sum(b) FROM t").show() // 10 -- should be 1000 sql("SELECT sum(a), sum(c) FROM t").show() // 10, 1000 -- sum(c) should be 100000 ``` Only a projection that is a prefix of the file's field list comes back right, so a column's value depends on which other columns the query selects. Both read paths behave the same way. Whether the failure is silent depends on the types of the mispaired fields: matching types return wrong values, as above, and incompatible ones fail the read with a schema-incompatibility error instead. A pushed filter is evaluated inside the deserializer, so the wrong pairing can also drop rows rather than only return wrong values for them. ### Does this PR introduce _any_ user-facing change? Yes, a bug fix on the Avro read path, both V1 and V2. A read that sets `positionalFieldMatching` and prunes columns now returns the values of the columns it asked for. A query whose projection is a prefix of the Avro field list is unaffected, which is why the option's existing tests need no change. A read that used to land on a type-compatible neighbouring field now pairs with its own field and fails when the two types do not match, so a query that returned values before this change can return an error instead. That is the point of the fix rather than a side effect, but it is the shape most likely to be reported as a regression. The "Cannot find field at position N" message that positional matching raises now names the position it looked for rather than the position within the projection, which are the same number for an unprojected read. Nothing changes when the option is off, which is the default, and nothing changes on the write path or in `from_avro`. ### How was this patch tested? Five new tests in `AvroSuite`, so each runs on both read paths (`AvroV1Suite` and `AvroV2Suite` extend it): the renamed-schema shape from the description, with each one-column and two-column projection whose values the fix changes, the ones it leaves alone being the prefixes of the field list, a pushed filter under both settings of `spark.sql.avro.filterPushdown.enabled`, `count(1)`, and mixed-case names under both case-sensitivity settings; a partition column sitting between two data columns in the schema; a nested record, which must keep resolving by its own positions, together with the `avroSchema` option supplying the Avro side; a projection that reaches past the end of the Avro schema, which reads null; and a mispaired type, which fails the read rather than returning a neighbouring field's values. One test in `AvroSchemaHelperSuite` for the helper itself, and one in `AvroArchiveReadBase`, which runs in the tar, zip and 7z suites, because the archive reader builds its own deserializer per entry. Two more tests, one in `AvroV1Suite` and one in `AvroV2Suite`, for the shape the removed gates used to decline. The file has three columns and the two scalar aggregates read the last two, so the merged projection is a proper subset of the data schema and the read has to resolve against that schema to answer `[100, 1000]`; the scans are one widened read of both columns, `FileSourceScanExec` on V1 and one canonically distinct `DataSourceV2ScanRelation` on V2, and the V2 case also asserts that the positional table now advertises `SCAN_MERGING`. Both pin the strictness flags, since a non-strict read is projection-sensitive for the other reason. Mutation checks. With the position mapping disabled, all ten of the original `AvroSuite` cases fail (five on each path), so does the archive one, and so do the two new ones, which answer `[10, 100]` where the file has `[100, 1000]`. With the two gates put back instead, the two new ones fail the other way, the V1 one with a scan per column rather than one reading both and the V2 one at the capability assertion. Between the two mutations they pin both halves: that merging is allowed here, and that what makes it safe is the mapping. The two columns in the archive test have different types on purpose, so a wrong pairing fails the read there rather than returning plausible values. Regression, re-measured at this head after the merge and the gate removal: the whole `avro` module, 501 tests, the `planmerging` package, 134 tests, since removing the avro arm touches the shared predicate, and `avro/scalastyle`, `avro/Test/scalastyle`, `sql/scalastyle`, `sql/Test/scalastyle` and `catalyst/scalastyle`. `RocksDBStateEncoderSuite` plus `StateStoreSuite`, 840 tests, were run on the pre-merge head, because the state-store encoder builds an `AvroDeserializer` too and nothing since has touched it. The existing `positionalFieldMatching` tests (SPARK-34365) needed no change, because their projections cover the whole schema. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58409 from LuciferYang/SPARK-59108. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com> (cherry picked from commit c809c28) Signed-off-by: yangjie01 <yangjie01@baidu.com>
LuciferYang
commented
Sep 4, 2026
LuciferYang
commented
Sep 4, 2026
Thanks @cloud-fan@peter-toth@uros-b As #58340 is missing from branch‑4.3, I’ll create a manual PR to merge it into branch‑4.3/branch‑4.2/branch‑4.1 |
cloud-fan
left a comment
There was a problem hiding this comment.
Review summary
The projection-invariant positional mapping, gate removal, documentation, and unique-name coverage are coherent, but the name-based mapping regresses V1's accepted duplicate-schema path. Preserve occurrence-specific ordinals at the V1 scan-to-deserializer boundary and add focused distinct-value coverage; V2 already rejects duplicate names. No tests were run as part of this source review.
Findings
1 total: 0 P0, 0 P1, 1 P2, 0 P3.
Non-blocking (P2)
- Preserve duplicate field occurrences on the V1 explicit-schema path —
sql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:478— see inline.
Re-review status
Prior AI findings: 0 addressed, 0 still present; additional unresolved findings in this review: 1.
New attribution: 0 newly introduced, 1 late catch, 0 previously raised, 0 unattributed.
Remaining prior AI findings
No prior AI findings remain.
Existing discussions
- existing discussion — The gate-removal work is present, but the response's duplicate-schema refutation is contradicted by the pinned V1 DataSource path, which catches duplicate-column validation and proceeds before the changed fieldIndex mapping.
- existing discussion — The merge-test changes are present, but treating schema(String) as proof that V1 rejects duplicate StructTypes overlooks DataSource's warning-and-continue compatibility branch.
- existing discussion — The broad V1/V2 request narrows on the pinned tree: V2 rejects duplicate schema names, but V1 explicit schemas still reach schema.fieldIndex and collapse duplicate occurrences.
PR metadata suggestions
- After fixing the retained V1 duplicate-occurrence case, update the change, user-facing behavior, and testing sections to state that accepted duplicate-name V1 schemas preserve occurrence-specific ordinals and are covered by a distinct-value V1 regression; V2 rejects duplicate data-column names.
| */ | ||
| private def positionsInDataSchema(projection: StructType): Array[Int] = dataSchema match { | ||
| case Some(schema) if positionalFieldMatch => | ||
| projection.map(field => schema.fieldIndex(field.name)).toArray |
There was a problem hiding this comment.
Non-blocking (P2): V1 file reads intentionally catch duplicate-column validation and continue with a warning, so an explicit schema such as (x, x) reaches this lookup. StructType.fieldIndex retains one index per name, which makes both occurrences resolve to the same Avro ordinal: same-typed fields silently return the second value twice, and differing types fail converter construction. Please preserve each required field's source ordinal while the V1 scan boundary can still distinguish occurrences, carry that mapping into top-level deserialization, keep nested records on local ordinals, and add a V1 regression with duplicate names and distinct physical values. V2 rejects duplicate data-column names and needs no duplicate-schema change.
Recommended change: Preserve occurrence-specific source ordinals before V1 required fields are reduced to StructField names, and pass that immutable mapping through the normal and archive V1 reader paths into top-level Avro deserialization.
Why this works: Capture each required attribute's ordinal in the full V1 data schema while duplicate occurrences are still distinguishable; have AvroSchemaHelper consume those ordinals directly at the root, while nested records continue to use their explicit local positions.
Scope: Avro V1 scan-pruning and normal/archive reader-to-deserializer plumbing, plus one focused V1 duplicate-schema regression; no V2 duplicate-schema work.
Compatibility: Unique-name positional reads, default name-based matching, V2 reads, and nested-record local matching retain their current behavior; supported V1 duplicate-name positional reads regain occurrence-correct values.
Risks: Capturing the mapping after attributes have already been reduced to names would preserve the bug. The normal and archive V1 paths could diverge, or root ordinal plumbing could accidentally affect nested-record local matching.
Constraints: Keep nested-record matching on explicit local positions. Do not change default name-based matching or broaden the recursiveFieldMaxDepth limitation. Do not add V2 duplicate-schema plumbing or coverage because V2 rejects duplicate data-column names.
Success: A V1 positional read with two same-named explicit-schema fields and distinct physical values returns the two distinct values in order on both applicable V1 reader paths, while existing unique-name and nested-record tests remain unchanged.
LuciferYang
commented
Sep 4, 2026
Thanks for staying on this, @cloud-fan, and you are right about the branch: As I read it there are two checks on the V1 path, looking at different things. The warn-and-continue one checks The tolerated case seems unable to leave two occurrences behind, because of Two places did look like they could bite, and I think your instinct about the weak check is right about one of them. Streaming is where a duplicate really does travel on it: So as far as I can tell |
cloud-fan
commented
Sep 4, 2026
Thanks for the detailed trace. Your reading is correct: the warning path only tolerates data/partition overlap, while duplicates within |
…ning ### What changes were proposed in this pull request? Backport of `c809c283c2d` (#58409) to `branch-4.3`. The Avro fix and the V1 half of the gate removal come over as they landed; the V2 half is dropped, for the reason in the fourth paragraph. `AvroDeserializer` now takes the schema its Catalyst schema was projected from, and under `positionalFieldMatching` it resolves a Catalyst field against that field's position in the data schema rather than its position in the projection. `AvroUtils.AvroSchemaHelper` takes the resulting positions; with none it keeps using a field's own position, which is what every caller whose Catalyst schema is not a projection needs (`from_avro`, the write path, the state-store encoder). The three read call sites pass the data schema: `AvroPartitionReaderFactory` on the V2 path, `AvroFileFormat.buildReader` and `AvroFileFormat.readArchive` on V1. A nested record keeps resolving by its own positions, since neither read path prunes nested fields: `FileScanBuilder.supportsNestedSchemaPruning` is false and `AvroScanBuilder` does not override it, and `SchemaPruning.canPruneDataSchema` covers only Parquet and ORC. ORC already does this for `orc.force.positional.evolution`: `OrcUtils.requestedColumnIds` maps the required schema through `dataSchema.fieldIndex(name)`, which makes its positional path projection-independent. Avro decodes the whole record whatever the projection asks for, so nothing extra is read. One gate kept avro out of V1 scan merging because merging widens the projection, and this commit retires it: #58411 (SPARK-59107, on this branch as `04cdb66c83d`) named avro in `DataSourceUtils.isProjectionSensitiveRead`. `hasProjectionSensitiveParser` loses its avro arm and, with it, its `options` parameter and the `org.apache.spark.sql.avro` import; the `AvroV1Suite` case that PR added goes too, since its assertion that each subquery keeps its own scan stops being true. `docs/sql-performance-tuning.md` no longer lists avro among the projection-sensitive V1 relations, which it has to stop doing whether or not the predicate comes off, because after this fix the position is the data schema's. The V2 half is not here. SPARK-57205 (#58340) is not on `branch-4.3`, so `AvroTable` has no `supportsScanMerging` to turn on, the `AvroV2Suite` capability case it added does not exist, the performance guide has no V2 paragraph, and the V2 twin of the new merge test cannot hold on a branch where avro never declares `SCAN_MERGING`. Those four hunks are the whole difference from the master commit. One shape stays broken, with or without this change: `recursiveFieldMaxDepth` makes `SchemaConverters` drop a field it will not recurse into, so the data schema is a gapped view of the Avro schema and positional matching misaligns from the gap onwards. The code records that where the positions are computed. ### Why are the changes needed? With `positionalFieldMatching=true` the deserializer is built from the projected read schema while the Avro side stays the full Avro schema, and `AvroUtils.AvroSchemaHelper.getAvroField` pairs Catalyst field *i* with Avro field *i*, so a column-pruned read takes the wrong Avro field and returns wrong values with no error. Measured on a file whose fields `a`, `b`, `c` hold `id`, `100 * id`, `10000 * id` for ids 0 to 4, read with the option on: ``` sql("SELECT sum(a), sum(b), sum(c) FROM t").show() // 10, 1000, 100000 -- all correct sql("SELECT sum(c) FROM t").show() // 10 -- should be 100000 sql("SELECT sum(b) FROM t").show() // 10 -- should be 1000 sql("SELECT sum(a), sum(c) FROM t").show() // 10, 1000 -- sum(c) should be 100000 ``` Only a projection that is a prefix of the file's field list comes back right, so a column's value depends on which other columns the query selects. Both read paths behave the same way. Whether the failure is silent depends on the types of the mispaired fields: matching types return wrong values, as above, and incompatible ones fail the read with a schema-incompatibility error instead. A pushed filter is evaluated inside the deserializer, so the wrong pairing can also drop rows rather than only return wrong values for them. ### Does this PR introduce _any_ user-facing change? Yes, a bug fix on the Avro read path, both V1 and V2, and 4.3.0 shipped the bug: positional matching has resolved against the projection since 3.2.0 (SPARK-34365). A read that sets `positionalFieldMatching` and prunes columns now returns the values of the columns it asked for. A query whose projection is a prefix of the Avro field list is unaffected, which is why the option's existing tests need no change. A read that used to land on a type-compatible neighbouring field now pairs with its own field and fails when the two types do not match, so a query that returned values before this change can return an error instead. That is the point of the fix rather than a side effect, but it is the shape most likely to be reported as a regression. The "Cannot find field at position N" message that positional matching raises now names the position it looked for rather than the position within the projection, which are the same number for an unprojected read. Nothing changes when the option is off, which is the default, and nothing changes on the write path or in `from_avro`. ### How was this patch tested? Five new tests in `AvroSuite`, so each runs on both read paths (`AvroV1Suite` and `AvroV2Suite` extend it): the renamed-schema shape from the description, with each one-column and two-column projection whose values the fix changes, the ones it leaves alone being the prefixes of the field list, a pushed filter under both settings of `spark.sql.avro.filterPushdown.enabled`, `count(1)`, and mixed-case names under both case-sensitivity settings; a partition column sitting between two data columns in the schema; a nested record, which must keep resolving by its own positions, together with the `avroSchema` option supplying the Avro side; a projection that reaches past the end of the Avro schema, which reads null; and a mispaired type, which fails the read rather than returning a neighbouring field's values. One test in `AvroSchemaHelperSuite` for the helper itself, and one in `AvroArchiveReadBase`, which runs in the tar, zip and 7z suites, because the archive reader builds its own deserializer per entry. One more test, in `AvroV1Suite`, for the shape the removed gate used to decline. The file has three columns and the two scalar aggregates read the last two, so the merged projection is a proper subset of the data schema and the read has to resolve against that schema to answer `[100, 1000]`; the scans are one widened `FileSourceScanExec` reading both columns. It pins the strictness flags, since a non-strict read is projection-sensitive for the other reason. Master's `AvroV2Suite` twin is not here, for the reason above. Mutation check, measured on this branch: with the position mapping disabled, 14 cases fail, the five `SPARK-59108` shapes on each read path, the archive one in each of the tar, zip and 7z suites, and the new merge test, which answers `[10, 100]` where the file has `[100, 1000]`. The two columns in the archive test have different types on purpose, so a wrong pairing fails the read there rather than returning plausible values. Regression, measured on this branch: the whole `avro` module, 499 tests, the `planmerging` package, 108 tests, since removing the avro arm touches the shared predicate, and `avro/scalastyle`, `avro/Test/scalastyle`, `sql/scalastyle` and `catalyst/scalastyle`. `RocksDBStateEncoderSuite` and `StateStoreSuite`, which also build an `AvroDeserializer`, were run on master rather than here; nothing in this backport differs from the master commit in that path. The existing `positionalFieldMatching` tests (SPARK-34365) needed no change, because their projections cover the whole schema. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58513 from LuciferYang/SPARK-59108-4.3. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com>
What changes were proposed in this pull request?
AvroDeserializernow takes the schema its Catalyst schema was projected from, and underpositionalFieldMatchingit resolves a Catalyst field against that field's position in the data schema rather than its position in the projection.AvroUtils.AvroSchemaHelpertakes the resulting positions; with none it keeps using a field's own position, which is what every caller whose Catalyst schema is not a projection needs (from_avro, the write path, the state-store encoder).The three read call sites pass the data schema:
AvroPartitionReaderFactoryon the V2 path,AvroFileFormat.buildReaderandAvroFileFormat.readArchiveon V1. A nested record keeps resolving by its own positions, since neither read path prunes nested fields:FileScanBuilder.supportsNestedSchemaPruningis false andAvroScanBuilderdoes not override it, andSchemaPruning.canPruneDataSchemacovers only Parquet and ORC.ORC already does this for
orc.force.positional.evolution:OrcUtils.requestedColumnIdsmaps the required schema throughdataSchema.fieldIndex(name), which makes its positional path projection-independent. Avro decodes the whole record whatever the projection asks for, so nothing extra is read.Two gates kept avro out of scan merging because merging widens the projection, and this commit retires both, since both landed while this was open: #58340 (SPARK-57205,
484866bd80d) withheld theSCAN_MERGINGcapability fromAvroTableon the V2 path, and #58411 (SPARK-59107,b82f9872d1c) named avro inDataSourceUtils.isProjectionSensitiveReadon the V1 path.AvroTable.supportsScanMergingbecomes an unconditionaltruerather than a deleted override, becauseFileTabledefaults it to false and dropping it would take the capability away from Avro V2 altogether;hasProjectionSensitiveParserloses its avro arm and, with it, itsoptionsparameter and theorg.apache.spark.sql.avroimport. Two tests go with them: theAvroV1Suitecase #58411 added, whose assertion that each subquery keeps its own scan stops being true, and the capability assertion in theAvroV2SuiteSPARK-57205 case. The documentation has to change whether or not the predicates come off, because it states the old behaviour in two places:docs/sql-performance-tuning.mdlisted avro among the projection-sensitive V1 relations and said Avro withholds the capability underpositionalFieldMatching, and after this fix the position is the data schema's.FileTable's class doc names the same shape as a general statement of the contract, so that one stays.A backport has to carry the same removal wherever both prerequisites are, which today is
masterandbranch-4.x.One shape stays broken, with or without this change:
recursiveFieldMaxDepthmakesSchemaConvertersdrop a field it will not recurse into, so the data schema is a gapped view of the Avro schema and positional matching misaligns from the gap onwards. The code records that where the positions are computed.Why are the changes needed?
With
positionalFieldMatching=truethe deserializer is built from the projected read schema while the Avro side stays the full Avro schema, andAvroUtils.AvroSchemaHelper.getAvroFieldpairs Catalyst field i with Avro field i, so a column-pruned read takes the wrong Avro field and returns wrong values with no error. Measured on a file whose fieldsa,b,choldid,100 * id,10000 * idfor ids 0 to 4, read with the option on:Only a projection that is a prefix of the file's field list comes back right, so a column's value depends on which other columns the query selects. Both read paths behave the same way. Whether the failure is silent depends on the types of the mispaired fields: matching types return wrong values, as above, and incompatible ones fail the read with a schema-incompatibility error instead. A pushed filter is evaluated inside the deserializer, so the wrong pairing can also drop rows rather than only return wrong values for them.
Does this PR introduce any user-facing change?
Yes, a bug fix on the Avro read path, both V1 and V2. A read that sets
positionalFieldMatchingand prunes columns now returns the values of the columns it asked for. A query whose projection is a prefix of the Avro field list is unaffected, which is why the option's existing tests need no change. A read that used to land on a type-compatible neighbouring field now pairs with its own field and fails when the two types do not match, so a query that returned values before this change can return an error instead. That is the point of the fix rather than a side effect, but it is the shape most likely to be reported as a regression. The "Cannot find field at position N" message that positional matching raises now names the position it looked for rather than the position within the projection, which are the same number for an unprojected read. Nothing changes when the option is off, which is the default, and nothing changes on the write path or infrom_avro.How was this patch tested?
Five new tests in
AvroSuite, so each runs on both read paths (AvroV1SuiteandAvroV2Suiteextend it): the renamed-schema shape from the description, with each one-column and two-column projection whose values the fix changes, the ones it leaves alone being the prefixes of the field list, a pushed filter under both settings ofspark.sql.avro.filterPushdown.enabled,count(1), and mixed-case names under both case-sensitivity settings; a partition column sitting between two data columns in the schema; a nested record, which must keep resolving by its own positions, together with theavroSchemaoption supplying the Avro side; a projection that reaches past the end of the Avro schema, which reads null; and a mispaired type, which fails the read rather than returning a neighbouring field's values. One test inAvroSchemaHelperSuitefor the helper itself, and one inAvroArchiveReadBase, which runs in the tar, zip and 7z suites, because the archive reader builds its own deserializer per entry.Two more tests, one in
AvroV1Suiteand one inAvroV2Suite, for the shape the removed gates used to decline. The file has three columns and the two scalar aggregates read the last two, so the merged projection is a proper subset of the data schema and the read has to resolve against that schema to answer[100, 1000]; the scans are one widened read of both columns,FileSourceScanExecon V1 and one canonically distinctDataSourceV2ScanRelationon V2, and the V2 case also asserts that the positional table now advertisesSCAN_MERGING. Both pin the strictness flags, since a non-strict read is projection-sensitive for the other reason.Mutation checks. With the position mapping disabled, all ten of the original
AvroSuitecases fail (five on each path), so does the archive one, and so do the two new ones, which answer[10, 100]where the file has[100, 1000]. With the two gates put back instead, the two new ones fail the other way, the V1 one with a scan per column rather than one reading both and the V2 one at the capability assertion. Between the two mutations they pin both halves: that merging is allowed here, and that what makes it safe is the mapping. The two columns in the archive test have different types on purpose, so a wrong pairing fails the read there rather than returning plausible values.Regression, re-measured at this head after the merge and the gate removal: the whole
avromodule, 501 tests, theplanmergingpackage, 134 tests, since removing the avro arm touches the shared predicate, andavro/scalastyle,avro/Test/scalastyle,sql/scalastyle,sql/Test/scalastyleandcatalyst/scalastyle.RocksDBStateEncoderSuiteplusStateStoreSuite, 840 tests, were run on the pre-merge head, because the state-store encoder builds anAvroDeserializertoo and nothing since has touched it. The existingpositionalFieldMatchingtests (SPARK-34365) needed no change, because their projections cover the whole schema.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code