-
Notifications
You must be signed in to change notification settings - Fork 237
[AURON #2431] Prune Iceberg changelog tasks by metadata predicates #2456
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,6 +19,7 @@ package org.apache.spark.sql.auron.iceberg | |
| import org.apache.spark.SPARK_VERSION | ||
| import org.apache.spark.internal.Logging | ||
| import org.apache.spark.sql.auron.{AuronConverters, AuronConvertProvider} | ||
| import org.apache.spark.sql.execution.FilterExec | ||
| import org.apache.spark.sql.execution.SparkPlan | ||
| import org.apache.spark.sql.execution.auron.plan.NativeIcebergTableScanExec | ||
| import org.apache.spark.sql.execution.datasources.v2.BatchScanExec | ||
|
|
@@ -28,6 +29,25 @@ import org.apache.auron.util.SemanticVersion | |
|
|
||
| class IcebergConvertProvider extends AuronConvertProvider with Logging { | ||
|
|
||
| override def prepare(exec: SparkPlan): Unit = { | ||
| exec.foreach { | ||
| case filter: FilterExec | ||
| if IcebergScanSupport.isSupportedChangelogTaskFilter(filter.condition) => | ||
| val referencedNames = filter.condition.references.map(_.name).toSet | ||
|
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.
Two ways that can bite. An alias that reuses the column name passes: in Would |
||
| val changelogScans = filter.child.collect { | ||
|
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.
That matters because dropping tasks at the scan is only safe if the filter could have been evaluated at the scan in the first place. Spark already decides that, and a filter still sitting above an operator is usually one Spark refused to push down. In Spark 3.5.8 Here is a shape that looks like it would return a wrong answer. On a changelog view select * from (select max(_change_ordinal) as _change_ordinal from v) where _change_ordinal = 0There are no grouping keys, so the filter stays above the aggregate. A filter above a I traced this through the optimizer source rather than running it, so I may be missing something that keeps these plans away from Would it help to match only a filter that sits directly above the scan, with def scanUnder(p: SparkPlan): Option[BatchScanExec] = p match {
case s: BatchScanExec => Some(s)
case proj: ProjectExec => scanUnder(proj.child)
case _ => None
} |
||
| case scan: BatchScanExec | ||
| if scan.scan.getClass.getName == | ||
| "org.apache.iceberg.spark.source.SparkChangelogScan" && | ||
| referencedNames.subsetOf(scan.output.map(_.name).toSet) => | ||
| scan | ||
| } | ||
| if (changelogScans.size == 1) { | ||
| IcebergScanSupport.addChangelogTaskFilter(changelogScans.head, filter.condition) | ||
| } | ||
| case _ => | ||
| } | ||
| } | ||
|
|
||
| override def isEnabled(exec: SparkPlan): Boolean = { | ||
| exec match { | ||
| case _: BatchScanExec => | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,7 @@ import org.apache.iceberg.expressions.{And => IcebergAnd, BoundPredicate, Expres | |
| import org.apache.iceberg.spark.source.AuronIcebergSourceUtil | ||
| import org.apache.spark.internal.Logging | ||
| import org.apache.spark.sql.auron.{NativeConverters, Shims} | ||
| import org.apache.spark.sql.catalyst.expressions.{And => SparkAnd, AttributeReference, EqualTo, Expression => SparkExpression, GreaterThan, GreaterThanOrEqual, In, IsNaN, IsNotNull, IsNull, LessThan, LessThanOrEqual, Literal, Not => SparkNot, Or => SparkOr, StartsWith} | ||
| import org.apache.spark.sql.catalyst.expressions.{And => SparkAnd, AttributeReference, EqualTo, Expression => SparkExpression, GreaterThan, GreaterThanOrEqual, In, InSet, IsNaN, IsNotNull, IsNull, LessThan, LessThanOrEqual, Literal, Not => SparkNot, Or => SparkOr, StartsWith} | ||
| import org.apache.spark.sql.catalyst.trees.TreeNodeTag | ||
| import org.apache.spark.sql.connector.read.{InputPartition, Scan} | ||
| import org.apache.spark.sql.execution.datasources.v2.BatchScanExec | ||
|
|
@@ -60,6 +60,8 @@ object IcebergScanSupport extends Logging { | |
| "auron.iceberg.scan.plan") | ||
| private val runtimeFilteredScanPlanTag: TreeNodeTag[Option[IcebergScanPlan]] = TreeNodeTag( | ||
| "auron.iceberg.runtime.filtered.scan.plan") | ||
| private val changelogTaskFilterTag: TreeNodeTag[SparkExpression] = TreeNodeTag( | ||
| "auron.iceberg.changelog.task.filter") | ||
|
|
||
| private val SparkChangelogScanClassName = | ||
| "org.apache.iceberg.spark.source.SparkChangelogScan" | ||
|
|
@@ -73,6 +75,34 @@ object IcebergScanSupport extends Logging { | |
| scan.getClass.getName == SparkChangelogScanClassName || | ||
| AuronIcebergSourceUtil.getClassOfSparkBatchQueryScan.isInstance(scan) | ||
|
|
||
| def addChangelogTaskFilter(exec: BatchScanExec, condition: SparkExpression): Unit = { | ||
| val combined = exec.getTagValue(changelogTaskFilterTag) match { | ||
| case Some(existing) => SparkAnd(existing, condition) | ||
| case None => condition | ||
| } | ||
| exec.setTagValue(changelogTaskFilterTag, combined) | ||
| } | ||
|
|
||
| def isSupportedChangelogTaskFilter(expression: SparkExpression): Boolean = { | ||
| expression match { | ||
| case SparkAnd(left, right) => | ||
| isSupportedChangelogTaskFilter(left) && isSupportedChangelogTaskFilter(right) | ||
| case EqualTo(attribute: AttributeReference, _: Literal) => | ||
| ChangelogMetadataColumnNames.contains(attribute.name) | ||
| case EqualTo(_: Literal, attribute: AttributeReference) => | ||
| ChangelogMetadataColumnNames.contains(attribute.name) | ||
| case In(attribute: AttributeReference, values) => | ||
| ChangelogMetadataColumnNames.contains(attribute.name) && | ||
| values.forall(_.isInstanceOf[Literal]) | ||
| case InSet(attribute: AttributeReference, _) => | ||
| ChangelogMetadataColumnNames.contains(attribute.name) | ||
| case IsNotNull(attribute: AttributeReference) => | ||
| ChangelogMetadataColumnNames.contains(attribute.name) | ||
| case _ => | ||
| false | ||
| } | ||
| } | ||
|
|
||
| def fallbackReason(exec: BatchScanExec): Option[String] = { | ||
| val scan = exec.scan | ||
| if (!isIcebergScan(scan)) { | ||
|
|
@@ -290,7 +320,10 @@ object IcebergScanSupport extends Logging { | |
| } | ||
|
|
||
| val pruningPredicates = collectPruningPredicates(scan.asInstanceOf[AnyRef], readSchema) | ||
| val nativeTasks = nativeChangelogTasks.map(task => toNativeScanTask(task, partitionSchema)) | ||
| val filteredTasks = exec | ||
| .getTagValue(changelogTaskFilterTag) | ||
|
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. The tag read here goes missing whenever the scan is rebuilt for runtime filters. So on a changelog scan carrying runtime filters, which is the shape the existing Was that intentional? If not, would it make sense for |
||
| .fold(nativeChangelogTasks)(filterChangelogTasks(nativeChangelogTasks, _, partitionSchema)) | ||
| val nativeTasks = filteredTasks.map(task => toNativeScanTask(task, partitionSchema)) | ||
| Some( | ||
| IcebergScanPlan( | ||
| nativeTasks, | ||
|
|
@@ -572,6 +605,76 @@ object IcebergScanSupport extends Logging { | |
| } | ||
| } | ||
|
|
||
| private type ChangelogMetadataPredicate = Seq[Any] => Boolean | ||
|
|
||
| private def filterChangelogTasks( | ||
| tasks: Seq[NativeChangelogDataFileTask], | ||
| condition: SparkExpression, | ||
| partitionSchema: StructType): Seq[NativeChangelogDataFileTask] = { | ||
| changelogTaskPredicate(condition, partitionSchema) | ||
| .map(predicate => | ||
| tasks.filter { task => | ||
| val values = metadataPartitionValues( | ||
| task.file.location(), | ||
| task.file.specId(), | ||
| Some(task.changelogTask), | ||
| partitionSchema) | ||
| predicate(values) | ||
| }) | ||
| .getOrElse(tasks) | ||
| } | ||
|
|
||
| private def changelogTaskPredicate( | ||
| expression: SparkExpression, | ||
| partitionSchema: StructType): Option[ChangelogMetadataPredicate] = { | ||
| expression match { | ||
| case SparkAnd(left, right) => | ||
| for { | ||
| leftPredicate <- changelogTaskPredicate(left, partitionSchema) | ||
| rightPredicate <- changelogTaskPredicate(right, partitionSchema) | ||
| } yield task => leftPredicate(task) && rightPredicate(task) | ||
| case EqualTo(attribute: AttributeReference, literal: Literal) => | ||
| changelogMetadataPredicate(attribute.name, Seq(literal.value), partitionSchema) | ||
| case EqualTo(literal: Literal, attribute: AttributeReference) => | ||
| changelogMetadataPredicate(attribute.name, Seq(literal.value), partitionSchema) | ||
| case In(attribute: AttributeReference, values) if values.forall(_.isInstanceOf[Literal]) => | ||
| changelogMetadataPredicate( | ||
| attribute.name, | ||
| values.map(_.asInstanceOf[Literal].value), | ||
| partitionSchema) | ||
| case InSet(attribute: AttributeReference, values) => | ||
| changelogMetadataPredicate(attribute.name, values.toSeq, partitionSchema) | ||
| case IsNotNull(attribute: AttributeReference) | ||
| if ChangelogMetadataColumnNames.contains(attribute.name) && | ||
| partitionSchema.fieldNames.contains(attribute.name) => | ||
| Some(_ => true) | ||
| case _ => | ||
| None | ||
| } | ||
| } | ||
|
|
||
| private def changelogMetadataPredicate( | ||
| columnName: String, | ||
| values: Seq[Any], | ||
| partitionSchema: StructType): Option[ChangelogMetadataPredicate] = { | ||
| if (!ChangelogMetadataColumnNames.contains(columnName)) { | ||
| return None | ||
| } | ||
|
|
||
| val index = partitionSchema.fieldNames.indexOf(columnName) | ||
| if (index < 0) { | ||
| None | ||
| } else { | ||
| val normalizedValues = values.map(normalizeChangelogMetadataValue) | ||
| Some(taskValues => normalizedValues.contains(taskValues(index))) | ||
| } | ||
| } | ||
|
|
||
| private def normalizeChangelogMetadataValue(value: Any): Any = value match { | ||
| case text: org.apache.spark.unsafe.types.UTF8String => text.toString | ||
| case other => other | ||
| } | ||
|
|
||
| private def toNativeScanTask( | ||
| task: FileScanTask, | ||
| partitionSchema: StructType): IcebergNativeScanTask = { | ||
|
|
||
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.
There may already be a place to do this.
preColumnarTransitionsin this file receives the whole stage plan, and it already runs a whole-plan pass at line 86 (AuronConvertStrategy.apply(sparkPlan)) before converting at line 90. Tags set in a pass like that survive, because for a leafBatchScanExecwithNewChildren(Nil)returns the same object, so the converter later reads the node that was tagged.Calling
AuronConverters.prepareExtensionPlans(sparkPlan)just before line 86 looks like it would do the same job, with no newShimsmethod and no newSparkSessionExtensionsinjection point. It would also run per query stage, which narrows the subtree search I mentioned inIcebergConvertProvider.prepare, since each of those shapes has an exchange between the filter and the scan. That is a narrowing though, not a replacement for the adjacency and exprId checks.Is there something about the pre-stage AQE hook that is needed here?