-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-58089][SQL][FOLLOWUP] Preserve join extraction semantics #57956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,8 +19,8 @@ package org.apache.spark.sql.execution.datasources | |
|
|
||
| import scala.collection.mutable | ||
|
|
||
| import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeSet, Cast, Expression, | ||
| NamedExpression, SortOrder} | ||
| import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeMap, AttributeSet, Cast, | ||
| Expression, NamedExpression, SortOrder} | ||
| import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression | ||
| import org.apache.spark.sql.catalyst.expressions.variant.VariantGet | ||
| import org.apache.spark.sql.catalyst.plans.LeftExistence | ||
|
|
@@ -105,20 +105,10 @@ import org.apache.spark.sql.types.VariantType | |
| * [[SQLConf.PUSH_VARIANT_INTO_SCAN]] is also enabled) and only fires when a hoistable extraction is | ||
| * present, so non-variant plans are untouched. | ||
| * | ||
| * Cast-error surface: relocating a strict extraction (`variant_get(..., failOnError = true)` or a | ||
| * strict `Cast`) below a `Join` means it is evaluated at the scan on rows the join later eliminates | ||
| * -- so a cast failure can surface for a row the un-hoisted plan would never have cast (here the | ||
| * eliminating rows come from the *other* joined table). This is the same pre-existing trade-off as | ||
| * [[PushVariantIntoScan]] pushing casts below a `Filter`, not a new error class: in both, the | ||
| * strict cast runs before the operator that would have discarded the failing row. This rule only | ||
| * relocates the extraction into a `Project`; [[PushVariantIntoScan]] still does the scan-level | ||
| * materialization and, when [[SQLConf.PUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERROR]] is set (default | ||
| * false), wraps the cast with a per-row cast-error companion slot so the error is only raised when | ||
| * the original expression consumes the failing row. That deferral is provenance-agnostic -- it acts | ||
| * on the relocated extraction regardless of how it reached the `Project` -- so enabling the flag | ||
| * suppresses the join-eliminated-row error exactly as it does the filter-eliminated-row case. With | ||
| * the flag off (the default), the strict cast raises immediately on any failing scanned row, as | ||
| * documented for below-`Filter` pushdown. | ||
| * Cast-error surface: relocating a throwable extraction below a `Join` means it is evaluated at the | ||
| * scan on rows the join later eliminates. Such extractions cross a join only when | ||
| * [[SQLConf.PUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERROR]] is enabled. Expressions not classified as | ||
| * throwable retain the existing behavior, including strict `VariantGet` and `Cast` expressions. | ||
| */ | ||
| object PullOutVariantExtractions extends Rule[LogicalPlan] { | ||
|
|
||
|
|
@@ -177,11 +167,16 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| case _ => false | ||
| } | ||
|
|
||
| private def isJoinHoistable(e: Expression): Boolean = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason to add this new gate? isJoinHoistable gate will not hoist a strict variant_get across a Join unless PUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERROR is set. The ungated behavior is documented between line 108-122, and is more consistent with existing bahaviors. It seems this gate singles out variant for cross a join while allowing the other two cases to raise:
I think it's good that all three have consistent behavior. We also have existing switches:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. I replaced the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 on using throwable, it is cleaner. Minor suggestion, please feel free to ignore. I am a bit worried there is no clear signal for hoist behavior change when someone classifies Cast and VariantGet as throwable in the future. Should we add a unit test to assert those two are throwable and add some comment there?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 4. @qlong's worry that there'd be no signal if That makes @qlong's suggested test worth more than a minor nit, and I'd also add a comment next to |
||
| isHoistable(e) && (!e.throwable || | ||
| SQLConf.get.getConf(SQLConf.PUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERROR)) | ||
| } | ||
|
|
||
| /** | ||
| * Collects hoisted extractions as aliases, de-duplicated by canonical form so a repeated | ||
| * extraction maps to a single output slot. | ||
| */ | ||
| private class ExtractionHoister { | ||
| private class ExtractionHoister(hoistable: Expression => Boolean = isHoistable) { | ||
| private val extracted = mutable.LinkedHashMap.empty[Expression, Alias] | ||
|
|
||
| def aliases: Seq[NamedExpression] = extracted.values.toSeq | ||
|
|
@@ -194,10 +189,14 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
|
|
||
| /** Replaces every hoistable extraction in `e` with a reference to its (new) alias. */ | ||
| def hoist(e: Expression): Expression = e.transformDown { | ||
| case ex if isHoistable(ex) => aliasFor(ex) | ||
| case ex if hoistable(ex) => aliasFor(ex) | ||
| } | ||
| } | ||
|
|
||
| private def extractionHoister(crossesJoin: Boolean): ExtractionHoister = { | ||
| if (crossesJoin) new ExtractionHoister(isJoinHoistable) else new ExtractionHoister | ||
| } | ||
|
|
||
| // Recursively pushes hoisted extraction aliases down through a join tree until each lands in a | ||
| // `Project` directly above a non-join child (the scan side, or the `Filter`/`Project` chain above | ||
| // it that `PhysicalOperation` collapses). Hoisting an extraction into a `Project` above a `Join` | ||
|
|
@@ -299,7 +298,7 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| rightHoister: ExtractionHoister): Seq[NamedExpression] = { | ||
| projectList.map { e => | ||
| e.transformDown { | ||
| case ex if isHoistable(ex) => | ||
| case ex if isJoinHoistable(ex) => | ||
| if (ex.references.subsetOf(leftOutput)) { | ||
| leftHoister.aliasFor(ex) | ||
| } else if (ex.references.subsetOf(rightOutput)) { | ||
|
|
@@ -312,7 +311,7 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| } | ||
|
|
||
| private def rewriteAggregate(agg: Aggregate): LogicalPlan = { | ||
| val hoister = new ExtractionHoister | ||
| val hoister = extractionHoister(agg.child.containsPattern(JOIN)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 6. If the gate is ever made live, this isn't quite the right question. Not worth changing while the gate is inert, but worth a comment if the machinery is being kept for later. |
||
| // Only hoist extractions that sit inside an aggregate function's arguments (or filter). A | ||
| // top-level extraction in `aggregateExpressions` is a grouping-key reference (grouping keys are | ||
| // already pulled out by `PullOutGroupingExpressions`); hoisting it would leave the `Aggregate` | ||
|
|
@@ -388,7 +387,7 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
|
|
||
| private def rewriteSortUnderProject( | ||
| project: Project, projectList: Seq[NamedExpression], sort: Sort): LogicalPlan = { | ||
| val hoister = new ExtractionHoister | ||
| val hoister = extractionHoister(sort.child.containsPattern(JOIN)) | ||
| val newOrder = sort.order.map(hoister.hoist(_).asInstanceOf[SortOrder]) | ||
| if (hoister.isEmpty) { | ||
| project | ||
|
|
@@ -419,7 +418,7 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| // yielding a multi-slot shredded struct with a full-variant slot -- the same shape as a | ||
| // Sort-under-Project whose `v` is also selected. See the class doc. | ||
| private def rewriteBareSort(sort: Sort): LogicalPlan = { | ||
| val hoister = new ExtractionHoister | ||
| val hoister = extractionHoister(sort.child.containsPattern(JOIN)) | ||
| val newOrder = sort.order.map(hoister.hoist(_).asInstanceOf[SortOrder]) | ||
| if (hoister.isEmpty) { | ||
| sort | ||
|
|
@@ -442,16 +441,16 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| project: Project, projectList: Seq[NamedExpression], join: Join): LogicalPlan = { | ||
| val leftOutput = join.left.outputSet | ||
| val rightOutput = join.right.outputSet | ||
| val leftHoister = new ExtractionHoister | ||
| val rightHoister = new ExtractionHoister | ||
| val leftHoister = extractionHoister(crossesJoin = true) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 5. Passing the predicate reads as if the hoister enforced it, which will mislead the next reader. Either construct plain |
||
| val rightHoister = extractionHoister(crossesJoin = true) | ||
|
|
||
| // A single variant extraction references exactly one attribute, hence one join side; route it | ||
| // to that side's hoister. Anything not cleanly on one side is left in place. We hoist from both | ||
| // the join condition (its extractions feed the equi-key comparison) and the Project above the | ||
| // join (its extractions -- e.g. aggregate arguments hoisted here by `rewriteAggregate`, or a | ||
| // user's `SELECT variant_get(...)`), so the pushdown sees them below the join. | ||
| val newCondition = join.condition.map(_.transformDown { | ||
| case ex if isHoistable(ex) => | ||
| case ex if isJoinHoistable(ex) => | ||
| if (ex.references.subsetOf(leftOutput)) { | ||
| leftHoister.aliasFor(ex) | ||
| } else if (ex.references.subsetOf(rightOutput)) { | ||
|
|
@@ -481,7 +480,13 @@ object PullOutVariantExtractions extends Rule[LogicalPlan] { | |
| left = pushSideAliases(join.left, leftHoister.aliases, needed), | ||
| right = pushSideAliases(join.right, rightHoister.aliases, needed), | ||
| condition = newCondition) | ||
| project.copy(projectList = newProjectList, child = newJoin) | ||
| // Outer joins widen attributes on their nullable side. Remap references in the parent | ||
| // Project to the copied Join's actual output so their nullability matches the child. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 on nullablity fix |
||
| val newJoinOutput = AttributeMap(newJoin.output.map(attr => attr -> attr)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 1. I measured this one, because it contradicts your own verification on #57190. What I ran. At I also added a throwaway check over six real SQL queries -- LEFT / RIGHT / FULL OUTER joins, the Why there is nothing to fix. No alias this rule creates can be non-nullable, so the outer join's widening has nothing to widen: That is the conclusion your #57190 review already recorded: " On the test ( So either there is a plan shape I haven't found -- in which case a test on a real query would be worth far more than this one -- or the remap is normalization rather than a fix. If it's normalization I don't object to keeping the code, but I'd drop or relabel the test and the description claim: as written, someone will later read this as a shipped bug fix and may backport it to branch-4.3 / branch-4.x for nothing. And if it is kept, the same normalization is missing in the sibling paths -- |
||
| val remappedProjectList = newProjectList.map(_.transformDown { | ||
| case attr: Attribute => newJoinOutput.getOrElse(attr, attr) | ||
| }.asInstanceOf[NamedExpression]) | ||
| project.copy(projectList = remappedProjectList, child = newJoin) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Finding 2. This paragraph states a rule and then withdraws it: "Such extractions cross a join only when [deferral] is enabled", immediately followed by "Expressions not classified as throwable retain the existing behavior, including strict
VariantGetandCastexpressions" -- which is every extraction this rule hoists. Someone coming here for the cast-error contract finds a sentence that applies to nothing.The paragraph it replaced was written to answer your own review question on #57190, and it is still accurate now that behavior is back to base: it explained why relocating a strict extraction below a
Joinis the same accepted trade-off asPushVariantIntoScanpushing a cast below aFilter, and howPUSH_VARIANT_INTO_SCAN_DEFER_CAST_ERRORinteracts with it. Deleting it loses the record of that decision, which is the thing a future reader will come looking for. I'd restore it and add a sentence saying thethrowablecheck is currently inert and what would make it fire.