This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new e2785d4baa46 [SPARK-57858][SQL] Emit BIN BY scaled DISTRIBUTE columns
as produced attributes
e2785d4baa46 is described below
commit e2785d4baa463580c0c2b72997a1fead6d35af87
Author: Nikolina Vraneš <[email protected]>
AuthorDate: Thu Jul 2 20:55:22 2026 +0800
[SPARK-57858][SQL] Emit BIN BY scaled DISTRIBUTE columns as produced
attributes
### What changes were proposed in this pull request?
The `BIN BY` relation operator (SPARK-57133) proportionally rescales its
`DISTRIBUTE UNIFORM` columns. The logical `BinBy` node carried those columns
through `child.output` with the child's own `ExprId`, even though execution
rewrites their values.
This PR makes the rescaled `DISTRIBUTE` columns *produced* attributes with
fresh `ExprId`s (same names, types, nullability, and positions), shadowing the
inputs, mirroring `Generate.generatorOutput`:
- `BinBy` gains a `scaledDistributeColumns` field; `output` swaps each
`DISTRIBUTE` input slot for its scaled counterpart in place, and
`producedAttributes` includes them. The input `distributeColumns` stay on the
node as the executor's read inputs but leave `output`.
- `BinBy.scaledDistributeAttributes` mints the fresh attributes (qualifier
and metadata dropped, matching `expr AS value` computed-value semantics).
- `ResolveBinBy` mints them; `DeduplicateRelations` renews them in both
phases so self-joins over a shared `BinBy` subtree resolve.
The two `BinBy` constructor invariants (`timeZoneId` set iff LTZ range, and
one scaled attribute per `DISTRIBUTE` column) are both internal `assert`s:
neither is reachable from `ResolveBinBy`, which derives `timeZoneId` from the
same `rangeStart` type and mints `scaledDistributeColumns` by mapping over
`distributeColumns`.
### Why are the changes needed?
Catalyst relies on the invariant that the same `ExprId` everywhere implies
the same value. No other operator edits a value under a retained child
attribute (`Generate` / `Window` / `Expand` / `Aggregate` all mint fresh ids
for changed columns). Carrying the rescaled `DISTRIBUTE` column under the
child's `ExprId` violated that: any rule reasoning on `ExprId` (predicate
pushdown, constraint propagation, common-subexpression elimination) could read
the pre-scale value. It is harmless tod [...]
### Does this PR introduce _any_ user-facing change?
No. `BIN BY` is gated off by default (SPARK-57440) and its physical
execution is still stubbed, so the operator is not usable end-to-end yet; this
is an internal analyzer / plan-shape change. The output schema (column names,
types, positions) is unchanged.
### How was this patch tested?
`ResolveBinBySuite`, including new cases that the rescaled `DISTRIBUTE`
columns are produced attributes shadowing the input, that multiple `DISTRIBUTE`
columns are each replaced in place with distinct fresh ids, and that
qualifier/metadata are dropped on the produced column; plus the existing
self-join deduplication regression.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic)
Closes #56930 from vranes/bin-by-distribute-produced-attrs.
Authored-by: Nikolina Vraneš <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit 3d8bbe8f3e37a2544a2ac28109b6dacdcc6aebee)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../sql/catalyst/analysis/BinByResolution.scala | 21 ++++++-
.../catalyst/analysis/DeduplicateRelations.scala | 6 +-
.../spark/sql/catalyst/analysis/ResolveBinBy.scala | 4 +-
.../plans/logical/basicLogicalOperators.scala | 43 ++++++++------
.../sql/catalyst/analysis/ResolveBinBySuite.scala | 69 +++++++++++++++++++++-
5 files changed, 117 insertions(+), 26 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
index 890df8eb83fc..d7dad79b07f6 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
@@ -20,7 +20,8 @@ package org.apache.spark.sql.catalyst.analysis
import scala.collection.mutable
import scala.util.control.NonFatal
-import org.apache.spark.sql.catalyst.expressions.{Attribute, EmptyRow,
Expression, ExprId}
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference, EmptyRow, Expression, ExprId}
+import org.apache.spark.sql.catalyst.plans.logical.BinByOutputAliases
import org.apache.spark.sql.catalyst.util.DateTimeUtils
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.SQLConf
@@ -154,4 +155,22 @@ object BinByResolution {
timeZoneId = if (isLTZ) Some(sessionZone) else None
)
}
+
+ /**
+ * Builds the three appended output attributes (`bin_start`, `bin_end`,
`bin_distribute_ratio`),
+ * applying `aliases`; `rangeType` is the type of `bin_start` / `bin_end`.
+ */
+ def appendedAttributesWithAliases(
+ rangeType: DataType,
+ aliases: BinByOutputAliases): Seq[Attribute] = Seq(
+ AttributeReference(aliases.effectiveBinStart, rangeType, nullable =
true)(),
+ AttributeReference(aliases.effectiveBinEnd, rangeType, nullable = true)(),
+ AttributeReference(aliases.effectiveBinRatio, DoubleType, nullable =
true)())
+
+ /**
+ * Mints a produced output attribute for each DISTRIBUTE input column: same
name, type, and
+ * nullability, but a fresh `ExprId` so the rescaled value is a distinct
attribute from the input.
+ */
+ def scaledDistributeAttributes(distributeColumns: Seq[Attribute]):
Seq[Attribute] =
+ distributeColumns.map(a => AttributeReference(a.name, a.dataType,
a.nullable)())
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
index 57aede9805d7..1fb703814fb9 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
@@ -202,8 +202,9 @@ object DeduplicateRelations extends Rule[LogicalPlan] {
existingRelations,
b,
_.producedAttributes.map(_.exprId.id).toSeq,
- newBinBy => newBinBy.copy(appendedAttributes =
- newBinBy.appendedAttributes.map(_.newInstance())))
+ newBinBy => newBinBy.copy(
+ scaledDistributeColumns =
newBinBy.scaledDistributeColumns.map(_.newInstance()),
+ appendedAttributes =
newBinBy.appendedAttributes.map(_.newInstance())))
case e: Expand =>
deduplicateAndRenew[Expand](
@@ -470,6 +471,7 @@ object DeduplicateRelations extends Rule[LogicalPlan] {
case oldVersion: BinBy
if
oldVersion.producedAttributes.intersect(conflictingAttributes).nonEmpty =>
val newVersion = oldVersion.copy(
+ scaledDistributeColumns =
oldVersion.scaledDistributeColumns.map(_.newInstance()),
appendedAttributes =
oldVersion.appendedAttributes.map(_.newInstance()))
newVersion.copyTagsFrom(oldVersion)
Seq((oldVersion, newVersion))
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
index 9cd225628d64..d90326cc3e0b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
@@ -65,7 +65,8 @@ object ResolveBinBy extends Rule[LogicalPlan] {
originExpr = b.originExpr)
val appendedAttributes =
- BinBy.appendedAttributesWithAliases(parameters.rangeType,
b.outputAliases)
+ BinByResolution.appendedAttributesWithAliases(parameters.rangeType,
b.outputAliases)
+ val scaledDistributeColumns =
BinByResolution.scaledDistributeAttributes(distributeAttributes)
BinBy(
binWidthMicros = parameters.binWidthMicros,
@@ -73,6 +74,7 @@ object ResolveBinBy extends Rule[LogicalPlan] {
rangeEnd = rangeEnd,
originMicros = parameters.originMicros,
distributeColumns = distributeAttributes,
+ scaledDistributeColumns = scaledDistributeColumns,
appendedAttributes = appendedAttributes,
child = child,
timeZoneId = parameters.timeZoneId)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
index 673edaab1368..9ca9897c123b 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
@@ -17,7 +17,6 @@
package org.apache.spark.sql.catalyst.plans.logical
-import org.apache.spark.SparkException
import org.apache.spark.sql.catalyst.{AliasIdentifier, InternalRow,
SQLConfHelper}
import org.apache.spark.sql.catalyst.analysis.{Analyzer, AnsiTypeCoercion,
MultiInstanceRelation, Resolver, TypeCoercion, TypeCoercionBase,
UnresolvedUnaryNode, WidenStatefulOpNullability}
import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat,
CatalogTable}
@@ -1799,8 +1798,13 @@ case class UnresolvedBinBy(
* @param rangeEnd Resolved attribute holding each row's window-end
timestamp.
* @param originMicros Alignment anchor in microseconds since the
epoch: the folded value of
* `ALIGN TO`, or the type-specific default when
the clause is omitted.
- * @param distributeColumns Resolved columns to proportionally redistribute.
- * @param appendedAttributes The three output attributes appended after
`child.output`.
+ * @param distributeColumns Resolved input columns to proportionally
redistribute. Read by the
+ * operator to compute the rescaled values; not
part of `output`.
+ * @param scaledDistributeColumns
+ * Produced output attributes holding the rescaled
values (fresh
+ * `ExprId`s, same names/types as
`distributeColumns`); they replace
+ * `distributeColumns` in `output`.
+ * @param appendedAttributes The three output attributes appended after the
child columns.
* @param child Input relation.
* @param timeZoneId Captured session local time zone for LTZ inputs;
`None` for NTZ.
* Required when `rangeStart.dataType` is
`TimestampType`; must be
@@ -1812,20 +1816,30 @@ case class BinBy(
rangeEnd: Attribute,
originMicros: Long,
distributeColumns: Seq[Attribute],
+ scaledDistributeColumns: Seq[Attribute],
appendedAttributes: Seq[Attribute],
child: LogicalPlan,
timeZoneId: Option[String])
extends UnaryNode {
- if (timeZoneId.isDefined != rangeStart.dataType.isInstanceOf[TimestampType])
{
- throw SparkException.internalError(
- s"timeZoneId must be set iff rangeStart is TIMESTAMP (LTZ); got
rangeStart.dataType=" +
- s"${rangeStart.dataType}, timeZoneId=$timeZoneId")
- }
+ assert(timeZoneId.isDefined ==
rangeStart.dataType.isInstanceOf[TimestampType],
+ s"timeZoneId must be set iff rangeStart is TIMESTAMP (LTZ); got
rangeStart.dataType=" +
+ s"${rangeStart.dataType}, timeZoneId=$timeZoneId")
+
+ assert(distributeColumns.length == scaledDistributeColumns.length,
+ "BinBy requires one scaled attribute per DISTRIBUTE column, got " +
+ s"${distributeColumns.length} distribute columns and " +
+ s"${scaledDistributeColumns.length} scaled attributes")
+
+ // In `output`, each DISTRIBUTE input is replaced by its scaled produced
counterpart.
+ private lazy val distributeReplacements: AttributeMap[Attribute] =
+ AttributeMap(distributeColumns.zip(scaledDistributeColumns))
- override def output: Seq[Attribute] = child.output ++ appendedAttributes
+ override def output: Seq[Attribute] =
+ child.output.map(a => distributeReplacements.getOrElse(a, a)) ++
appendedAttributes
- override def producedAttributes: AttributeSet =
AttributeSet(appendedAttributes)
+ override def producedAttributes: AttributeSet =
+ AttributeSet(scaledDistributeColumns ++ appendedAttributes)
final override val nodePatterns: Seq[TreePattern] = Seq(BIN_BY)
@@ -1833,15 +1847,6 @@ case class BinBy(
copy(child = newChild)
}
-object BinBy {
- def appendedAttributesWithAliases(
- rangeType: DataType,
- aliases: BinByOutputAliases): Seq[Attribute] = Seq(
- AttributeReference(aliases.effectiveBinStart, rangeType, nullable =
true)(),
- AttributeReference(aliases.effectiveBinEnd, rangeType, nullable = true)(),
- AttributeReference(aliases.effectiveBinRatio, DoubleType, nullable =
true)())
-}
-
/**
* A logical plan node for creating a logical limit, which is split into two
separate logical nodes:
* a [[LocalLimit]], which is a partition local limit, followed by a
[[GlobalLimit]].
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
index 43134eed760d..ff44ae657411 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
@@ -47,6 +47,7 @@ class ResolveBinBySuite extends AnalysisTest {
private val tsEndNtz = $"ts_end".timestampNTZ
private val value = $"value".double
private val label = $"label".string
+ private val label2 = $"label2".string
private val ltzChild: LogicalPlan = LocalRelation(tsStart, tsEnd, value,
label)
private val ntzChild: LogicalPlan = LocalRelation(tsStartNtz, tsEndNtz,
value)
@@ -202,6 +203,67 @@ class ResolveBinBySuite extends AnalysisTest {
assert(bi.distributeColumns.map(_.exprId) == Seq(value.exprId))
}
+ test("resolved BinBy emits the DISTRIBUTE column as a produced attribute
replacing the input") {
+ // `value` sits mid-schema (not last) and carries a qualifier + metadata,
so this covers
+ // in-place replacement, produced identity, and the qualifier/metadata
drop in one go.
+ val md = new MetadataBuilder().putString("comment", "a measure").build()
+ val valueMd = AttributeReference("value", DoubleType, nullable = true,
md)()
+ val child = SubqueryAlias("m", LocalRelation(tsStart, tsEnd, valueMd,
label))
+ val bi = ResolveBinBy.apply(
+ unresolved(child = child, distribute = Seq(UnresolvedAttribute(Seq("m",
"value")))))
+ .asInstanceOf[BinBy]
+
+ // The input is read (held in distributeColumns) but not forwarded by
identity.
+ assert(bi.distributeColumns.head.qualifier == Seq("m"))
+ assert(bi.distributeColumns.head.metadata == md)
+ assert(!bi.output.exists(_.exprId == valueMd.exprId))
+
+ // It is replaced at its own position by a fresh-id, same-name produced
attribute.
+ val outValue = bi.output(child.output.indexWhere(_.exprId ==
valueMd.exprId))
+ assert(outValue.name == "value" && outValue.exprId != valueMd.exprId)
+ assert(bi.scaledDistributeColumns.map(_.exprId) == Seq(outValue.exprId))
+ assert(bi.producedAttributes.contains(outValue))
+
+ // The produced (computed) value drops the input's qualifier and metadata.
+ assert(outValue.qualifier.isEmpty && outValue.metadata == Metadata.empty)
+
+ // Forwarded (non-distribute) columns keep their identity.
+ assert(bi.output.exists(_.exprId == label.exprId))
+ assert(bi.output.exists(_.exprId == tsStart.exprId))
+ }
+
+ test("resolved BinBy emits each of multiple DISTRIBUTE columns as a produced
attribute " +
+ "replacing the input") {
+ // `v1`, `v2`, `v3` sit at non-adjacent schema positions with forwarded
columns between
+ // them, so this covers per-slot in-place replacement with distinct fresh
ids.
+ val v1 = AttributeReference("v1", DoubleType, nullable = true)()
+ val v2 = AttributeReference("v2", DoubleType, nullable = true)()
+ val v3 = AttributeReference("v3", DoubleType, nullable = true)()
+ val child = LocalRelation(tsStart, tsEnd, v1, label, v2, label2, v3)
+ val distribute = Seq(v1, v2, v3)
+ val bi = ResolveBinBy.apply(
+ unresolved(child = child, distribute = distribute)).asInstanceOf[BinBy]
+
+ // The inputs are read (held in distributeColumns) but none is forwarded
by identity.
+ assert(bi.distributeColumns.map(_.exprId) == distribute.map(_.exprId))
+ assert(distribute.forall(v => !bi.output.exists(_.exprId == v.exprId)))
+
+ // Each is replaced at its own position by a fresh-id, same-name produced
attribute.
+ val outputs = distribute.map(v =>
bi.output(child.output.indexWhere(_.exprId == v.exprId)))
+ outputs.zip(distribute).foreach { case (out, in) =>
+ assert(out.name == in.name && out.exprId != in.exprId)
+ }
+ assert(outputs.map(_.exprId).distinct.length == distribute.length)
+ assert(bi.scaledDistributeColumns.map(_.exprId) == outputs.map(_.exprId))
+ assert(outputs.forall(bi.producedAttributes.contains))
+
+ // The child portion of `output` keeps the child's column order and names.
+ assert(bi.output.take(child.output.length).map(_.name) ==
child.output.map(_.name))
+ assert(bi.output.exists(_.exprId == label.exprId))
+ assert(bi.output.exists(_.exprId == label2.exprId))
+ assert(bi.output.exists(_.exprId == tsStart.exprId))
+ }
+
test("multipart identifiers disambiguate same-name columns across a JOIN") {
val t1Start = AttributeReference("ts_start", TimestampType, nullable =
true)()
val t1End = AttributeReference("ts_end", TimestampType, nullable = true)()
@@ -330,9 +392,10 @@ class ResolveBinBySuite extends AnalysisTest {
val binBys = analyzed.collect { case b: BinBy => b }
assert(binBys.size == 2, s"expected two BinBy nodes, got ${binBys.size}")
- val appendedExprIds = binBys.flatMap(_.appendedAttributes.map(_.exprId))
- assert(appendedExprIds.distinct.size == appendedExprIds.size,
- "appended BinBy attributes must have distinct exprIds across the two
join sides")
+ val producedExprIds = binBys.flatMap(b =>
+ (b.scaledDistributeColumns ++ b.appendedAttributes).map(_.exprId))
+ assert(producedExprIds.distinct.size == producedExprIds.size,
+ "produced BinBy attributes must have distinct exprIds across the two
join sides")
}
// `super.test` escapes the suite-wide flag-on wrapper; pin the flag off
explicitly.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]