Skip to content

[SPARK-59108][SQL] Fix Avro positional matching under column pruning - #58409

Closed
LuciferYang wants to merge 10 commits into
apache:masterfrom
LuciferYang:SPARK-59108
Closed

[SPARK-59108][SQL] Fix Avro positional matching under column pruning#58409
LuciferYang wants to merge 10 commits into
apache:masterfrom
LuciferYang:SPARK-59108

Conversation

@LuciferYang

@LuciferYangLuciferYang commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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

… 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-tothpeter-toth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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). x and (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:1701 reads two Catalyst fields out of test.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 dataSchemaPositions default: Two call sites, both in this file, and you already made AvroDeserializer.dataSchema explicit 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))))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

Thanks @peter-toth, and thanks for re-running it. All five taken.

1. Added select("y", "z"). You are right that the other two are prefixes and pass on base, so the description now says "each one-column and two-column projection whose values the fix changes" and names why the rest are left out, rather than claiming every one.

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: test.avro has twelve Avro fields and the SPARK-34365 test reads two Catalyst fields out of it correctly, so trailing extras are simply ignored. The sentence now says the fields after the gap shift by one whatever the projection is.

5. Default dropped, and the nested call passes Array.empty explicitly.

Locally: the ten AvroSuite cases on both paths, AvroSchemaHelperSuite, AvroSerdeSuite and AvroCatalystDataConversionSuite, 64 tests; avro/scalastyle, avro/Test/scalastyle and sql/scalastyle.

@LuciferYang
LuciferYang marked this pull request as draft August 30, 2026 15:48
@LuciferYang
LuciferYang marked this pull request as ready for review August 30, 2026 19:42
LuciferYang added a commit that referenced this pull request Sep 1, 2026
### 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>
LuciferYang added a commit that referenced this pull request Sep 1, 2026
### 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>
LuciferYang added a commit that referenced this pull request Sep 2, 2026
### 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>
LuciferYang added a commit that referenced this pull request Sep 2, 2026
### 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>

@peter-tothpeter-toth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md states 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:67supportsScanMerging becomes override protected def supportsScanMerging: Boolean = true, not a deleted override. FileTable's default is false, 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, and AvroOptions leaves the import.
  • DataSourceUtils.scala:203-205 — drop the case _: AvroFileFormat arm. options is then unused, so hasProjectionSensitiveParser loses that parameter and :193 becomes hasProjectionSensitiveParser(hs.fileFormat). The org.apache.spark.sql.avro import at :32 goes with it, and the doc sentence at :179-181 loses the Avro clause together with its "(SPARK-59108, which removes that at the root, so this case goes with it)".
  • AvroSuite.scala:3873 — the AvroV1Suite test "SPARK-59107: positionalFieldMatching makes an avro read projection-sensitive" goes. Its scanColumns === 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 the withSQLConf block and the hasStrictFileReads sentence.

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-fancloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 namessql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:478 — see inline.
  • Remove obsolete projection-sensitivity gates and documentationconnector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroPartitionReaderFactory.scala:110 — remaining in an existing discussion.

Nit (P3)

  • Fix the malformed Scaladoc sentencesql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:51 — see inline.
  • Delimit the two pushdown conditionsconnector/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:110existing 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

Thanks, both of you. 2ec57b9c2d1 retires the two gates and takes the two comment fixes; one finding I think is refuted, with the measurement below.

Retiring the gates (@peter-toth's 6, and cloud-fan's P2 on the same thread). Done in the order you gave. AvroTable.supportsScanMerging is an unconditional true rather than a deleted override, since FileTable defaults it to false; hasProjectionSensitiveParser loses its avro arm and with it the options parameter and the org.apache.spark.sql.avro import; the AvroV1Suite case from #58411 and the capability assertion in the AvroV2SuiteSPARK-57205 case are gone. Two things the compiler caught that the list did not: MergeSubplans and FileSourceScanExec became unused imports in AvroSuite with that test, and AvroOptions became one in AvroTable, where the only mention left is prose. The documentation went with them: docs/sql-performance-tuning.md no longer lists avro among the projection-sensitive V1 relations, and the V2 paragraph no longer ends with Avro withholding the capability. FileTable's class doc keeps the shape as a general statement of the contract, as you said.

