Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.apache.spark.sql.connector.catalog.functions;

import org.apache.spark.annotation.Evolving;
import org.apache.spark.sql.connector.expressions.Literal;

/**
* Base class for user-defined functions that can be 'reduced' on another function.
Expand Down Expand Up @@ -60,6 +61,52 @@
@Evolving
public interface ReducibleFunction<I, O> {

/**
* Generic reducer for parameterized functions (bucket, truncate, etc.).
*
* If this function is 'reducible' on another function, return the {@link Reducer}.
* <p>
* Each parameter is a non-complex {@link Literal} carrying both its value and data type:
* array/map/struct/UDT-typed values are filtered out by Spark and not passed here, but other
* scalar values (e.g. bucket numBuckets, truncate width, or a
* {@code CalendarInterval}) may be. {@link Literal#value()} is Spark's internal representation
* (e.g. {@code UTF8String} for strings, {@code Decimal} for decimals); use
* {@link Literal#dataType()} to interpret it rather than assuming a JVM type.
* <p>
* {@code thisParams} and {@code otherParams} hold each side's own literal parameters and may have
* different lengths -- for example a zero-parameter transform reducing onto a one-parameter one.
* Implementations must check each array's length before indexing into it.
* <p>
* Returning {@code null} means "not reducible for these parameters" and is authoritative:
* Spark consults no other overload. Dispatch order: Spark tries this generalized overload
* first; only if it is not implemented (throws {@link UnsupportedOperationException}) and each
* side has a single non-null integer parameter does Spark fall back to the deprecated
* {@code reducer(int, ReducibleFunction, int)} overload. If every eligible overload throws
* {@link UnsupportedOperationException}, Spark logs an "implements no reducer" warning; any
* other exception is logged and the pair is treated as not reducible (the join falls back to a
* shuffle).
* <p>
* Examples:
* <ul>
* <li>bucket(4, x) and bucket(2, x): thisParams = [4], otherParams = [2]</li>
* <li>truncate(x, 3) and truncate(x, 5): thisParams = [3], otherParams = [5]</li>
* <li>hypothetical range_bucket(x, 0L, 100L, 4): thisParams = [0L, 100L, 4]</li>
* </ul>
*
* @param thisParams literal parameters for this function (may differ in length from otherParams)
* @param otherFunction the other parameterized function
* @param otherParams literal parameters for the other function (may differ in length from
* thisParams)
* @return a reduction function if reducible, null otherwise
* @since 4.3.0
*/
default Reducer<I, O> reducer(
Literal<?>[] thisParams,
ReducibleFunction<?, ?> otherFunction,
Literal<?>[] otherParams) {
throw new UnsupportedOperationException();
}

