Skip to content

Commit a4dcd6d

Browse files
szehon-hogengliangwang
authored andcommitted
[SPARK-55596][SQL] DSV2 Enhanced Partition Stats Filtering
### What changes were proposed in this pull request? PR for SPARK-55596: SPIP doc: https://docs.google.com/document/d/17vcw411PxSRLWoK-BiLI56UiNdokLWtovF8JZUlDTOo ### Why are the changes needed? Enhance partition filtering for DSV2 data sources that have partition stats. ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? Add unit test : DataSourceV2EnhancedPartitionFilterSuite ### Was this patch authored or co-authored using generative AI tooling? Tests were generated by Cursor Closes #54459 from szehon-ho/partition_filter. Authored-by: Szehon Ho <szehon.apache@gmail.com> Signed-off-by: Gengliang Wang <gengliang@apache.org>
1 parent d95f1da commit a4dcd6d

14 files changed

Lines changed: 1163 additions & 26 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.sql.connector.expressions;
19+
20+
import org.apache.spark.annotation.Evolving;
21+
import org.apache.spark.sql.connector.catalog.Table;
22+
23+
/**
24+
* A reference to a partition column in {@link Table#partitioning()}.
25+
* <p>
26+
* {@link #fieldNames()} returns the partition column name (or names) as reported by
27+
* the table's partition schema.
28+
* {@link #ordinal()} returns the 0-based position in {@link Table#partitioning()}.
29+
*
30+
* @since 4.2.0
31+
*/
32+
@Evolving
33+
public interface PartitionColumnReference extends NamedReference {
34+
35+
/**
36+
* Returns the 0-based ordinal of this partition column in {@link Table#partitioning()}.
37+
*/
38+
int ordinal();
39+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.sql.connector.expressions.filter;
19+
20+
import org.apache.spark.annotation.Evolving;
21+
import org.apache.spark.sql.catalyst.InternalRow;
22+
import org.apache.spark.sql.connector.catalog.Table;
23+
import org.apache.spark.sql.connector.expressions.NamedReference;
24+
import org.apache.spark.sql.connector.expressions.PartitionColumnReference;
25+
26+
import static org.apache.spark.sql.connector.expressions.Expression.EMPTY_EXPRESSION;
27+
28+
/**
29+
* Represents a partition predicate that can be evaluated using {@link Table#partitioning()}.
30+
* <p>
31+
* Connectors are expected to leverage partition predicates for pruning whenever they have
32+
* partition metadata to evaluate them. Use {@link #eval(InternalRow)} to evaluate this
33+
* predicate against a single partition's keys.
34+
* </p>
35+
*
36+
* @since 4.2.0
37+
*/
38+
@Evolving
39+
public abstract class PartitionPredicate extends Predicate {
40+
41+
public static final String NAME = "PARTITION_PREDICATE";
42+
43+
protected PartitionPredicate() {
44+
super(NAME, EMPTY_EXPRESSION);
45+
}
46+
47+
/**
48+
* {@inheritDoc}
49+
* <p>
50+
* For PartitionPredicate, returns {@link PartitionColumnReference} instances that identify
51+
* the partition columns (from {@link Table#partitioning()}) referenced by this predicate.
52+
* Each reference's {@link PartitionColumnReference#fieldNames()} gives the partition column
53+
* name; {@link PartitionColumnReference#ordinal()} gives the 0-based position in
54+
* {@link Table#partitioning()}.
55+
* <p>
56+
* <b>Example:</b> Suppose {@code Table.partitioning()} returns three partition
57+
* transforms: {@code [years(ts), months(ts), bucket(32, id)]} with ordinals 0, 1, 2.
58+
* Each {@link PartitionColumnReference} has {@link PartitionColumnReference#fieldNames()}
59+
* (the transform display name, e.g. {@code years(ts)}) and
60+
* {@link PartitionColumnReference#ordinal()}:
61+
* <ul>
62+
* <li>{@code years(ts) = 2026} returns one reference: (fieldNames=[years(ts)], ordinal=0).</li>
63+
* <li>{@code years(ts) = 2026 and months(ts) = 01} returns two references:
64+
* (fieldNames=[years(ts)], ordinal=0), (fieldNames=[months(ts)], ordinal=1).</li>
65+
* <li>{@code bucket(32, id) = 1} returns one reference:
66+
* (fieldNames=[bucket(32, id)], ordinal=2).</li>
67+
* </ul>
68+
* <p>
69+
* Data sources can use these references to decide whether to return a predicate for post-scan
70+
* filtering. For example, sources supporting partition spec evolution should return
71+
* PartitionPredicates that reference later-added partition transforms (incompletely
72+
* partitioned data) to Spark for post-scan filter, while predicates that reference only
73+
* initially-added partition transforms may be fully pushed.
74+
*
75+
* @return array of partition column references
76+
*/
77+
@Override
78+
public abstract NamedReference[] references();
79+
80+
/**
81+
* Evaluates this predicate against a single partition's keys.
82+
* <p>
83+
* The caller must pass the <b>full</b> partition key: one value per partition transform in
84+
* {@link Table#partitioning()}, in order. A key for only a subset of referenced columns is not
85+
* supported.
86+
*
87+
* @param partitionKey the full partition key for one partition, ordered according to
88+
* {@link Table#partitioning()}.
89+
* @return true if the partition represented by these keys satisfies this predicate.
90+
*/
91+
public abstract boolean eval(InternalRow partitionKey);
92+
}

sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsPushDownV2Filters.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package org.apache.spark.sql.connector.read;
1919

2020
import org.apache.spark.annotation.Evolving;
21+
import org.apache.spark.sql.connector.expressions.PartitionColumnReference;
22+
import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
2123
import org.apache.spark.sql.connector.expressions.filter.Predicate;
2224

2325
/**
@@ -26,6 +28,12 @@
2628
* Please Note that this interface is preferred over {@link SupportsPushDownFilters}, which uses
2729
* V1 {@link org.apache.spark.sql.sources.Filter} and is less efficient due to the
2830
* internal -&gt; external data conversion.
31+
* <p>
32+
* <b>Iterative pushdown:</b> When {@link #supportsIterativePushdown()} returns true,
33+
* {@link #pushPredicates(Predicate[])} may be called <i>multiple times</i> on the same
34+
* {@link ScanBuilder} instance with additional predicates (e.g. {@link PartitionPredicate}).
35+
* The implementation must accumulate state across all calls, and
36+
* {@link #pushedPredicates()} must return predicates from all of them.
2937
*
3038
* @since 3.3.0
3139
*/
@@ -34,9 +42,24 @@ public interface SupportsPushDownV2Filters extends ScanBuilder {
3442

3543
/**
3644
* Pushes down predicates, and returns predicates that need to be evaluated after scanning.
45+
* Any predicate that the data source cannot fully push down must be returned as-is so that
46+
* Spark can evaluate it after the scan; the data source must not modify or drop such predicates.
3747
* <p>
3848
* Rows should be returned from the data source if and only if all of the predicates match.
3949
* That is, predicates must be interpreted as ANDed together.
50+
* <p>
51+
* This method may be called multiple times with additional predicates (e.g.
52+
* {@link PartitionPredicate} when {@link #supportsIterativePushdown()} returns true).
53+
* The implementation must accumulate state across all calls so that
54+
* {@link #pushedPredicates()} can return predicates from all of them.
55+
* <p>
56+
* For each {@link PartitionPredicate}, the implementation can use
57+
* {@link PartitionPredicate#references()} (each {@link PartitionColumnReference} has
58+
* {@link PartitionColumnReference#ordinal()}) to decide whether to return it for post-scan
59+
* filtering. For example, data sources with
60+
* partition spec evolution may return predicates that reference later-added partition
61+
* transforms (incompletely partitioned data) so Spark evaluates them after the scan, while
62+
* predicates that reference only initially-added partition transforms may be fully pushed.
4063
*/
4164
Predicate[] pushPredicates(Predicate[] predicates);
4265

@@ -55,9 +78,25 @@ public interface SupportsPushDownV2Filters extends ScanBuilder {
5578
* Both case 1 and 2 should be considered as pushed predicates and should be returned
5679
* by this method.
5780
* <p>
81+
* When iterative pushdown is supported and {@link #pushPredicates(Predicate[])} was called
82+
* multiple times, this method must return predicates from <i>all</i> calls.
83+
* <p>
5884
* It's possible that there is no predicates in the query and
5985
* {@link #pushPredicates(Predicate[])} is never called,
6086
* empty array should be returned for this case.
6187
*/
6288
Predicate[] pushedPredicates();
89+
90+
/**
91+
* Returns true if this data source supports iterative filter pushdown. When true,
92+
* {@link #pushPredicates(Predicate[])} may be called multiple times with additional
93+
* predicates (e.g. {@link PartitionPredicate}). The implementation must accumulate state
94+
* across all calls, and {@link #pushedPredicates()} must return predicates from all of them.
95+
* See the class-level Javadoc for the full contract.
96+
*
97+
* @since 4.2.0
98+
*/
99+
default boolean supportsIterativePushdown() {
100+
return false;
101+
}
63102
}

sql/catalyst/src/main/java/org/apache/spark/sql/connector/util/V2ExpressionSQLBuilder.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.apache.spark.sql.connector.expressions.SortDirection;
3636
import org.apache.spark.sql.connector.expressions.SortOrder;
3737
import org.apache.spark.sql.connector.expressions.UserDefinedScalarFunc;
38+
import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
3839
import org.apache.spark.sql.connector.expressions.aggregate.Avg;
3940
import org.apache.spark.sql.connector.expressions.aggregate.Max;
4041
import org.apache.spark.sql.connector.expressions.aggregate.Min;
@@ -78,6 +79,8 @@ public String build(Expression expr) {
7879
return visitLiteral(literal);
7980
} else if (expr instanceof NamedReference namedReference) {
8081
return visitNamedReference(namedReference);
82+
} else if (expr instanceof PartitionPredicate partitionPredicate) {
83+
return visitPartitionPredicate(partitionPredicate);
8184
} else if (expr instanceof Cast cast) {
8285
return visitCast(build(cast.expression()), cast.expressionDataType(), cast.dataType());
8386
} else if (expr instanceof Extract extract) {
@@ -332,6 +335,10 @@ protected String visitUnexpectedExpr(Expression expr) throws IllegalArgumentExce
332335
"_LEGACY_ERROR_TEMP_3207", Map.of("expr", String.valueOf(expr)));
333336
}
334337

338+
protected String visitPartitionPredicate(PartitionPredicate partitionPredicate) {
339+
return partitionPredicate.describe();
340+
}
341+
335342
protected String visitOverlay(String[] inputs) {
336343
assert(inputs.length == 3 || inputs.length == 4);
337344
if (inputs.length == 3) {

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.apache.spark.sql.connector.expressions.{BucketTransform, Cast => V2Ca
3434
import org.apache.spark.sql.connector.expressions.filter.{AlwaysFalse, AlwaysTrue}
3535
import org.apache.spark.sql.errors.DataTypeErrors.toSQLId
3636
import org.apache.spark.sql.errors.QueryCompilationErrors
37+
import org.apache.spark.sql.internal.connector.PartitionPredicateImpl
3738
import org.apache.spark.sql.types._
3839
import org.apache.spark.util.ArrayImplicits._
3940

@@ -213,6 +214,7 @@ object V2ExpressionUtils extends SQLConfHelper with Logging {
213214
case l: V2Literal[_] => Some(Literal(l.value, l.dataType))
214215
case r: NamedReference => Some(UnresolvedAttribute(r.fieldNames.toImmutableArraySeq))
215216
case c: V2Cast => toCatalyst(c.expression).map(Cast(_, c.dataType, ansiEnabled = true))
217+
case p: PartitionPredicateImpl => Some(p.expression)
216218
case e: GeneralScalarExpression => convertScalarExpr(e)
217219
case _ => None
218220
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.sql.internal.connector
19+
20+
import org.apache.spark.sql.connector.expressions.PartitionColumnReference
21+
22+
/**
23+
* Implementation of [[PartitionColumnReference]] that carries the position ordinal in
24+
* Table.partitioning() and the partition column name(s) for that position.
25+
*/
26+
private[connector] case class PartitionColumnReferenceImpl(
27+
ordinal: Int,
28+
fieldNames: Array[String])
29+
extends PartitionColumnReference
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.sql.internal.connector
19+
20+
import org.apache.spark.SparkException
21+
import org.apache.spark.internal.{Logging, LogKeys}
22+
import org.apache.spark.sql.catalyst.InternalRow
23+
import org.apache.spark.sql.catalyst.expressions.{AttributeReference, BoundReference, Expression => CatalystExpression, Predicate => CatalystPredicate}
24+
import org.apache.spark.sql.connector.expressions.NamedReference
25+
import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate
26+
27+
/**
28+
* An implementation for [[PartitionPredicate]] that wraps a Catalyst Expression representing a
29+
* partition filter.
30+
*/
31+
class PartitionPredicateImpl private (
32+
private val catalystExpr: CatalystExpression,
33+
private val partitionSchema: Seq[AttributeReference])
34+
extends PartitionPredicate with Logging {
35+
36+
/** The wrapped partition filter Catalyst Expression. */
37+
def expression: CatalystExpression = catalystExpr
38+
39+
/** Bound predicate, computed once and reused for all partition rows. */
40+
@transient private lazy val boundPredicate: InternalRow => Boolean = {
41+
val boundExpr = catalystExpr.transform {
42+
case a: AttributeReference =>
43+
val index = partitionSchema.indexWhere(_.name == a.name)
44+
require(index >= 0, s"Column ${a.name} not found in partition schema")
45+
BoundReference(index, partitionSchema(index).dataType, nullable = a.nullable)
46+
}
47+
val predicate = CatalystPredicate.createInterpreted(boundExpr)
48+
predicate.eval
49+
}
50+
51+
override def eval(partitionValues: InternalRow): Boolean = {
52+
if (partitionValues.numFields != partitionSchema.length) {
53+
logWarning(
54+
log"Cannot evaluate partition predicate ${MDC(LogKeys.EXPR, catalystExpr.sql)}: " +
55+
log"partition value field count (${MDC(LogKeys.COUNT, partitionValues.numFields)}) " +
56+
log"does not match schema (${MDC(LogKeys.NUM_PARTITIONS, partitionSchema.length)}). " +
57+
log"Including partition in scan result to avoid incorrect filtering.")
58+
return true
59+
}
60+
61+
try {
62+
boundPredicate(partitionValues)
63+
} catch {
64+
case e: Exception =>
65+
logWarning(
66+
log"Failed to evaluate partition predicate ${MDC(LogKeys.EXPR, catalystExpr.sql)}. " +
67+
log"Including partition in scan result to avoid incorrect filtering.",
68+
e)
69+
true
70+
}
71+
}
72+
73+
@transient override lazy val references: Array[NamedReference] = {
74+
val refNames = catalystExpr.references.map(_.name).toSet
75+
partitionSchema.zipWithIndex
76+
.filter { case (attr, _) => refNames.contains(attr.name) }
77+
.map { case (attr, ordinal) => PartitionColumnReferenceImpl(ordinal, Array(attr.name)) }
78+
.toArray
79+
}
80+
81+
override def equals(obj: Any): Boolean = obj match {
82+
case other: PartitionPredicateImpl =>
83+
catalystExpr.semanticEquals(other.catalystExpr) && partitionSchema == other.partitionSchema
84+
case _ => false
85+
}
86+
87+
override def hashCode(): Int = {
88+
31 * catalystExpr.semanticHash() + partitionSchema.hashCode()
89+
}
90+
91+
override def toString(): String = s"PartitionPredicate(${catalystExpr.sql})"
92+
}
93+
94+
object PartitionPredicateImpl {
95+
96+
def apply(
97+
catalystExpr: CatalystExpression,
98+
partitionSchema: Seq[AttributeReference]): PartitionPredicateImpl = {
99+
if (partitionSchema.isEmpty) {
100+
throw SparkException.internalError(
101+
s"Cannot evaluate partition predicate ${catalystExpr.sql}: partition schema is empty")
102+
}
103+
val partitionNames = partitionSchema.map(_.name).toSet
104+
val refNames = catalystExpr.references.map(_.name).toSet
105+
if (!refNames.subsetOf(partitionNames)) {
106+
throw SparkException.internalError(
107+
s"Cannot evaluate partition predicate ${catalystExpr.sql}: expression references " +
108+
s"${refNames.mkString(", ")} not all in partition columns ${partitionNames.mkString(", ")}")
109+
}
110+
new PartitionPredicateImpl(catalystExpr, partitionSchema)
111+
}
112+
}

0 commit comments

Comments
 (0)