Repository navigation
Conversation
There was a problem hiding this comment.
Thanks for the PR, @ykisana!
The PR binds the scan's reported ordering to the scan output in DataSourceV2ScanExecBase. It uses ProjectionOverSchema, the same way outputBoundPushedFilters remaps pushed filters. So a sort order on s.y keeps matching a required ordering after nested schema pruning narrows s. The logical ordering stays in relation space, so PlanMerger is unaffected. I checked that the new test fails without the fix (the SortExec stays) and passes with it. One case still reports an ordering that does not hold, see finding 1.
Blocking
- 1. A narrowed struct sort key is still reported: a sort order on a struct that pruning narrows (
sitself, ors.inner) is rebound to the narrowed type and kept. That claims an order the rows don't have, and the new scaladoc says it should giveNone. Nothing uses the claim today, but a scan must not report an ordering that doesn't hold. The fix is to drop a sort order whose type changed, plus a test that pins it. inline:DataSourceV2ScanExecBase.scala:186
Minor
- 2. Stale comment:
V2ScanPartitioningAndOrdering.scala:107-110still limits the match to "when the scan reads the whole struct" and points at SPARK-60011 as the open gap. inline:DataSourceV2ScanExecBase.scala:181
@peter-toth addressed both of these |
There was a problem hiding this comment.
Re-checked through 1c26a916817. Findings 1 and 2 are resolved. A sort order whose type pruning narrows now gives None, and the comment in V2ScanPartitioningAndOrdering is updated. The new test fails on the base, and also with either the sameType check or the FIELD_NOT_FOUND catch removed. Nothing regressed, and the PR merges cleanly onto master.
Minor
- 3. The
outputOrderingscaladoc still says[s]derives the key ordering only whensis pruned (new): a narrowedsderives it too now. inline:DataSourceV2ScanExecBase.scala:180 - 4. The description calls the bound ordering a
lazy val(new):outputBoundOrderingis adef, computed once inside thekeptOrderinglazy val.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the fix, @ykisana. I reviewed the latest commit, 1c26a91, and left 10 inline comments. Rebinding the reported ordering through ProjectionOverSchema fixes the s.y case in the description, and I found no case where the scan now claims an ordering that the rows do not have. Here is a summary, ordered by priority.
Correctness
DataSourceV2ScanExecBaseL196: A reported key whose root name differs in case from the column, such asS.yfors, is not rebound. The stale sort order is kept, so the redundantSortExecstays.DataSourceV2ScanExecBaseL190: This is an existing bug outside this diff.outputBoundPushedFilters, which this code mirrors, has no narrowing check, so a fully pushed predicate on a whole struct is re-typed after nested schema pruning.DataSourceV2ScanExecBaseL202: OnlyFIELD_NOT_FOUNDis caught. An internal error fromProjectionOverSchemanow fails planning when a connector reads a struct column as another type.DataSourceV2ScanExecBaseL181:derivedOrderingis not rebound, so a narrowed struct partition key still falls back to a sort order over the unpruned type.
Tests
KeyGroupedPartitioningSuiteL4193: No test with a partitioned table pins the plan changes for key-grouped scans.KeyGroupedPartitioningSuiteL4206: TheprunedDfcheck also passes on the base, and with one row per split neithercheckAnswercan see the ordering.KeyGroupedPartitioningSuiteL4219: A kept two-level key and theGetArrayStructFieldspath are not covered.
Docs and comments
DataSourceV2ScanExecBaseL187-188: "rows sorted by a struct are not sorted by a narrower one" is too strong. The PR description has the same sentence.
Cleanup
DataSourceV2ScanExecBaseL192: This is the third copy of theProjectionOverSchemaandFIELD_NOT_FOUNDidiom.DataSourceV2ScanExecBaseL193: The projection is built for every scan, even when no ordering is reported.
Item 1 is the most important one for this PR, and items 5 and 6 would pin the new behavior. Item 2 predates this PR, so a separate JIRA may suit it better. The rest are minor.
d7d00ee to
7683fa2
Compare
Updated, 2 and 9 can be follow ups. For 9 might be a more involved refactor. |
|
Follow up tickets from @dongjoon-hyun comments: |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for addressing items 1-10, @ykisana. I reviewed the latest commit, 7683fa2, and left 7 inline comments, continuing the numbering from items 1-10. Items 1, 3-8 and 10 are resolved, and items 2 and 9 are tracked in SPARK-60410 and SPARK-60411. I found no case where the scan now claims an ordering that the rows do not have. Here is a summary, ordered by priority.
Correctness
DataSourceV2ScanExecBaseL210: ThenormalizeExprscall that I suggested in item 1 renames only attributes, so a reported array field in another case, such asarr.Y, still leaves the redundantSortExec.
Tests
DataSourceV2ScanExecBaseL218: No test reaches theNonFatalcatch through anything butFIELD_NOT_FOUND.KeyGroupedPartitioningSuiteL4234: The kept ordering already meets thenonEmptycheck, so no test pins the derived ordering on a struct key that the scan reads whole.
Docs and comments
DataSourceV2ScanExecBaseL206: "MirrorsoutputBoundPushedFilters" no longer holds.DataSourceV2ScanExecBaseL159: The docs ofspark.sql.sources.v2.bucketing.partitionKeyOrdering.enabledand the migration guide do not cover the two cases that nested schema pruning adds.
Cleanup
DataSourceV2ScanExecBaseL223: ThenormalizeExprscall rebuilds anAttributeMapover the whole output on everybindToOutputcall.KeyGroupedPartitioningSuiteL4200: The new tests define the same columns three times, shadow the class-levelcolumns, and have two different localscanhelpers.
All of these are minor. Item 11 is a gap in my own suggestion for item 1, and items 12 and 13 would pin the new NonFatal catch and the rebinding of the derived ordering.
| */ | ||
| private def bindToOutput(e: Expression): Option[Expression] = { | ||
| try { | ||
| val bound = DataSourceStrategy.normalizeExprs(Seq(e), output).head |
There was a problem hiding this comment.
Minor: The normalizeExprs call that I suggested in item 1 renames only attributes. So it fixes a mixed-case root such as S.y, but not a mixed-case field of an array of structs. For a reported arr.Y, ExtractValue keeps the reported spelling in the field (fields(ordinal).copy(name = fieldName), complexTypeExtractors.scala:104), and ProjectionOverSchema copies it back (prunedField.copy(name = a.field.name), ProjectionOverSchema.scala:56). GetArrayStructFields has no canonicalized override, so the field name takes part in semanticEquals, and the bound arr.Y never matches a required arr.y. A struct field is fine, because GetStructField.canonicalized drops name (complexTypeExtractors.scala:222-223), which is why S.y passes. So with ascending("arr.Y") on the arrays table, SELECT id, arr.y ... SORT BY arr.y should still plan a SortExec. I traced this by reading the code and did not run it. It is only a missed optimization, and the same gap exists when the scan reads arr whole. Could you add an arr.Y case, and either take the field name from the output schema after the projection or leave a comment for a follow-up?
| // `FIELD_NOT_FOUND` for a nested field that pruning removed is the expected failure. Any | ||
| // other comes from a source whose `readSchema()` changes a column's type. An ordering is only | ||
| // an optimization, so it is dropped either way. | ||
| case NonFatal(_) => None |
There was a problem hiding this comment.
Minor: No test reaches this catch through anything but FIELD_NOT_FOUND. Changing it back to the round-1 catch, case e: SparkIllegalArgumentException if e.getCondition == "FIELD_NOT_FOUND" => None, still passes the suite. So a later narrowing would bring back the INTERNAL_ERROR from item 3 (ProjectionOverSchema.scala:61 and :109) without a test failing. CustomReportingScan already delegates readSchema() (KeyGroupedPartitioningSuite.scala:10654). Could you let CustomReportingCatalog return s as a string there, report an ordering on s.x, and check that the scan of SELECT count(s) reports no ordering instead of throwing?
| sql(s"INSERT INTO testcat.ns.$table VALUES " + | ||
| "(1, named_struct('x', 1L, 'inner', named_struct('a', 1L, 'b', 2L)))") | ||
| assert(scan(table, "s.inner.b").outputOrdering.isEmpty, table) | ||
| assert(scan(table, "s").outputOrdering.nonEmpty, table) |
There was a problem hiding this comment.
Minor: For the identity(s) tables (j = 1), the kept [s] or [s.inner] already meets this nonEmpty check, since a kept sort order wins over the derived one. So no test pins that the new flatMap(bindToOutput) in derivedOrdering still derives [s ASC] for a struct key that the scan reads whole. A bug that drops every struct-typed key there would still pass: test 2 and the SPARK-59981 tests use only id keys, and the struct-key SPJ tests from L3105 check shuffles and answers, not the ordering. The comment at L4219, "The partitioned tables derive an ordering on s", also describes nothing that these assertions check. Could you add an identity(s) table with no reported ordering and assert orderingNames(scan(table, "s")) == Seq((Seq("s"), Ascending))?
| * sorted by a narrower one. Nested schema pruning keeps an attribute's exprId but narrows its | ||
| * type, so field ordinals are recomputed. Attribute names are first normalized to the output's, | ||
| * since `ProjectionOverSchema` matches them by name and a source may report a key in another | ||
| * case. Mirrors `DataSourceV2ScanRelation.outputBoundPushedFilters`. |
There was a problem hiding this comment.
Nit: "Mirrors DataSourceV2ScanRelation.outputBoundPushedFilters" no longer holds. This method normalizes the names by exprId, drops a narrowed value with sameType, and drops any NonFatal failure. outputBoundPushedFilters (DataSourceV2Relation.scala:298-310) does none of these, and its missing narrowing check is item 2. A reader who trusts the word may take that method as safe for narrowed structs, or copy its FIELD_NOT_FOUND-only catch back here. Could you say something like "Like outputBoundPushedFilters, but it also normalizes the names, drops a narrowed value and drops any failure"?
| * keys in front would stop a kept `[data]` from satisfying a required `[data]`. So the result | ||
| * can depend on the columns a query reads. A report `[s]` derives the key ordering only when `s` | ||
| * is pruned. | ||
| * is pruned, or narrowed by nested schema pruning. |
There was a problem hiding this comment.
Minor: This sentence covers the two new cases, but the user-facing docs of spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled do not. SQLConf.scala:2682-2689 and docs/sql-migration-guide.md:49 list when the key ordering is derived ("starts with a sort order on a column pruned from the scan output or on a partition transform") and which keys are left out ("Partition transforms such as days(ts) or bucket(8, id)"). With this PR, a report that starts with a sort order on a nested field that pruning drops, or on a struct that pruning narrows, also falls back to the key ordering. A key such as identity(s) whose struct pruning narrows is now left out of it. So a reader cannot tell why SELECT id, s.inner.b on an identity(s) table reports no ordering while SELECT id, s does. Could you mention both cases there?
| } | ||
|
|
||
| /** Built only when a sort order or a partition key is bound, which most scans do not have. */ | ||
| @transient private lazy val outputProjection = ProjectionOverSchema(schema, outputSet) |
There was a problem hiding this comment.
Nit: outputProjection is built once, but the normalizeExprs call that I suggested in item 1 builds a new AttributeMap over the whole output on every call (DataSourceStrategy.scala:578), so once per reported sort order and once per partition key. The cost is tiny, as in item 10. Could you build the map once next to this lazy val, e.g. @transient private lazy val outputNames = AttributeMap(output.map(a => a -> a.name)), and rename with e.transform { case a: AttributeReference => a.withName(outputNames.getOrElse(a, a.name)) } at L210?
| test("SPARK-60011: a scan's reported ordering on a nested field after schema pruning") { | ||
| // One split holds every row, so an ordering the scan claims but does not have would show in | ||
| // the answer. `S.y` resolves to `s` case-insensitively and keeps the reported spelling. | ||
| val columns = Array(Column.create("id", LongType), |
There was a problem hiding this comment.
Nit: The same id and s: struct<x, y> columns are defined three times (here, L4253 and nestedItemsColumns in the k-way merge test), and elements at L4244 repeats the struct. Both columns vals shadow the class-level columns at L425, and the columns parameter of scan at L4223 shadows this one too. There are also two local scan helpers with different signatures (L4223 and L4255), and collect(...) { case s: SortExec => s }.isEmpty appears four times. Could you hoist one columns val and one scan helper next to ascending?
What changes were proposed in this pull request?
V2ScanPartitioningAndOrderingresolves a scan'sSupportsReportOrderingordering against the fullDataSourceV2Relation. For a nested sort key such ass.y, the result isGetStructField(s, 1)over the unpruned types: struct<x, y>. When nested schema pruning narrows the scan output tos: struct<y>, the attribute keeps itsExprIdbut changes type, andymoves to ordinal 0.DataSourceV2ScanExecBase.outputOrderingchecks the ordering against the output only byExprId, so the stale expression is kept and never matches a required ordering on the pruneds.This PR binds the reported ordering to the scan output in
DataSourceV2ScanExecBasebeforeoutputOrderinguses it. It usesProjectionOverSchema, which rebinds the attribute to its pruned type and recomputes each field ordinal by name, the same wayDataSourceV2ScanRelation.outputBoundPushedFiltersremaps pushed filters. Attribute names are first normalized to the output's byExprId, sinceProjectionOverSchemamatches attributes by name and a source may report a key such asS.yfor the columns. A field of an array of structs, such asarr.Y, takes its name from the output too, since that name takes part insemanticEquals. A sort order on a nested field that the scan no longer reads (FIELD_NOT_FOUND) is treated like a sort order on a pruned column and ends the kept prefix. So is a sort order whose own type pruning narrows, such as one on the structsitself or ons.inner: rows sorted by a struct are not necessarily sorted by a narrower one. Since the ordering is only an optimization, any other failure to bind, such as the internal errorProjectionOverSchemaraises for a source whosereadSchema()changes a struct column's type, drops the sort order as well instead of failing planning. The ordering derived from the partition keys is bound the same way, so a narrowed struct partition key no longer yields a sort order over the unpruned type. The docs ofspark.sql.sources.v2.bucketing.partitionKeyOrdering.enabledand the migration guide now list both cases. Both orderings are bound once, inside thekeptOrderingandderivedOrderinglazy vals, likeoutputPartitioning, because the planner asks foroutputOrderingmany times.The logical
DataSourceV2ScanRelation.orderingis unchanged and stays in relation space, whichPlanMergerrelies on to compare two scans of the same table.Why are the changes needed?
Follow-up to SPARK-59948, which removed the redundant sort only when the scan reads the whole struct. With
s struct<x: bigint, y: bigint>and a reported ordering ons.y:plans a redundant sort above a scan that already reports that ordering:
Found by @peter-toth while reviewing #59199.
Does this PR introduce any user-facing change?
No. Query results are unchanged. For DSv2 sources that report an ordering on a nested field, the physical plan no longer contains a redundant
SortExecwhen nested schema pruning narrows the struct.How was this patch tested?
Added tests to
KeyGroupedPartitioningSuite. A table withs: struct<x, y>reports an ordering ons.y, or onS.y, with all rows in one split.SELECT id, s.y ... SORT BY s.yhas noSortExecand returns the rows in order. Without the fix, the plan contains theSortExecabove.SELECT id, s.x ... SORT BY s.x, where pruning dropsy, reports no scan ordering, still plans a sort and returns the rows in order ofx.sor ons.inner(also in upper case), with and withoutidentity(s)partitioning, the scan reports no ordering when pruning narrows that struct (SELECT id, s.inner.b), and keeps it when the scan reads the whole struct (SELECT id, s).identity(s)partitioning and no reported ordering, the scan derives[s]when it readsswhole, and nothing when pruning narrowss.s.inner.band anarr.yorarr.Ykey overarr: array<struct<x, y>>remove the sort, and the scan reports no ordering when pruning drops the field.readSchema()returns the structsas a string and that reports an ordering ons.xplansSELECT count(s)with no scan ordering instead of failing withINTERNAL_ERROR.identity(id)partitioning, a reported[s.y]or[s.y, id]gives[id]when pruning dropsy.(id, s.y)over two splits ofid1 written out ofs.yorder plans one k-way merge and noSortExec, and returns every match.Also ran
KeyGroupedPartitioningSuite,MergeSubplansSuite,WriteDistributionAndOrderingSuiteandDataSourceV2Suite.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Fix written by and all changes reviewed by @ykisana. Tests, comments, pr desc by Claude Opus.