Duplicate names (cloud-fan's P2 at AvroDeserializer.scala:478) I believe is refuted. The premise is that Spark permits a duplicate-name file-source schema, so I tried to build one on both read paths before writing the ordinal plumbing:

V1, caseSensitive=false: AnalysisException [COLUMN_ALREADY_EXISTS] The column `x` already exists
V2, caseSensitive=false: same
V1, caseSensitive=true: same
V2, caseSensitive=true: same

spark.read.schema("x long, x long") is rejected before the read on all four combinations, so a duplicate-name data schema never reaches the deserializer and fieldIndex has no two occurrences to collapse. The other routes to one are closed too: the Avro spec forbids duplicate field names in a record, so an inferred schema cannot have them; the avroSchema option supplies the Avro side rather than the Catalyst data schema; and x beside X is rejected by the same check when analysis is case-insensitive, while under case-sensitive analysis they are two distinct names that fieldIndex separates, which the mixed-case test already covers. If you would rather have the state ruled out in code as well, I can add a uniqueness assertion where the positions are computed, but I would rather not add a check for a state that cannot arise.

Nits. Both taken: that position is the one in the full schema rather than in the projection, and With pushdown on, the filter runs inside the deserializer; with it off, it runs above the scan.

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 master and branch-4.x.

@cloud-fancloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pathsql/core/src/main/scala/org/apache/spark/sql/avro/AvroDeserializer.scala:478
    The existing duplicate-name discussion remains valid on the narrower V1 schema(StructType) path: both occurrences are resolved with fieldIndex, 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 pathsGeneral
    Please add positive positionalFieldMatching scan-merging regressions in AvroV1Suite and AvroV2Suite. Each should run two scalar aggregates over different columns, assert the literal result, and assert one widened scan (FileSourceScanExec for V1 and DataSourceV2ScanRelation for V2); the V2 case should also assert that the positional table advertises SCAN_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.

@peter-tothpeter-toth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

Thanks @peter-toth, and thanks for the DataFrameReader.schema(String) pointer, that settles the duplicate-name thread better than my measurements did: schema(String) is schema(StructType.fromDDL(...)), so the four runs I posted were already the schema(StructType) path, and the narrowing had nothing left to narrow. No assertion added.

All three are in at 01a676f2575.

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 positionsInDataSchema returned the identity, which is what the empty array already means. Three columns now, with the two subqueries over b and c, so the union is a proper subset and the answer [100, 1000] is only reachable through the mapping. Re-measured both ways at this head: with the mapping disabled both fail with [10, 100], with the gates put back instead the V1 one fails with a scan per column and the V2 one at the capability assertion. So the pair pins both halves now rather than one.

9. Pinned, same three configurations as the twin. isProjectionSensitiveRead is true for a non-strict read whatever the parser does, so the test rested on two defaults its twin already declines to rest on.

10. Added, close to your wording: the comment now says positionalFieldMatching resolves a column against its position in the data schema rather than in the projection, so widening the projection does not move a column's Avro field either. That is the half FileTable names second, and with the paragraph I removed it was unrecorded.

The description carries the new shape and both mutation results.

@peter-tothpeter-toth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Thank you @LuciferYang and @peter-toth!

@cloud-fancloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

LuciferYang added a commit that referenced this pull request Sep 4, 2026
### 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

Copy link
Copy Markdown
ContributorAuthor

Merge Summary:

Posted by merge_spark_pr.py

@LuciferYang

Copy link
Copy Markdown
ContributorAuthor

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-fancloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

Thanks for staying on this, @cloud-fan, and you are right about the branch: getOrInferFileFormatSchema does catch the duplicate-column check and continue with a warning (DataSource.scala:239-245, SPARK-18108 and SPARK-21144). I had not found it, and my earlier replies answered the symptom rather than the mechanism, which is probably why the question kept coming back. Let me lay out what I read this time so you can tell me where it is wrong.

As I read it there are two checks on the V1 path, looking at different things. The warn-and-continue one checks (dataSchema ++ partitionSchema).map(_.name), so what it tolerates is a name shared between the data schema and the partition schema. The throwing one is checkSchemaColumnNameDuplication(hs.dataSchema, equality) in resolveRelation, and that is what a duplicate inside the data schema hits. It sits after the relation-building match and applies to its result, so every HadoopFsRelation branch goes through it, the streaming-sink metadata one and the CatalogFileIndex one included, and equality is conf.resolver.

The tolerated case seems unable to leave two occurrences behind, because of DataSource.scala:222-223: a user-specified schema becomes the data schema minus every field whose name matches a partition column, so the field that made the concatenation ambiguous is the one that gets removed, and if the duplicated name is the partition column's own then both copies go. Printing the resolved relation's two schemas:

user schema (a, p), partition column p: loaded, data=a partition=p, rows=[2,2],[0,0],[1,1]
user schema (p, p), partition column p: loaded, data=<empty> partition=p, rows=[2],[0],[1]
user schema (x, x), no partition column: AnalysisException [COLUMN_ALREADY_EXISTS] The column `x` already exists

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: readStream.schema("x int, x int") survives stream setup, since sourceSchema goes through getOrInferFileFormatSchema. FileStreamSource.getBatch then builds each micro-batch with resolveRelation(checkFilesExist = false, readOnly = true), so the query fails at first-batch planning rather than at analysis, which is worth its own JIRA even though no relation reaches buildReader. The other is HiveMetastoreCatalog.convertToLogicalRelation, the one construction site that skips the check outright, but it is only ever called with ParquetFileFormat or OrcFileFormat, and a Hive avro table stays a HiveTableRelation and reads through the Hive SerDe.

So as far as I can tell dataSchema.fieldIndex is never asked to choose between two occurrences. You know this code much better than I do, though, so if there is a path I have walked past, please say which one and I will open a follow-up JIRA for the ordinal plumbing, since this PR is already merged and cannot carry the change anyway. A query I can run would help me see it fastest.

@cloud-fan

Copy link
Copy Markdown
Contributor

Thanks for the detailed trace. Your reading is correct: the warning path only tolerates data/partition overlap, while duplicates within dataSchema are rejected before reader construction. I don't see a supported path where dataSchema.fieldIndex must distinguish duplicate occurrences, so my earlier finding was incorrect. The streaming late-failure case is separate from this PR.

LuciferYang added a commit that referenced this pull request Sep 5, 2026
…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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@LuciferYang@uros-b@cloud-fan@peter-toth