/**
* This method is for the bucket function.
*
Expand All @@ -78,7 +125,12 @@ public interface ReducibleFunction<I, O> {
* @param otherBucketFunction the other parameterized function
* @param otherNumBuckets parameter for the other function
* @return a reduction function if it is reducible, null if not
* @deprecated as of 4.3.0. Please override
* {@link #reducer(Literal[], ReducibleFunction, Literal[])} instead.
* The new overload supports transforms with any number of parameters of any type
* (e.g. truncate width, multi-arg range buckets), not just a single int.
*/
@Deprecated(since = "4.3.0")
default Reducer<I, O> reducer(
int thisNumBuckets,
ReducibleFunction<?, ?> otherBucketFunction,
Expand All @@ -101,6 +153,6 @@ default Reducer<I, O> reducer(
* @return a reduction function if it is reducible, null if not.
*/
default Reducer<I, O> reducer(ReducibleFunction<?, ?> otherFunction) {
throw new UnsupportedOperationException();
return reducer(new Literal<?>[0], otherFunction, new Literal<?>[0]);
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, LogicalPlan,
import org.apache.spark.sql.connector.catalog.{FunctionCatalog, Identifier}
import org.apache.spark.sql.connector.catalog.functions._
import org.apache.spark.sql.connector.catalog.functions.ScalarFunction.MAGIC_METHOD_NAME
import org.apache.spark.sql.connector.expressions.{BucketTransform, Cast => V2Cast, Expression => V2Expression, FieldReference, GeneralScalarExpression, IdentityTransform, Literal => V2Literal, NamedReference, NamedTransform, NullOrdering => V2NullOrdering, SortDirection => V2SortDirection, SortOrder => V2SortOrder, SortValue, Transform}
import org.apache.spark.sql.connector.expressions.{Cast => V2Cast, Expression => V2Expression, FieldReference, GeneralScalarExpression, IdentityTransform, Literal => V2Literal, NamedReference, NamedTransform, NullOrdering => V2NullOrdering, SortDirection => V2SortDirection, SortOrder => V2SortOrder, SortValue, Transform}
import org.apache.spark.sql.connector.expressions.filter.{AlwaysFalse, AlwaysTrue}
import org.apache.spark.sql.connector.read.{SampleMethod => V2SampleMethod}
import org.apache.spark.sql.errors.DataTypeErrors.toSQLId
Expand Down Expand Up @@ -116,17 +116,6 @@ object V2ExpressionUtils extends SQLConfHelper with Logging {
funCatalogOpt: Option[FunctionCatalog] = None): Option[Expression] = trans match {
case IdentityTransform(ref) =>
Some(resolveRef[NamedExpression](ref, query))
case BucketTransform(numBuckets, refs, sorted)
if sorted.isEmpty && refs.length == 1 && refs.forall(_.isInstanceOf[NamedReference]) =>
val resolvedRefs = refs.map(r => resolveRef[NamedExpression](r, query))
// Create a dummy reference for `numBuckets` here and use that, together with `refs`, to
// look up the V2 function.
val numBucketsRef = AttributeReference("numBuckets", IntegerType, nullable = false)()
funCatalogOpt.flatMap { catalog =>
loadV2FunctionOpt(catalog, "bucket", Seq(numBucketsRef) ++ resolvedRefs).map { bound =>
TransformExpression(bound, resolvedRefs, Some(numBuckets))
}
}
case NamedTransform(name, args) =>
val catalystArgs = args.map(toCatalyst(_, query, funCatalogOpt))
funCatalogOpt.flatMap { catalog =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

package org.apache.spark.sql.catalyst.plans.physical

import scala.annotation.tailrec
import scala.collection.mutable

import org.apache.spark.{SparkException, SparkUnsupportedOperationException}
Expand Down Expand Up @@ -641,19 +640,15 @@ object KeyedPartitioning {

def supportsExpressions(expressions: Seq[Expression]): Boolean = {
def isSupportedTransform(transform: TransformExpression): Boolean = {
transform.children.size == 1 && isReference(transform.children.head)
}

@tailrec
def isReference(e: Expression): Boolean = e match {
case _: Attribute => true
case g: GetStructField => isReference(g.child)
case _ => false
// Should only consider column references, not literals.
val nonLiteralChildren = transform.children.filterNot(_.isInstanceOf[Literal])
// We need exactly one column reference per transform.
nonLiteralChildren.size == 1 && TransformExpression.isColumnRef(nonLiteralChildren.head)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Apply bound-function input casts before evaluating parameterized transforms through SPJ

This gate now admits parameterized transforms whose bound function requests legal implicit casts. BoundFunction.inputTypes() explicitly allows types to differ from those passed to bind, with Spark responsible for casting. However, the scan path stores the raw Catalyst children in TransformExpression, and the identity-vs-transform reducer later binds and directly evaluates that expression without running Analyzer type coercion.

For a concrete case, a connector can report truncate(str_col, Expressions.literal(2.toShort)); binding (StringType, ShortType) to a scalar function that declares (StringType, IntegerType) is legal. This transform passes the new gate, but the synthetic reducer evaluates the raw Short literal through ApplyFunctionExpression. Its SpecificInternalRow(IntegerType) then attempts to cast the boxed Short to Int, raising ClassCastException. Before parameterized transforms were admitted here, this query would fall back to a shuffle.

Please coerce the transform children to function.inputTypes() before direct reducer evaluation (or reject mismatched types from this SPJ path), and add an identity-vs-parameterized-transform regression covering an implicitly cast literal.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunchao Thanks. I was able to reproduce this issue which raises ClassCastException.
I'll work on a fix, and let you know.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunchao I went with the second option you mentioned, rejecting "mismatched types from this SPJ path" rather than coercing, since coercion (even up-cast-only) added a family of edge cases (lossy casts, null→0, overflow→ClassCastException, eval-vs-reducer strategy drift) for a narrow optimization.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Thanks—rejecting the mismatched literal fixes that reproducer, but this still checks only Literal children. BoundFunction.inputTypes() applies to every argument, and identityReducer directly evaluates every raw child through the same unanalyzed path. For example, a connector can legally bind (ShortType, IntegerType) and return a scalar function whose declared inputs are (IntegerType, IntegerType). Because the literal is already an exact IntegerType, literalParamsMatchInputTypes passes; then identityReducer.reduce feeds the boxed Short through ApplyFunctionExpression into SpecificInternalRow(IntegerType), which throws ClassCastException. Please validate every child against its corresponding declared input type before this direct evaluation (or insert the required casts), and add an identity-vs-transform regression with a ShortType column and exact-typed integer literal. The current tests cover only the literal slot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunchao Fixed! identityReducer now validates every child (not just literals) against the declared input types via argsMatchInputTypes (+ exact-arity)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reducer-compatible transforms must not take the raw partition-key equality fast path.

This widened gate now admits parameterized transforms such as xor(x, 1). With spark.sql.sources.v2.bucketing.pushPartValues.enabled=true, spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled=true, and partially clustered distribution disabled, consider two tables containing x IN (0, 1) partitioned by identity(x) and xor(x, 1). Both scans advertise sorted raw partition keys [0, 1], but partition 0 contains x=0 on the identity side and x=1 on the xor side (partition 1 is reversed). KeyedShuffleSpec.isCompatibleWith treats the reducer-compatible expressions plus equal unreduced keys as directly compatible, so EnsureRequirements skips reducer reconciliation and GroupPartitionsExec; a shuffle-free equality join silently returns zero rows instead of two.

Please restrict the raw-key-equality fast path to identical transform semantics, or force compatible-but-different transforms through reduced-key reconciliation. Add end-to-end SPJ regressions for identity-vs-parameterized and parameterized-vs-parameterized transforms whose raw key arrays coincide but whose physical partitions differ.

}

expressions.forall {
case t: TransformExpression if isSupportedTransform(t) => true
case e: Expression if isReference(e) => true
case e: Expression if TransformExpression.isColumnRef(e) => true
case _ => false
}
}
Expand Down Expand Up @@ -1302,24 +1297,58 @@ case class KeyedShuffleSpec(
}
}

private def isExpressionCompatible(left: Expression, right: Expression): Boolean =
/**
* The reducer mapping a raw identity column `col` onto transform `t` (it applies `t` to the
* identity values), or None if not reducible. Single source of the identity-vs-transform
* decision: [[isExpressionCompatible]] derives the gate from it (`.isDefined`) and [[reducers]]
* returns it, so the two cannot drift (a divergence would keep raw keys -> mis-join).
*
* The reducer evals the transform with `col` substituted for its column, so it validates the
* SUBSTITUTED expression's arg types ([[TransformExpression.argsMatchInputTypes]]) -- not t's own
* -- keeping a type mismatch (e.g. a `ShortType` `col` at an `IntegerType` slot) from reaching
* eval and raising a `ClassCastException`.
*/
private def identityReducer(
col: AttributeReference, t: TransformExpression): Option[Reducer[_, _]] = {
// `transform` preserves the root node type, so this is always a TransformExpression; the only
// real gate is argsMatchInputTypes on the substituted (identity) column -- see the doc above.
val reducerExpr =
t.transform { case _: AttributeReference => col }.asInstanceOf[TransformExpression]
if (reducerExpr.argsMatchInputTypes) {
val boundExpr = BindReferences.bindReference(reducerExpr, AttributeSeq(Seq(col)))
Some(new Reducer[Any, Any] {
override def reduce(v: Any): Any = boundExpr.eval(new GenericInternalRow(Array[Any](v)))
override def resultType(): DataType = reducerExpr.dataType
override def displayName(): String = reducerExpr.toString
})
} else {
None
}
}

private def isExpressionCompatible(left: Expression, right: Expression): Boolean = {
def compatibleTransformsAllowed: Boolean =
SQLConf.get.v2BucketingPushPartValuesEnabled &&
!SQLConf.get.v2BucketingPartiallyClusteredDistributionEnabled &&
SQLConf.get.v2BucketingAllowCompatibleTransforms
(left, right) match {
case (_: LeafExpression, _: LeafExpression) => true
case (left: TransformExpression, right: TransformExpression) =>
if (SQLConf.get.v2BucketingPushPartValuesEnabled &&
!SQLConf.get.v2BucketingPartiallyClusteredDistributionEnabled &&
SQLConf.get.v2BucketingAllowCompatibleTransforms) {
if (compatibleTransformsAllowed) {
left.isCompatible(right)
} else {
left.isSameFunction(right)
}
case (_: AttributeReference, _: TransformExpression) |
(_: TransformExpression, _: AttributeReference) =>
SQLConf.get.v2BucketingPushPartValuesEnabled &&
!SQLConf.get.v2BucketingPartiallyClusteredDistributionEnabled &&
SQLConf.get.v2BucketingAllowCompatibleTransforms
// Identity transform on one side, arbitrary transform on the other. Derive the gate from the
// producer (identityReducer): the pair is compatible only if a reducer can actually be built,
// so the gate and reducers cannot drift (a divergence would keep raw keys -> mis-join).
case (col: AttributeReference, t: TransformExpression) =>
compatibleTransformsAllowed && identityReducer(col, t).isDefined
case (t: TransformExpression, col: AttributeReference) =>
compatibleTransformsAllowed && identityReducer(col, t).isDefined
case _ => false
}
}

/**
* Return a set of [[Reducer]] for the partition expressions of this shuffle spec,
Expand All @@ -1341,19 +1370,11 @@ case class KeyedShuffleSpec(
val results = partitioning.expressions.zip(other.partitioning.expressions).map {
case (e1: TransformExpression, e2: TransformExpression) => e1.reducers(e2)

// Identity transform on this side, arbitrary transform on the other side: create a reducer
// that applies the other's transform to the raw identity values. The symmetric case
// Identity transform on this side, arbitrary transform on the other side. The symmetric case
// (TransformExpression, AttributeReference) is handled when the other side calls reducers.
// Each partition expression is guaranteed to have exactly one leaf child (asserted in
// keyPositions), so `a` lives at position 0 in the row we construct.
case (a: AttributeReference, t: TransformExpression) =>
val reducerExpr = t.transform { case _: AttributeReference => a }
val boundExpr = BindReferences.bindReference(reducerExpr, AttributeSeq(Seq(a)))
Some(new Reducer[Any, Any] {
override def reduce(v: Any): Any = boundExpr.eval(new GenericInternalRow(Array[Any](v)))
override def resultType(): DataType = reducerExpr.dataType
override def displayName(): String = reducerExpr.toString
})
// identityReducer is the shared decision the compatibility gate also consults, so the two
// cannot drift.
case (col: AttributeReference, t: TransformExpression) => identityReducer(col, t)

case (_, _) => None
}
Expand All @@ -1375,7 +1396,13 @@ case class KeyedShuffleSpec(

val newExpressions = partitioning.expressions.zip(keyPositions).map {
case (te: TransformExpression, positionSet) =>
te.copy(children = te.children.map(_ => clustering(positionSet.head)))
// Preserve literal parameters (e.g., numBuckets, truncate width)
// while replacing only column references with the new clustering expression
val newChildren = te.children.map {
case l: Literal => l // Keep literals as-is
case _ => clustering(positionSet.head) // Replace column references
}
te.copy(children = newChildren)
case (_, positionSet) => clustering(positionSet.head)
}
KeyedPartitioning(newExpressions, partitioning.partitionKeys, partitioning.isGrouped)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
package org.apache.spark.sql.execution.datasources.v2

import org.apache.spark.sql.catalyst.analysis.{AnsiTypeCoercion, ResolveTimeZone, TypeCoercion}
import org.apache.spark.sql.catalyst.expressions.{Expression, Literal, SortOrder, TransformExpression, V2ExpressionUtils}
import org.apache.spark.sql.catalyst.expressions.{Expression, SortOrder, TransformExpression, V2ExpressionUtils}
import org.apache.spark.sql.catalyst.expressions.V2ExpressionUtils._
import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, RebalancePartitions, RepartitionByExpression, Sort}
import org.apache.spark.sql.catalyst.rules.{Rule, RuleExecutor}
Expand Down Expand Up @@ -96,9 +96,7 @@ object DistributionAndOrderingUtils {
}

private def resolveTransformExpression(expr: Expression): Expression = expr.transform {
case TransformExpression(scalarFunc: ScalarFunction[_], arguments, Some(numBuckets)) =>
V2ExpressionUtils.resolveScalarFunction(scalarFunc, Seq(Literal(numBuckets)) ++ arguments)
case TransformExpression(scalarFunc: ScalarFunction[_], arguments, None) =>
case TransformExpression(scalarFunc: ScalarFunction[_], arguments) =>
V2ExpressionUtils.resolveScalarFunction(scalarFunc, arguments)
}

Expand Down
Loading