dongjoon-hyun commented on code in PR #52185:
URL: https://github.com/apache/spark/pull/52185#discussion_r4459312711
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedMergeIntoTableSuite.scala:
##########
@@ -218,6 +219,253 @@ class GroupBasedMergeIntoTableSuite extends
MergeIntoTableSuiteBase {
}
}
+ test("merge pushes conditional NOT MATCHED BY SOURCE predicates to target") {
Review Comment:
**2. [Low] No test keeps an untouched row in a group that is read**
The two tests that check replaced partitions (this one and the `ON 1 = 0`
one) update or delete every row in the groups that are read. In the other
tests, the in-memory table cannot evaluate the pushed `OR`, so every group is
read.
So no test has a group that is read because of the new condition and also
has a row that matches nothing and must be carried over. A small change to this
test would cover that case. Add `(5, 100, 'hr')` to the target and use this
clause:
```sql
WHEN NOT MATCHED BY SOURCE AND t.dep IN ('hr', 'finance') AND t.salary > 150
THEN
DELETE
```
`BooleanSimplification` turns `D OR (D AND x)` into `D`, so the in-memory
table still prunes `software` and the `cond.sql` assertion stays the same. Then
`(5, 100, 'hr')` must still be in the replaced `hr` partition.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala:
##########
@@ -174,16 +174,29 @@ object RewriteMergeIntoTable extends
RewriteRowLevelCommand with PredicateHelper
readRelation, joinPlan, matchedActions, notMatchedActions,
notMatchedBySourceActions, metadataAttrs, checkCardinality)
- // predicates of the ON condition can be used to filter the target table
(planning & runtime)
+ // predicates that can be used to filter the target table (planning):
+ // - without NOT MATCHED BY SOURCE clauses, target rows that don't satisfy
the ON condition
+ // match no action and are simply carried over, so the ON condition can
filter the target
+ // - if every NOT MATCHED BY SOURCE clause is conditional, a target row
can be skipped only
+ // if it satisfies neither the ON condition nor any of those conditions,
so the filter is
+ // their disjunction; only the target-only part of the ON condition can
take part, as a
+ // disjunction that references the source is not a valid filter for the
target table
+ // - an unconditional NOT MATCHED BY SOURCE clause matches any target row
and disables this
+ val pushableCond = if (notMatchedBySourceActions.isEmpty) {
+ cond
+ } else if (notMatchedBySourceActions.exists(_.condition.isEmpty)) {
+ TrueLiteral
+ } else {
+ val (targetCond, _) = splitTargetPredicates(readRelation, cond)
+ (targetCond +: notMatchedBySourceActions.flatMap(_.condition)).reduce(Or)
Review Comment:
**1. [Medium] DELETE-only MERGE on an unpadded CHAR column deletes too
little**
With this change, a MERGE whose actions are all DELETE can now prune groups
with a CHAR condition that the rest of the plan evaluates differently.
`ApplyCharTypePadding` runs after this rule. It puts read-side padding over
the read relation and remaps the join and `MergeRows` to the padded column. It
does not remap `ReplaceData.condition`. That is because
`ReplaceData.references` is `query.outputSet`
([v2Commands.scala#L400](https://github.com/apache/spark/blob/f2b3c156118b115caf6a382d2d0b017719abbaf8/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/v2Commands.scala#L400)),
and `transformUpWithNewOutput` only remaps attributes in `references`
([QueryPlan.scala#L394-L398](https://github.com/apache/spark/blob/f2b3c156118b115caf6a382d2d0b017719abbaf8/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/QueryPlan.scala#L394-L398)).
Its string comparison pass still pads the literals of that condition
([ApplyCharTypePaddingHelper.scala#L101-L121](https://github.com/apache/spark/blob/f2b3c156118b115caf6a382d2d0b017719abbaf8/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyCha
rTypePaddingHelper.scala#L101-L121)).
So the pushed predicate compares the stored value with a padded literal,
while `MergeRows` compares the padded value with the same literal. A MERGE with
assignments is different: `ResolveRowLevelCommandAssignments` strips the CHAR
metadata first, so both sides compare the stored value, and the pushdown adds
no new difference there.
Here is an example. `dep CHAR(8)` is the partition column, and the rows are
stored without padding, for example because they were written with
`spark.sql.legacy.charVarcharAsString=true` or by another engine:
```sql
-- t: (1, 'hr'), (3, 'finance'); s: (1)
MERGE INTO t USING s
ON t.pk = s.pk AND t.dep IN ('hr', 'finance')
WHEN MATCHED THEN DELETE
WHEN NOT MATCHED BY SOURCE AND t.dep IN ('hr', 'finance') THEN DELETE
```
- **master:** The NOT MATCHED BY SOURCE clause makes the pushed condition
`true`, so every group is read and both rows are deleted.
- **This PR:** The pushed condition is `dep IN ('hr ', 'finance ')`. No
stored partition value matches it, so no group is read and nothing is deleted.
There is no error.
I found this by reading the code and did not run this PR.
On master, the same mismatch already affects DELETE-only MERGE without NOT
MATCHED BY SOURCE clauses, where the ON condition is pushed in the same way. So
the root cause is in `ApplyCharTypePadding` and should be fixed there
separately. Could this PR avoid widening it, for example by keeping
`TrueLiteral` when the new disjunction references a CHAR column? Otherwise, the
PR description's "No change in query results" would need a caveat.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala:
##########
@@ -174,16 +174,29 @@ object RewriteMergeIntoTable extends
RewriteRowLevelCommand with PredicateHelper
readRelation, joinPlan, matchedActions, notMatchedActions,
notMatchedBySourceActions, metadataAttrs, checkCardinality)
- // predicates of the ON condition can be used to filter the target table
(planning & runtime)
+ // predicates that can be used to filter the target table (planning):
+ // - without NOT MATCHED BY SOURCE clauses, target rows that don't satisfy
the ON condition
+ // match no action and are simply carried over, so the ON condition can
filter the target
+ // - if every NOT MATCHED BY SOURCE clause is conditional, a target row
can be skipped only
+ // if it satisfies neither the ON condition nor any of those conditions,
so the filter is
+ // their disjunction; only the target-only part of the ON condition can
take part, as a
+ // disjunction that references the source is not a valid filter for the
target table
+ // - an unconditional NOT MATCHED BY SOURCE clause matches any target row
and disables this
+ val pushableCond = if (notMatchedBySourceActions.isEmpty) {
+ cond
+ } else if (notMatchedBySourceActions.exists(_.condition.isEmpty)) {
+ TrueLiteral
+ } else {
+ val (targetCond, _) = splitTargetPredicates(readRelation, cond)
+ (targetCond +: notMatchedBySourceActions.flatMap(_.condition)).reduce(Or)
+ }
+
+ // predicates of the ON condition can be used to filter the target table
at runtime
// only if there is no NOT MATCHED BY SOURCE clause
- val (pushableCond, groupFilterCond) = if
(notMatchedBySourceActions.isEmpty) {
- if (groupFilterEnabled) {
- (cond, Some(toGroupFilterCondition(relation, source, cond)))
- } else {
- (cond, None)
- }
+ val groupFilterCond = if (notMatchedBySourceActions.isEmpty &&
groupFilterEnabled) {
Review Comment:
**5. [Low] `ON t.pk = s.pk` still reads every group**
As [cloud-fan
noted](https://github.com/apache/spark/pull/52185#discussion_r4360904644), the
disjunction folds to `true` when ON has no target-only conjunct, which is the
most common shape. The runtime group filter is still off whenever there is a
NOT MATCHED BY SOURCE clause, so such a MERGE reads every group, as before.
When every NOT MATCHED BY SOURCE clause is conditional, the argument for the
static disjunction also holds at runtime. A target row that matches no source
row and fails every NOT MATCHED BY SOURCE condition is only carried over. So
the group filter could be:
```scala
val nmbsConds = notMatchedBySourceActions.flatMap(_.condition)
Some((toGroupFilterCondition(relation, source, cond) +:
nmbsConds).reduce(Or))
```
`RowLevelOperationRuntimeGroupFiltering` already rewrites the MERGE group
filter with `RewritePredicateSubquery`, which handles `EXISTS` under `OR`. This
could go in a separate PR. I mention it because it would cover the shape that
the static disjunction cannot.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedMergeIntoTableSuite.scala:
##########
@@ -218,6 +219,253 @@ class GroupBasedMergeIntoTableSuite extends
MergeIntoTableSuiteBase {
}
}
+ test("merge pushes conditional NOT MATCHED BY SOURCE predicates to target") {
+ withTempView("source") {
+ createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+ """{ "pk": 1, "salary": 100, "dep": "hr" }
+ |{ "pk": 2, "salary": 200, "dep": "software" }
+ |{ "pk": 3, "salary": 300, "dep": "finance" }
+ |""".stripMargin)
+
+ val sourceDF = Seq(1, 3, 6).toDF("pk")
+ sourceDF.createOrReplaceTempView("source")
+
+ // every NOT MATCHED BY SOURCE clause is conditional, so the target-only
part of the ON
+ // condition and that clause's condition can be pushed to the target
table as a
+ // disjunction; a target row satisfying neither is only carried over, so
its group
+ // doesn't have to be read at all
+ val (cond, groupFilterCond) = executeAndKeepConditions {
+ sql(
+ s"""MERGE INTO $tableNameAsString t
+ |USING source s
+ |ON t.pk = s.pk AND t.dep IN ('hr', 'finance')
+ |WHEN MATCHED THEN
+ | UPDATE SET t.salary = t.salary + 1
+ |WHEN NOT MATCHED THEN
+ | INSERT (pk, salary, dep) VALUES (s.pk, 0, 'hr')
+ |WHEN NOT MATCHED BY SOURCE AND t.dep IN ('hr', 'finance') THEN
+ | DELETE
+ |""".stripMargin)
+ }
+
+ assert(cond.sql == "(t.dep IN ('hr', 'finance'))", s"unexpected pushable
condition: $cond")
Review Comment:
**3. [Nit] The `cond.sql` assertions depend on optimizer simplifications**
These assertions read `ReplaceData.condition` from the optimized plan. They
rely on how `BooleanSimplification` folds the disjunction (`D OR D` to `D`, `(1
= 0) OR D` to `D`, `true OR D` to `true`) and on how the result renders as SQL.
[cloud-fan's
review](https://github.com/apache/spark/pull/52185#pullrequestreview-5637730797)
sees the exact match as a strength, because any change to the emitted
disjuncts fails it, and I agree with that. The cost is that an unrelated
optimizer change also fails these tests, for example one that rewrites `dep =
'hr' OR dep = 'finance'` as an `IN` list.
This is optional. A short comment above each assertion that names the
simplification it relies on would help whoever has to update it.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala:
##########
@@ -391,13 +404,23 @@ object RewriteMergeIntoTable extends
RewriteRowLevelCommand with PredicateHelper
targetTable: LogicalPlan,
cond: Expression): (LogicalPlan, Expression) = {
+ val (targetCond, joinCond) = splitTargetPredicates(targetTable, cond)
+ (Filter(targetCond, targetTable), joinCond)
+ }
+
+ // splits the given condition into a part that can be evaluated against the
target table alone
+ // and a part that requires the source as well, each returned as a
conjunction
+ private def splitTargetPredicates(
Review Comment:
**4. [Low] `extractPredicatesWithinOutputSet` would also cover an `OR` in
the ON condition**
`splitTargetPredicates` keeps only whole top-level conjuncts. Take an ON
condition like `(t.pk = s.pk AND t.dep = 'hr') OR (t.pk = s.pk AND t.dep =
'it')`. Its only conjunct references the source, so `targetCond` is
`TrueLiteral` and the disjunction folds to `true`.
`PredicateHelper.extractPredicatesWithinOutputSet`, which this object
already mixes in, returns the strongest target-only predicate implied by ON.
Here that is `t.dep = 'hr' OR t.dep = 'it'`. Because ON implies it, it is a
safe disjunct. A part that mixes target and source references under `NOT` is
treated as one leaf and dropped, so this also holds before the optimizer runs.
Could the group-based branch use it?
```scala
val targetCond = extractPredicatesWithinOutputSet(cond,
readRelation.outputSet)
.getOrElse(TrueLiteral)
```
The delta-based path still needs `splitTargetPredicates`, because it also
uses the remaining join condition.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala:
##########
@@ -391,13 +404,23 @@ object RewriteMergeIntoTable extends
RewriteRowLevelCommand with PredicateHelper
targetTable: LogicalPlan,
cond: Expression): (LogicalPlan, Expression) = {
+ val (targetCond, joinCond) = splitTargetPredicates(targetTable, cond)
Review Comment:
**6. [Low] The delta-based path keeps the old rule**
I understand that the delta-based path is left for a separate PR. In case it
helps there, the same disjunction seems to fit without other changes.
`buildWriteDeltaPlan` could put `Filter(targetCond OR c1 OR ..., readRelation)`
under the join and keep the full ON condition as the join condition
([L296-L302](https://github.com/apache/spark/blob/f2b3c156118b115caf6a382d2d0b017719abbaf8/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala#L296-L302)).
A target row that the filter drops can neither match nor fire a NOT MATCHED BY
SOURCE action, so it would produce no delta row.
Until then, the same MERGE reads only the matching groups on a group-based
connector but the whole table on a delta-based one. A shared helper that builds
the disjunction for both paths would keep them from drifting apart.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTable.scala:
##########
@@ -174,16 +174,29 @@ object RewriteMergeIntoTable extends
RewriteRowLevelCommand with PredicateHelper
readRelation, joinPlan, matchedActions, notMatchedActions,
notMatchedBySourceActions, metadataAttrs, checkCardinality)
- // predicates of the ON condition can be used to filter the target table
(planning & runtime)
+ // predicates that can be used to filter the target table (planning):
+ // - without NOT MATCHED BY SOURCE clauses, target rows that don't satisfy
the ON condition
+ // match no action and are simply carried over, so the ON condition can
filter the target
+ // - if every NOT MATCHED BY SOURCE clause is conditional, a target row
can be skipped only
+ // if it satisfies neither the ON condition nor any of those conditions,
so the filter is
+ // their disjunction; only the target-only part of the ON condition can
take part, as a
+ // disjunction that references the source is not a valid filter for the
target table
+ // - an unconditional NOT MATCHED BY SOURCE clause matches any target row
and disables this
+ val pushableCond = if (notMatchedBySourceActions.isEmpty) {
Review Comment:
**7. [Nit] No conf restores the previous plan**
Before this PR, a group-based MERGE with any NOT MATCHED BY SOURCE clause
pushed nothing to the connector. Now, when those clauses are all conditional,
it pushes a disjunction, and that changes which groups are read and replaced.
If a connector evaluates such a predicate wrongly, or in the CHAR case of
item 1, users cannot get the old plan back without a code change. Would an
internal conf, enabled by default, that keeps `TrueLiteral` be worth adding?
--
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]