dongjoon-hyun commented on code in PR #58756:
URL: https://github.com/apache/spark/pull/58756#discussion_r4001846479
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/write/RowLevelOperationTable.scala:
##########
@@ -40,6 +41,7 @@ private[sql] case class RowLevelOperationTable(
override def columns: Array[Column] = table.columns()
override def capabilities: util.Set[TableCapability] = table.capabilities
override def constraints(): Array[Constraint] = table.constraints()
+ override def partitioning(): Array[Transform] = table.partitioning()
Review Comment:
With this delegation, row-level scans now go through the second-pass
`PartitionPredicate` path. When a `PartitionPredicate` is accepted, Spark drops
the original filter: from the post-scan `Filter` for delta-based operations
(`V2ScanRelationPushDown`), and from the MERGE join condition for group-based
MERGE (the filter ends up in `evaluatedFilters`, which
`optimizeMergeJoinCondition` removes). But `PartitionPredicateImpl.eval` fails
open: it returns `true` on an evaluation exception or a field-count mismatch.
For example, under ANSI mode, `MERGE INTO t USING s ON t.id = s.id AND
CAST(t.dep AS INT) = 1 WHEN MATCHED THEN UPDATE ...` should fail with
`CAST_INVALID_INPUT` when a row in the `hr` partition matches by `id`. With
this change, the fail-open `eval` keeps the `hr` partition, the conjunct is
dropped from the join condition, and the matching `hr` rows are updated without
any error. A delta-based `UPDATE t SET ... WHERE CAST(dep AS INT) = 1` has the
same problem. Group-based UPDATE/DELETE are safe because the rewrite plan
evaluates the condition again.
The fail-open behavior itself comes from SPARK-55596, but this PR extends it
from reads to UPDATE/MERGE writes. For row-level operations, should we keep the
original filter (the post-scan filter or the join conjunct) even when a
`PartitionPredicate` is accepted?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryPartitionPredicateDeleteTable.scala:
##########
@@ -107,6 +113,72 @@ class InMemoryPartitionPredicateDeleteTable(
}
}
+ /**
+ * Row-level scans push V2 predicates iteratively, so a group-based
operation receives a
+ * second-pass [[PartitionPredicate]] the same way a metadata-only DELETE
does. Only partition
+ * predicates prune, by partition key; a data predicate is always returned
since the scan
+ * cannot filter rows.
+ */
+ override protected def newRowLevelScanBuilder(
+ options: CaseInsensitiveStringMap)(
+ onBuild: BatchScanBaseClass => Unit): ScanBuilder = {
+ new PartitionPredicateRowLevelScanBuilder(onBuild)
+ }
+
+ class PartitionPredicateRowLevelScanBuilder(onBuild: BatchScanBaseClass =>
Unit)
+ extends ScanBuilder with SupportsPushDownV2Filters with
SupportsPushDownRequiredColumns {
+
+ private var readSchema: StructType = schema
+ private val pushed = ArrayBuffer.empty[Predicate]
+
+ override def supportsIterativePushdown(): Boolean = true
+
+ override def pushPredicates(predicates: Array[Predicate]):
Array[Predicate] = {
+ val (accepted, returned) = predicates.partition {
+ case _: PartitionPredicate => acceptPartitionPredicates
+ case p => refsOnlyPartCols(p) &&
InMemoryTableWithV2Filter.supportsPredicates(Array(p))
Review Comment:
This accepts any `=`, `<=>`, `IS_NULL`, etc. whose references are all
partition columns, whatever its children are. `build()` then assumes that
`children(0)` is a partition column name and `children(1)` is a `LiteralValue`
(`filtersToKeys` -> `evalPredicate`). For example, `UPDATE t SET salary = 0
WHERE upper(dep) = 'HR'` translates to `=(UPPER(dep), 'HR')`. This builder
accepts it, and planning then fails in `InMemoryBaseTable.extractValue` with
`Unknown filter attribute: UPPER(dep)`. Could we accept only a column reference
compared with a literal, and return everything else?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryPartitionPredicateDeleteTable.scala:
##########
@@ -107,6 +113,72 @@ class InMemoryPartitionPredicateDeleteTable(
}
}
+ /**
+ * Row-level scans push V2 predicates iteratively, so a group-based
operation receives a
+ * second-pass [[PartitionPredicate]] the same way a metadata-only DELETE
does. Only partition
+ * predicates prune, by partition key; a data predicate is always returned
since the scan
+ * cannot filter rows.
+ */
+ override protected def newRowLevelScanBuilder(
Review Comment:
This override skips everything the parent `newRowLevelScanBuilder` does: the
`use-catalyst-runtime-filtering` branch, `options`, the ordering-aware scan,
and `recordScanEvent`. `PartitionPredicateRowLevelBatchScan` also has no
runtime filtering, so `RowLevelOperationRuntimeGroupFiltering` can no longer
inject group filters for this table. Delta-based operations are affected too,
since they call the same builder.
As a result, the existing fallback tests in this suite (e.g. `first and
second pass rejected: table rejects all`) used to prune through the V1 `In`
pushdown (`canEvaluate`) and now rewrite the whole table. They still pass
because they check only the final rows, so this coverage loss goes unnoticed.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedDeleteFilterSuite.scala:
##########
@@ -241,6 +245,31 @@ class DataSourceV2EnhancedDeleteFilterSuite extends
SharedSparkSession {
}
}
+ // A group-based UPDATE reads the table through RowLevelOperationTable. The
wrapper reports
+ // the table's partitioning, so the row-level scan, which pushes V2
predicates iteratively,
+ // receives the IN on the partition column as a second-pass
PartitionPredicate, and only the
+ // matching partitions are read and replaced.
+ test("SPARK-59457: group-based UPDATE receives a second-pass
PartitionPredicate") {
Review Comment:
This test covers only group-based UPDATE. The PR description, and the
updated class doc, also cover MERGE, delta-based scans
(`V2ScanRelationPushDown`), and runtime filter pushdown. Could you add at least
a group-based MERGE test (e.g. `ON t.pk = s.pk AND t.dep IN ('hr',
'software')`) that checks both the join condition and the result, plus a
delta-based (`supports-deltas`) UPDATE test? MERGE matters most, because an
accepted `PartitionPredicate` now removes the conjunct from the join condition.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryPartitionPredicateDeleteTable.scala:
##########
@@ -107,6 +113,72 @@ class InMemoryPartitionPredicateDeleteTable(
}
}
+ /**
+ * Row-level scans push V2 predicates iteratively, so a group-based
operation receives a
+ * second-pass [[PartitionPredicate]] the same way a metadata-only DELETE
does. Only partition
+ * predicates prune, by partition key; a data predicate is always returned
since the scan
+ * cannot filter rows.
+ */
+ override protected def newRowLevelScanBuilder(
+ options: CaseInsensitiveStringMap)(
+ onBuild: BatchScanBaseClass => Unit): ScanBuilder = {
+ new PartitionPredicateRowLevelScanBuilder(onBuild)
+ }
+
+ class PartitionPredicateRowLevelScanBuilder(onBuild: BatchScanBaseClass =>
Unit)
Review Comment:
nit: This builder and `PartitionPredicateRowLevelBatchScan` mostly duplicate
`InMemoryEnhancedPartitionFilterScanBuilder` and
`InMemoryEnhancedPartitionFilterBatchScan` in
`InMemoryEnhancedPartitionFilterTable`: the accumulating `pushPredicates`,
`build()` with `filtersToKeys` plus `PartitionPredicate.eval`, and a scan that
holds `pushedPartitionPredicates`. `pruneColumns` also duplicates
`InMemoryScanBuilder.pruneColumns`. Could we move the shared part into
`InMemoryBaseTable` so both fixtures use it?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryPartitionPredicateDeleteTable.scala:
##########
@@ -107,6 +113,72 @@ class InMemoryPartitionPredicateDeleteTable(
}
}
+ /**
+ * Row-level scans push V2 predicates iteratively, so a group-based
operation receives a
+ * second-pass [[PartitionPredicate]] the same way a metadata-only DELETE
does. Only partition
+ * predicates prune, by partition key; a data predicate is always returned
since the scan
+ * cannot filter rows.
+ */
+ override protected def newRowLevelScanBuilder(
+ options: CaseInsensitiveStringMap)(
+ onBuild: BatchScanBaseClass => Unit): ScanBuilder = {
+ new PartitionPredicateRowLevelScanBuilder(onBuild)
+ }
+
+ class PartitionPredicateRowLevelScanBuilder(onBuild: BatchScanBaseClass =>
Unit)
+ extends ScanBuilder with SupportsPushDownV2Filters with
SupportsPushDownRequiredColumns {
+
+ private var readSchema: StructType = schema
+ private val pushed = ArrayBuffer.empty[Predicate]
+
+ override def supportsIterativePushdown(): Boolean = true
+
+ override def pushPredicates(predicates: Array[Predicate]):
Array[Predicate] = {
+ val (accepted, returned) = predicates.partition {
+ case _: PartitionPredicate => acceptPartitionPredicates
+ case p => refsOnlyPartCols(p) &&
InMemoryTableWithV2Filter.supportsPredicates(Array(p))
+ }
+ pushed ++= accepted
+ returned
+ }
+
+ override def pushedPredicates(): Array[Predicate] = pushed.toArray
+
+ override def pruneColumns(requiredSchema: StructType): Unit = {
+ val metadataNames = metadataColumns.map(_.name).toSet
+ val schemaNames = schema.map(_.name).toSet
+ readSchema = StructType(requiredSchema.filter {
+ case MetadataStructFieldWithLogicalName(_, name) =>
metadataNames.contains(name)
+ case f => schemaNames.contains(f.name)
+ })
+ }
+
+ override def build(): Scan = {
+ val (partPreds, stdPreds) =
pushed.toArray.partition(_.isInstanceOf[PartitionPredicate])
+ val partitionPredicates =
partPreds.map(_.asInstanceOf[PartitionPredicate])
+ val keys = InMemoryTableWithV2Filter.filtersToKeys(
+ data.map(_.key).toImmutableArraySeq,
Review Comment:
nit: `data` rebuilds `dataMap.values.flatten.toArray` on every call. Here it
is called twice (for the keys and for the partitions), outside
`dataMap.synchronized`. How about computing it once, as
`InMemoryEnhancedPartitionFilterTable.build()` does with `allPartitions`?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedDeleteFilterSuite.scala:
##########
@@ -357,6 +386,35 @@ class DataSourceV2EnhancedDeleteFilterSuite extends
SharedSparkSession {
}
}
+ /**
+ * Asserts that the group-based plan's row-level scan was pruned by one
PartitionPredicate with
+ * the given references, and that only the partitions with the given `dep`
values, the first
+ * partition field, were replaced.
+ */
+ private def assertRowLevelScanPrunedByPartitionPredicate(
+ plan: SparkPlan,
+ expectedOrdinals: Array[Int],
+ expectedPartitionFieldNames: Array[String],
+ expectedReplacedDeps: Set[String]): Unit = {
+ assert(plan.isInstanceOf[ReplaceDataExec],
+ s"Expected ReplaceDataExec but got: ${plan.getClass.getSimpleName}")
+ val scans = collect(plan) { case s: BatchScanExec => s }
+ val scan = scans.map(_.scan).collectFirst {
+ case s:
InMemoryPartitionPredicateDeleteTable#PartitionPredicateRowLevelBatchScan => s
+ }.getOrElse(fail("Expected the row-level scan of the in-memory table"))
+ assertPartitionFieldReferences(
+ scan.pushedPartitionPredicates.toArray, Seq(expectedOrdinals),
expectedPartitionFieldNames)
+
+ val table = scans.map(_.table).collectFirst {
+ case RowLevelOperationTable(t: InMemoryPartitionPredicateDeleteTable, _)
=> t
+ }.getOrElse(fail("Expected the row-level operation table"))
+ val replacedDeps = table.replacedPartitions.map(_.head.toString)
+ assert(
+ replacedDeps.toSet === expectedReplacedDeps &&
Review Comment:
nit: `replacedPartitions` is already `distinct` (see
`PartitionBasedReplaceData.doCommit`), so the size check adds nothing. Also,
joining two `===` with `&&` loses ScalaTest's diff message. How about
`assert(replacedDeps.sorted === expectedReplacedDeps.toSeq.sorted)`?
--
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]