yyanyy opened a new pull request, #58298:
URL: https://github.com/apache/spark/pull/58298
### What changes were proposed in this pull request?
When `V2TableRefreshUtil` refreshes a `DataSourceV2Relation` in
`ALLOW_NEW_FIELDS` mode, the relation
is now replaced with a `Project` over a relation that exposes the
**current** table schema. The
relation stays aligned with the physical scan, while the projection
recreates the **captured** output
— same names, expression IDs, data types, nullability and metadata — for the
already-analyzed parent
plan.
A new `CapturedSchemaProjection` builds that projection, recursing through
structs, arrays and maps
so a captured nested type can be rebuilt from a wider current one.
When the captured and current outputs already have the same shape, the
relation is returned
untouched, so plans are unchanged whenever the table has not changed. That
check also makes the
rewrite idempotent, since `transformDown` revisits the node it just produced.
#### Alternatives considered
- **Pass the relation's output down to the connector** so it can retain only
the roots Spark still
knows about. Rejected: `relation.output` is a Catalyst planner concept
rather than part of the
connector API, so this introduces a dependency from connectors onto
Spark's logical plan and needs
plumbing through several layers.
- **Widen the scan output instead** — keep every field `Scan.readSchema()`
returns, reusing the
captured `ExprId` where the name is known and a fresh attribute otherwise,
and let the existing
`V2ScanRelationPushDown` projection drop the extras. Smaller diff, but it
changes the shared
`toOutputAttrs`, whose other callers (including the row-level-operation
path) would need auditing
first. Attributes also cannot simply be dropped for unknown names: an
extra physical column in the
middle would shift row ordinals.
- **Interpose the projection at refresh time** (this PR) — done where the
captured output is still
in hand, leaving `toOutputAttrs` and its other callers untouched.
### Why are the changes needed?
Spark refreshes versioned DSv2 tables **between analysis and optimization**,
in
`QueryExecution`. `V2TableRefreshUtil` loads the current table and does:
```scala
r.copy(table = currentTable)
```
which intentionally swaps in the current table/snapshot while preserving the
analyzed output. That
leaves the relation internally inconsistent:
```
relation.output = [id, salary] // captured at analysis
refreshed table = the current version
scan data schema = [id, salary, new_column] // current snapshot
```
Spark then asks the connector to prune to `[id, salary]`. A source that does
not prune all the way
back reports more than that, and `PushDownUtils.pruneColumns` calls:
```scala
scan -> toOutputAttrs(scan.readSchema(), relation)
```
`toOutputAttrs` builds its name map only from the stored `relation.output`,
so:
```scala
a => a.withExprId(nameToAttr(a.name).exprId)
// ^ nameToAttr("new_column") throws
```
```
java.util.NoSuchElementException: key not found: new_column
```
The precise trigger is not "`readSchema` is wider than the query's project".
It is:
```
Scan.readSchema field names are not a subset of relation.output field names.
```
**Reporting more than was requested is permitted.**
`SupportsPushDownRequiredColumns.pruneColumns` states that an implementation
should try its best but
that partial pruning is acceptable, giving as its example a source that
cannot prune nested fields
and only prunes top-level columns. That example alone reproduces the bug,
without the source doing
anything non-compliant:
```scala
sql("CREATE TABLE t (id INT, person STRUCT<name: STRING>) USING ...")
sql("INSERT INTO t VALUES (1, named_struct('name', 'Alice'))")
val df = spark.table("t") // captures id,
person: STRUCT<name>
sql("ALTER TABLE t ADD COLUMN person.age INT FIRST") // the table changes
underneath
df.collect()
```
Spark pushes the captured `person: STRUCT<name>`; a source that prunes
top-level columns only honours
"give me `person`" and reports its current full type, and the query fails
plan validation:
```
[PLAN_VALIDATION_FAILED_RULE_IN_BATCH] Rule ...V2ScanRelationPushDown in
batch Early Filter and
Projection Push-Down generated an invalid plan: The plan output schema has
changed from
STRUCT<id: INT, person: STRUCT<name: STRING>> to
STRUCT<id: INT, person: STRUCT<age: INT, name: STRING>>
```
#### Why this is rarely observed
A one-shot query analyzes and optimizes back to back, so the window between
the captured output and
the refreshed table is normally too small to notice. It becomes
deterministic in two ways, and this
is not specific to Spark Connect — the refresh phase lives in common
`QueryExecution`:
1. Force analysis on a `DataFrame`, change the table, and only then run its
first action:
```scala
val df = spark.table("t").filter("salary < 999")
df.queryExecution.analyzed // analysis only: output = [id, salary]
// external ALTER TABLE ADD COLUMN + write
df.collect() // first optimization: refresh sees
new_column
```
Do not call `df.collect()` before the change in this form — that would
already evaluate and cache
the refresh phase for this `QueryExecution`.
2. Use a temp view created from a `Dataset`, which stores an analyzed plan
rather than SQL text. Each
action builds a new `QueryExecution` from that stored plan, so a baseline
read is still possible
before the change. This is what the tests below use.
#### A second, silent bug
Because `toOutputAttrs` keys by name, a captured metadata column whose name
a new data column has
taken collapses onto a single attribute together with that data column:
```
RelationV2[id#50, index#51, index#51] <- one expression ID, two
read-schema fields
```
`SELECT id, index`, where `index` was the metadata column when the plan was
captured, then returned
`[2, 99]` — the new data column's value — instead of `[2, 0]`, the row index
it asked for. Refresh now
fails with an internal error in that case. Reporting it with a user-facing
message belongs in
`V2TableUtil.validateCapturedMetadataColumns`, which does not detect the
conflict yet because it only
compares the captured metadata columns against the ones the connector still
reports;
`V2TableUtilSuite` pins that gap and a follow-up closes it.
### Does this PR introduce _any_ user-facing change?
Yes, three changes, all relative to `master`:
1. Queries against a source that prunes partially, run after the table
gained a column, previously
failed with `NoSuchElementException` or
`PLAN_VALIDATION_FAILED_RULE_IN_BATCH`. They now succeed
and return the captured schema.
2. A captured metadata column shadowed by a new data column of the same name
previously returned the
data column's values silently; it now raises an error.
3. No change when the table has not changed — the relation is returned
untouched and the plan is
identical.
### How was this patch tested?
New unit tests in `CapturedSchemaProjectionSuite` cover the projection at
the expression level:
structs, arrays of structs, arrays of arrays, map keys and values, three
levels of nested structs, and
a heterogeneous `struct -> map value -> array -> struct` chain — each adding
a field at every level,
with a different ordinal shift per level so a cross-level mixup cannot pass;
null preservation at each
level; case-sensitive and case-insensitive name matching; duplicate field
names; and every rejection
path.
New end-to-end tests in `DataSourceV2DataFrameSuite` use a stored-plan temp
view against a source that
reports every data column from `readSchema()`: flat and nested widening, a
column added first, an
external case-only rename, a metadata column across a wider scan, a metadata
column shadowed by a new
data column, and a self-join.
`V2TableUtilSuite` gains a characterization test pinning the validation
blind spot described above.
**Regression check.** With the rebinding call in `V2TableRefreshUtil`
reverted, 7 of the 8 end-to-end
tests fail:
| Test | Failure without the change |
|---|---|
| reconciles a wider partially-pruned scan with stored temp view output |
`NoSuchElementException: key not found: new_column` |
| recreates a captured nested schema from a partially-pruned scan |
`PLAN_VALIDATION_FAILED_RULE_IN_BATCH`, `STRUCT<id, person: STRUCT<name>>` ->
`STRUCT<id, person: STRUCT<age, name>>` |
| recreates a captured schema nested through a map and an array | same, for
`data: STRUCT<m: MAP<STRING, STRUCT<arr: ARRAY<STRUCT<v>>>>>` |
| preserves a captured metadata column across a wider partially-pruned scan
| `NoSuchElementException: key not found: new_column` |
| restores the captured column order when a column is added first |
`NoSuchElementException: key not found: new_column` |
| restores the captured column name after an external case-only rename |
`Aliases salary#9310 are dangling in the references for plan` |
| rebinds every relation of a self-joined partially-pruned table |
`NoSuchElementException: key not found: new_column` |
The eighth test does not enable partial pruning, so its scan reports only
the captured columns and
`toOutputAttrs` resolves all of them — which is why the failure is specific
to sources that report
names outside the captured output.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code 2.1.246
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]