cloud-fan commented on code in PR #58635:
URL: https://github.com/apache/spark/pull/58635#discussion_r3995080333
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -374,24 +424,45 @@ object InjectRuntimeFilter extends Rule[LogicalPlan] with
PredicateHelper with J
allowMaterializedCache = false,
applicationDistinctCount = None)
}
+ extracted.toRight("no selective creation side")
} else {
- None
+ Left("the application side does not qualify")
}
}
- // This checks if there is already a DPP filter, as this rule is called just
after DPP.
+ // Returns the DPP filter on `key` at the top of `plan`, as this rule is
called just after DPP.
@tailrec
- private def hasDynamicPruningSubquery(
- left: LogicalPlan,
- right: LogicalPlan,
- leftKey: Expression,
- rightKey: Expression): Boolean = {
- (left, right) match {
- case (Filter(DynamicPruningSubquery(pruningKey, _, _, _, _, _, _),
plan), _) =>
- pruningKey.fastEquals(leftKey) || hasDynamicPruningSubquery(plan,
right, leftKey, rightKey)
- case (_, Filter(DynamicPruningSubquery(pruningKey, _, _, _, _, _, _),
plan)) =>
- pruningKey.fastEquals(rightKey) ||
- hasDynamicPruningSubquery(left, plan, leftKey, rightKey)
+ private def findDynamicPruning(
+ plan: LogicalPlan,
+ key: Expression): Option[DynamicPruningSubquery] = plan match {
+ case Filter(dpp @ DynamicPruningSubquery(pruningKey, _, _, _, _, _, _),
child) =>
+ if (pruningKey.fastEquals(key)) Some(dpp) else findDynamicPruning(child,
key)
+ case _ => None
+ }
+
+ /**
+ * Whether the DPP filter `exprId` at the top of `prunedSide` reaches the
scan. It is not final
+ * here: `PushDownPredicates` carries it towards the scan later, and
+ * `CleanupDynamicPruningFilters` then keeps it only in a chain of
deterministic projections and
+ * filters directly over the scan. Simulate that with the same pushdown rule
rather than
+ * predicting what it can push through. The cleanup also folds a filter into
an equality on the
+ * same key already sitting on the scan, which prunes at least as much.
+ */
+ private def dynamicPruningReachesScan(prunedSide: LogicalPlan, exprId:
ExprId): Boolean = {
+ var plan = prunedSide
+ var pushed = PushDownPredicates(plan)
Review Comment:
**Non-blocking (P2):** This survival forecast differs from the optimizer
pipeline that will determine the DPP's actual fate in two ways: it invokes
`PushDownPredicates` even when that rule is configured in
`spark.sql.optimizer.excludedRules`, and the final check accepts every
`LeafNode` although `CleanupDynamicPruningFilters` preserves DPP only over its
supported file, Hive, and V2 scan relations. In either case this method can
credit DPP, suppress Bloom and the not-applied warning, and then have cleanup
remove the filter. Please base hint credit on the effective pushdown/cleanup
result, or otherwise make both the configured-rule behavior and supported-scan
predicate exactly match the downstream pipeline.
**Recommended change:** Move the DPP credit and Bloom-fallback decision to a
point after the real DPP pushdown and CleanupDynamicPruningFilters batches, so
InjectRuntimeFilter observes only predicates the configured pipeline retained.
Remove the private PushDownPredicates simulation and LeafNode approximation,
and preserve any required subquery-merge/column-pruning preparation for newly
injected Bloom filters at the new position. Add unsupported-Range-union and
excluded-PushDownPredicates regressions against the final optimized plan.
**Why this works:** The decision will inspect actual surviving
DynamicPruningSubquery nodes rather than predict them, eliminating both
configuration divergence and scan-type drift before it suppresses Bloom or a
warning.
**Scope:**
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer,
sql/core/src/main/scala/org/apache/spark/sql/execution,
sql/core/src/test/scala/org/apache/spark/sql
**Compatibility:** DPP remains preferred when it truly survives, and the
hint still never falls back to filtering in the opposite direction.
**Risks:** Moving injection later can bypass optimizer work that current
Bloom subqueries or filters rely on. Batch reordering can affect subquery
merging and fixed-point assumptions.
**Constraints:** Do not make catalyst depend directly on sql/core scan
implementation classes. Respect spark.sql.optimizer.excludedRules exactly as
the real batch does. A surviving DPP still takes effect as the sole mechanism
for the hinted join; otherwise Bloom and warning gates remain unchanged.
**Success:** A DPP copy on Range or another cleanup-unsupported leaf cannot
credit the hint. Excluding PushDownPredicates cannot make the forecast differ
from the final optimized plan. Every credited DPP survives in the final plan;
otherwise a Bloom filter is present or the hint has a reason-bearing warning.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -644,3 +683,214 @@ private[sql] object NullAwareAntiJoinPlanning extends
JoinSelectionHelper {
}
}
}
+
+/**
+ * Decides whether a plan can serve as the source of a runtime filter on a
join key. A runtime
+ * filter evaluates its source separately from the join, so the key values the
source produces
+ * must be the same in both evaluations, or the filter could prune rows the
join itself matches.
+ * `deterministic` is not enough for that: Spark flags order-dependent
computations such as
+ * first, last, row_number or an unordered LIMIT as deterministic.
+ *
+ * The plan is walked bottom-up, tracking the output attributes whose values
are unstable: they
+ * come from a non-deterministic expression, an order-dependent aggregate or
window function, or
+ * an expression over such an attribute. The plan is rejected outright when
its row set is
+ * unstable: a filter, join condition or grouping consumes an unstable
attribute, an inner
+ * generate uses an unstable generator, a sample is unseeded or over anything
but a scan, or a
+ * limit is over anything but a total order; and, with its own reason, when an
operator's effect
+ * on the rows is not analyzed. The source qualifies when the key references
no unstable
+ * attribute. Values that are unstable but only carried to the output (a
`first(name)` next to a
+ * `GROUP BY id`, a row number next to the key) do not disqualify it.
+ */
+private[optimizer] object RuntimeFilterSourceAnalysis extends AliasHelper {
+
+ /**
+ * @param unstable output attributes whose values depend on evaluation order
or on chance.
+ * @param totallyOrdered whether the rows are in a total order on stable
keys, so that a limit
+ * over them keeps the same rows every time.
+ */
+ private case class Taint(unstable: AttributeSet, totallyOrdered: Boolean =
false)
+
+ private val NotRepeatable =
+ "the hinted side may produce different rows or join keys when evaluated
again"
+
+ /** Why `plan` is not a repeatable source of `key`, or None when it is. */
+ def rejection(plan: LogicalPlan, key: Expression): Option[String] = {
+ if (plan.isStreaming) {
+ Some("the hinted side is a stream")
+ } else if (!key.deterministic) {
+ Some(NotRepeatable)
+ } else {
+ analyze(plan) match {
+ case Left(reason) => Some(reason)
+ case Right(t) if key.references.intersect(t.unstable).nonEmpty =>
Some(NotRepeatable)
+ case _ => None
+ }
+ }
+ }
+
+ /**
+ * Whether `e` yields the same value on every evaluation. A subquery counts
as deterministic
+ * when its plan is, which is the very check this analysis replaces, so its
plan is analyzed
+ * too.
+ */
+ private def isStable(e: Expression, unstable: AttributeSet): Boolean = {
+ e.deterministic && e.references.intersect(unstable).isEmpty && !e.exists {
+ case s: SubqueryExpression => s.plan.isStreaming ||
+ analyze(s.plan).forall(t =>
s.plan.outputSet.intersect(t.unstable).nonEmpty)
+ case _ => false
+ }
+ }
+
+ /** Returns the taint of `plan`'s output, or the reason its row set is not
repeatable. */
+ private def analyze(plan: LogicalPlan): Either[String, Taint] = plan match {
+ case _: LeafNode => Right(Taint(AttributeSet.empty))
Review Comment:
**Blocking (P1):** `LeafNode` is not a repeatability guarantee. In
particular, `SparkSession.createDataFrame(rdd, ...)` produces a `LogicalRDD`,
and an uncheckpointed RDD can have opaque `mapPartitions` lineage that returns
a different subset on each computation even though all Catalyst attributes
appear stable. This accepts that source, after which the runtime-filter
subquery and join can recompute it and observe different key sets. Please fail
closed on opaque leaves and admit only sources with an explicit
repeatability/materialization guarantee, such as supported scans, local
materialization, or an actually checkpointed `LogicalRDD`; the seeded-sample
check should use the same guarantee.
**Recommended change:** Replace blanket LeafNode acceptance with an explicit
set of source classes whose repeated evaluation is stable, including local
materialization and supported scans, and accept LogicalRDD only when its input
is actually checkpointed. Reuse the same source predicate for seeded Sample's
over-scan check. Add uncheckpointed/checkpointed LogicalRDD controls for both
Bloom and DPP.
**Why this works:** Opaque sources will fail closed unless they expose a
concrete materialization or scan guarantee, preventing independent filter and
join evaluations from reading different key sets.
**Scope:**
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer,
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning,
sql/core/src/test/scala/org/apache/spark/sql
**Compatibility:** Preserve behavior outside the stated repair boundary.
**Risks:** A source-class allowlist can drift as new scan leaf types are
added. Cross-module source classes may require a shared capability predicate
rather than catalyst depending on sql/core implementations.
**Constraints:** Do not equate LeafNode with stable ordering or
repeatability. A lazy checkpoint that has not materialized must remain
ineligible. Seeded Sample must use the same positive source guarantee.
**Success:** An uncheckpointed LogicalRDD with nondeterministic
mapPartitions lineage cannot source Bloom or hinted DPP. An actually
checkpointed LogicalRDD and supported catalog scan retain intended eligibility.
A seeded Sample over an opaque leaf is rejected rather than inheriting blanket
leaf acceptance.
##########
docs/sql-ref-syntax-qry-select-hints.md:
##########
@@ -172,6 +172,55 @@ SELECT /*+ SHUFFLE_REPLICATE_NL(t1) */ * FROM t1 INNER
JOIN t2 ON t1.key = t2.ke
SELECT /*+ BROADCAST(t1), MERGE(t1, t2) */ * FROM t1 INNER JOIN t2 ON t1.key =
t2.key;
```
+### Runtime Filter Hints
+
+A runtime filter prunes one side of a join using the join key values found on
the other side, so
+rows that cannot match are discarded early. Spark decides on its own whether
such a filter is worth
+building, based on estimates of how much data it would save. Runtime filter
hints let users make
+that decision instead, for the cases where the estimates are unavailable or
wrong.
+
+#### Runtime Filter Hints Types
+
+* **RUNTIME_FILTER**
+
+ Suggests that Spark build a runtime filter from the hinted relation and
use it to prune the
+ other side of the join. Use it when the hinted side is known to match only
a small fraction
+ of the other side, but Spark does not choose a runtime filter on its own,
typically because
+ table statistics are missing or misleading. The hinted side may be any
relation or subquery,
+ and is never itself pruned. The hint does not choose how the pruning is
done; Spark picks the
+ mechanism. `RUNTIME_FILTER` can be combined with a join strategy hint.
+
+The hint overrides Spark's cost estimates, but not the requirements that make
a runtime filter
+correct, so Spark is not guaranteed to follow it. A side that join semantics
forbid pruning is
+never pruned, e.g. the left side of a `LEFT OUTER` join, whose rows must all
appear in the output.
+The hinted side must produce the same rows each time it is evaluated, since
building the filter
Review Comment:
**Nit (P3):** This says the hinted side must produce the same rows on every
evaluation, but the implementation intentionally accepts unstable values
carried only in non-key columns, for example a stable `GROUP BY id` key
alongside `first(name)`. Please describe the actual eligibility contract
instead: row membership and the selected join-key values must be repeatable;
unrelated carried output values need not be identical.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -644,3 +683,214 @@ private[sql] object NullAwareAntiJoinPlanning extends
JoinSelectionHelper {
}
}
}
+
+/**
+ * Decides whether a plan can serve as the source of a runtime filter on a
join key. A runtime
+ * filter evaluates its source separately from the join, so the key values the
source produces
+ * must be the same in both evaluations, or the filter could prune rows the
join itself matches.
+ * `deterministic` is not enough for that: Spark flags order-dependent
computations such as
+ * first, last, row_number or an unordered LIMIT as deterministic.
+ *
+ * The plan is walked bottom-up, tracking the output attributes whose values
are unstable: they
+ * come from a non-deterministic expression, an order-dependent aggregate or
window function, or
+ * an expression over such an attribute. The plan is rejected outright when
its row set is
+ * unstable: a filter, join condition or grouping consumes an unstable
attribute, an inner
+ * generate uses an unstable generator, a sample is unseeded or over anything
but a scan, or a
+ * limit is over anything but a total order; and, with its own reason, when an
operator's effect
+ * on the rows is not analyzed. The source qualifies when the key references
no unstable
+ * attribute. Values that are unstable but only carried to the output (a
`first(name)` next to a
+ * `GROUP BY id`, a row number next to the key) do not disqualify it.
+ */
+private[optimizer] object RuntimeFilterSourceAnalysis extends AliasHelper {
+
+ /**
+ * @param unstable output attributes whose values depend on evaluation order
or on chance.
+ * @param totallyOrdered whether the rows are in a total order on stable
keys, so that a limit
+ * over them keeps the same rows every time.
+ */
+ private case class Taint(unstable: AttributeSet, totallyOrdered: Boolean =
false)
+
+ private val NotRepeatable =
+ "the hinted side may produce different rows or join keys when evaluated
again"
+
+ /** Why `plan` is not a repeatable source of `key`, or None when it is. */
+ def rejection(plan: LogicalPlan, key: Expression): Option[String] = {
+ if (plan.isStreaming) {
+ Some("the hinted side is a stream")
+ } else if (!key.deterministic) {
+ Some(NotRepeatable)
+ } else {
+ analyze(plan) match {
+ case Left(reason) => Some(reason)
+ case Right(t) if key.references.intersect(t.unstable).nonEmpty =>
Some(NotRepeatable)
+ case _ => None
+ }
+ }
+ }
+
+ /**
+ * Whether `e` yields the same value on every evaluation. A subquery counts
as deterministic
+ * when its plan is, which is the very check this analysis replaces, so its
plan is analyzed
+ * too.
+ */
+ private def isStable(e: Expression, unstable: AttributeSet): Boolean = {
+ e.deterministic && e.references.intersect(unstable).isEmpty && !e.exists {
+ case s: SubqueryExpression => s.plan.isStreaming ||
+ analyze(s.plan).forall(t =>
s.plan.outputSet.intersect(t.unstable).nonEmpty)
+ case _ => false
+ }
+ }
+
+ /** Returns the taint of `plan`'s output, or the reason its row set is not
repeatable. */
+ private def analyze(plan: LogicalPlan): Either[String, Taint] = plan match {
+ case _: LeafNode => Right(Taint(AttributeSet.empty))
+
+ case p: Project => analyze(p.child).map { t =>
+ Taint(
+ AttributeSet(p.projectList.filterNot(isStable(_,
t.unstable)).map(_.toAttribute)),
+ t.totallyOrdered)
+ }
+
+ case f: Filter => analyze(f.child).flatMap { t =>
+ if (isStable(f.condition, t.unstable)) Right(t) else Left(NotRepeatable)
+ }
+
+ case j: Join => analyze(j.left).flatMap { l =>
+ analyze(j.right).flatMap { r =>
+ val unstable = l.unstable ++ r.unstable
+ if (j.condition.forall(isStable(_, unstable))) {
+ Right(Taint(unstable))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+ }
+
+ case a: Aggregate => analyze(a.child).map { t =>
+ // Grouping on an unstable value changes which rows form a group, so
every aggregate result
+ // then depends on it; a grouping expression's own value is as stable as
its input.
+ val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+ val unstable = a.aggregateExpressions.filter { e =>
+ !isStable(e, t.unstable) ||
+ (e.exists(_.isInstanceOf[AggregateExpression]) &&
+ (!stableGroups || !isOrderIrrelevantAggregate(e)))
+ }
+ Taint(AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case w: Window => analyze(w.child).map { t =>
+ val stablePartitions = w.partitionSpec.forall(isStable(_, t.unstable))
+ val unstable = w.windowExpressions.filter { e =>
+ !stablePartitions || !isStable(e, t.unstable) ||
!isOrderIrrelevantWindow(e)
+ }
+ Taint(t.unstable ++ AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case u: Union =>
+ val taints = u.children.map(analyze)
+ taints.collectFirst { case Left(reason) => Left(reason) }.getOrElse {
+ val unstable = u.output.zipWithIndex.collect {
+ case (attr, i) if u.children.zip(taints).exists {
+ case (child, Right(taint)) =>
taint.unstable.contains(child.output(i))
+ case _ => false
+ } => attr
+ }
+ Right(Taint(AttributeSet(unstable)))
+ }
+
+ // An inner generate drops the rows for which the generator yields
nothing, so an unstable
+ // generator changes the row set; an outer generate keeps them.
+ case g: Generate => analyze(g.child).flatMap { t =>
+ if (isStable(g.generator, t.unstable)) {
+ Right(t)
+ } else if (g.outer) {
Review Comment:
**Blocking (P1):** An unstable outer generator does not necessarily preserve
multiplicity: it can emit a different positive number of rows on each
evaluation while still preserving every input row. This branch records only
unstable `generatorOutput`, so a later `count(*)` (or an aggregate over an
otherwise stable child value) references no tainted attribute and is accepted
as a join key. The filter build and join can then compute different keys and
the runtime filter can drop the matching row. Please propagate a
row-multiplicity instability through this analysis and make downstream
multiplicity-dependent aggregates/windows reject it, while retaining the safe
case where the selected key is independent of that multiplicity.
**Recommended change:** Add an unstable-multiplicity component to Taint. Set
it for an unstable outer Generate (and any other modeled operator whose
repeatable membership can still change duplicate counts), propagate it through
row-preserving operators, and make multiplicity-sensitive Aggregate and Window
outputs unstable or reject the source. Add Bloom and DPP regressions with
count(*) above a nondeterministic outer generator.
**Why this works:** The analysis will no longer infer a stable aggregate
merely because its expression references no generatorOutput attribute; it will
explicitly account for the number of input rows contributing to each
aggregate/window result.
**Scope:**
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer,
sql/core/src/test/scala/org/apache/spark/sql
**Compatibility:** Continue accepting outer generators whose instability is
confined to unused generator outputs and multiplicity-independent selected keys.
**Risks:** An incomplete transfer table could clear multiplicity taint
through another row-expanding or row-collapsing operator. Over-propagation
could conservatively decline safe hints.
**Constraints:** Use the same eligibility result for Bloom and DPP. Preserve
acceptance of an outer generator when the selected key and row membership do
not depend on its varying multiplicity.
**Success:** count(*) and equivalent multiplicity-sensitive keys above an
unstable outer generator are rejected. A stable child key carried through the
same outer generator remains eligible. The hinted and unhinted query return the
same rows for the regression shape.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -644,3 +683,214 @@ private[sql] object NullAwareAntiJoinPlanning extends
JoinSelectionHelper {
}
}
}
+
+/**
+ * Decides whether a plan can serve as the source of a runtime filter on a
join key. A runtime
+ * filter evaluates its source separately from the join, so the key values the
source produces
+ * must be the same in both evaluations, or the filter could prune rows the
join itself matches.
+ * `deterministic` is not enough for that: Spark flags order-dependent
computations such as
+ * first, last, row_number or an unordered LIMIT as deterministic.
+ *
+ * The plan is walked bottom-up, tracking the output attributes whose values
are unstable: they
+ * come from a non-deterministic expression, an order-dependent aggregate or
window function, or
+ * an expression over such an attribute. The plan is rejected outright when
its row set is
+ * unstable: a filter, join condition or grouping consumes an unstable
attribute, an inner
+ * generate uses an unstable generator, a sample is unseeded or over anything
but a scan, or a
+ * limit is over anything but a total order; and, with its own reason, when an
operator's effect
+ * on the rows is not analyzed. The source qualifies when the key references
no unstable
+ * attribute. Values that are unstable but only carried to the output (a
`first(name)` next to a
+ * `GROUP BY id`, a row number next to the key) do not disqualify it.
+ */
+private[optimizer] object RuntimeFilterSourceAnalysis extends AliasHelper {
+
+ /**
+ * @param unstable output attributes whose values depend on evaluation order
or on chance.
+ * @param totallyOrdered whether the rows are in a total order on stable
keys, so that a limit
+ * over them keeps the same rows every time.
+ */
+ private case class Taint(unstable: AttributeSet, totallyOrdered: Boolean =
false)
+
+ private val NotRepeatable =
+ "the hinted side may produce different rows or join keys when evaluated
again"
+
+ /** Why `plan` is not a repeatable source of `key`, or None when it is. */
+ def rejection(plan: LogicalPlan, key: Expression): Option[String] = {
+ if (plan.isStreaming) {
+ Some("the hinted side is a stream")
+ } else if (!key.deterministic) {
+ Some(NotRepeatable)
+ } else {
+ analyze(plan) match {
+ case Left(reason) => Some(reason)
+ case Right(t) if key.references.intersect(t.unstable).nonEmpty =>
Some(NotRepeatable)
+ case _ => None
+ }
+ }
+ }
+
+ /**
+ * Whether `e` yields the same value on every evaluation. A subquery counts
as deterministic
+ * when its plan is, which is the very check this analysis replaces, so its
plan is analyzed
+ * too.
+ */
+ private def isStable(e: Expression, unstable: AttributeSet): Boolean = {
+ e.deterministic && e.references.intersect(unstable).isEmpty && !e.exists {
+ case s: SubqueryExpression => s.plan.isStreaming ||
+ analyze(s.plan).forall(t =>
s.plan.outputSet.intersect(t.unstable).nonEmpty)
+ case _ => false
+ }
+ }
+
+ /** Returns the taint of `plan`'s output, or the reason its row set is not
repeatable. */
+ private def analyze(plan: LogicalPlan): Either[String, Taint] = plan match {
+ case _: LeafNode => Right(Taint(AttributeSet.empty))
+
+ case p: Project => analyze(p.child).map { t =>
+ Taint(
+ AttributeSet(p.projectList.filterNot(isStable(_,
t.unstable)).map(_.toAttribute)),
+ t.totallyOrdered)
+ }
+
+ case f: Filter => analyze(f.child).flatMap { t =>
+ if (isStable(f.condition, t.unstable)) Right(t) else Left(NotRepeatable)
+ }
+
+ case j: Join => analyze(j.left).flatMap { l =>
+ analyze(j.right).flatMap { r =>
+ val unstable = l.unstable ++ r.unstable
+ if (j.condition.forall(isStable(_, unstable))) {
+ Right(Taint(unstable))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+ }
+
+ case a: Aggregate => analyze(a.child).map { t =>
+ // Grouping on an unstable value changes which rows form a group, so
every aggregate result
+ // then depends on it; a grouping expression's own value is as stable as
its input.
+ val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+ val unstable = a.aggregateExpressions.filter { e =>
+ !isStable(e, t.unstable) ||
+ (e.exists(_.isInstanceOf[AggregateExpression]) &&
+ (!stableGroups || !isOrderIrrelevantAggregate(e)))
+ }
+ Taint(AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case w: Window => analyze(w.child).map { t =>
+ val stablePartitions = w.partitionSpec.forall(isStable(_, t.unstable))
+ val unstable = w.windowExpressions.filter { e =>
+ !stablePartitions || !isStable(e, t.unstable) ||
!isOrderIrrelevantWindow(e)
+ }
+ Taint(t.unstable ++ AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case u: Union =>
+ val taints = u.children.map(analyze)
+ taints.collectFirst { case Left(reason) => Left(reason) }.getOrElse {
+ val unstable = u.output.zipWithIndex.collect {
+ case (attr, i) if u.children.zip(taints).exists {
+ case (child, Right(taint)) =>
taint.unstable.contains(child.output(i))
+ case _ => false
+ } => attr
+ }
+ Right(Taint(AttributeSet(unstable)))
+ }
+
+ // An inner generate drops the rows for which the generator yields
nothing, so an unstable
+ // generator changes the row set; an outer generate keeps them.
+ case g: Generate => analyze(g.child).flatMap { t =>
+ if (isStable(g.generator, t.unstable)) {
+ Right(t)
+ } else if (g.outer) {
+ Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput)))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+
+ case e: Expand => analyze(e.child).map { t =>
+ val unstable = e.output.zipWithIndex.collect {
+ case (attr, i) if e.projections.exists(p => !isStable(p(i),
t.unstable)) => attr
+ }
+ Taint(AttributeSet(unstable))
+ }
+
+ case s: Sort => analyze(s.child).map { t =>
+ val stableOrder = s.order.forall(o => isStable(o.child, t.unstable))
+ Taint(t.unstable, totallyOrdered = s.global && stableOrder &&
+ sortedOnUniqueKey(s.child, s.order.map(_.child)))
+ }
+
+ // A limit keeps whichever rows arrive first unless the order is total.
+ case l @ (_: GlobalLimit | _: LocalLimit | _: Offset | _: Tail) =>
+ analyze(l.children.head).flatMap { t =>
+ if (t.totallyOrdered) Right(t) else Left(NotRepeatable)
+ }
+
+ // A sample draws a fresh seed per evaluation unless one is given, and
depends on the input
+ // row order even then: only a seeded sample over a scan, through
projections and filters
+ // that keep the row order, is repeatable.
+ case s: Sample =>
+ val overScan = NodeWithOnlyDeterministicProjectAndFilter.unapply(s.child)
+ .exists(_.isInstanceOf[LeafNode])
+ if (s.seed.isDefined && overScan) analyze(s.child) else
Left(NotRepeatable)
+
+ // The rows and their values are unchanged; a shuffle loses the order.
+ case _: Distinct | _: SubqueryAlias | _: Repartition | _:
RepartitionByExpression |
+ _: RebalancePartitions =>
+ analyze(plan.children.head).map(t => Taint(t.unstable))
+
+ // Observed metrics do not touch the rows.
+ case c: CollectMetrics => analyze(c.child)
Review Comment:
**Non-blocking (P2):** `CollectMetrics` is row-preserving, but copying this
subtree into the runtime-filter subquery duplicates a user-visible side effect.
A `Dataset.observe` source then produces two collectors with the same
observation name; `QueryExecution.observedMetrics` combines metrics from the
main plan and subqueries with map overwrite semantics, so the injected
evaluation can replace the join-side value. Please reject a source containing
`CollectMetrics` (with the normal reason-bearing warning), or otherwise ensure
the observation is neither duplicated nor replaced.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -644,3 +683,214 @@ private[sql] object NullAwareAntiJoinPlanning extends
JoinSelectionHelper {
}
}
}
+
+/**
+ * Decides whether a plan can serve as the source of a runtime filter on a
join key. A runtime
+ * filter evaluates its source separately from the join, so the key values the
source produces
+ * must be the same in both evaluations, or the filter could prune rows the
join itself matches.
+ * `deterministic` is not enough for that: Spark flags order-dependent
computations such as
+ * first, last, row_number or an unordered LIMIT as deterministic.
+ *
+ * The plan is walked bottom-up, tracking the output attributes whose values
are unstable: they
+ * come from a non-deterministic expression, an order-dependent aggregate or
window function, or
+ * an expression over such an attribute. The plan is rejected outright when
its row set is
+ * unstable: a filter, join condition or grouping consumes an unstable
attribute, an inner
+ * generate uses an unstable generator, a sample is unseeded or over anything
but a scan, or a
+ * limit is over anything but a total order; and, with its own reason, when an
operator's effect
+ * on the rows is not analyzed. The source qualifies when the key references
no unstable
+ * attribute. Values that are unstable but only carried to the output (a
`first(name)` next to a
+ * `GROUP BY id`, a row number next to the key) do not disqualify it.
+ */
+private[optimizer] object RuntimeFilterSourceAnalysis extends AliasHelper {
+
+ /**
+ * @param unstable output attributes whose values depend on evaluation order
or on chance.
+ * @param totallyOrdered whether the rows are in a total order on stable
keys, so that a limit
+ * over them keeps the same rows every time.
+ */
+ private case class Taint(unstable: AttributeSet, totallyOrdered: Boolean =
false)
+
+ private val NotRepeatable =
+ "the hinted side may produce different rows or join keys when evaluated
again"
+
+ /** Why `plan` is not a repeatable source of `key`, or None when it is. */
+ def rejection(plan: LogicalPlan, key: Expression): Option[String] = {
+ if (plan.isStreaming) {
+ Some("the hinted side is a stream")
+ } else if (!key.deterministic) {
+ Some(NotRepeatable)
+ } else {
+ analyze(plan) match {
+ case Left(reason) => Some(reason)
+ case Right(t) if key.references.intersect(t.unstable).nonEmpty =>
Some(NotRepeatable)
+ case _ => None
+ }
+ }
+ }
+
+ /**
+ * Whether `e` yields the same value on every evaluation. A subquery counts
as deterministic
+ * when its plan is, which is the very check this analysis replaces, so its
plan is analyzed
+ * too.
+ */
+ private def isStable(e: Expression, unstable: AttributeSet): Boolean = {
+ e.deterministic && e.references.intersect(unstable).isEmpty && !e.exists {
+ case s: SubqueryExpression => s.plan.isStreaming ||
+ analyze(s.plan).forall(t =>
s.plan.outputSet.intersect(t.unstable).nonEmpty)
+ case _ => false
+ }
+ }
+
+ /** Returns the taint of `plan`'s output, or the reason its row set is not
repeatable. */
+ private def analyze(plan: LogicalPlan): Either[String, Taint] = plan match {
+ case _: LeafNode => Right(Taint(AttributeSet.empty))
+
+ case p: Project => analyze(p.child).map { t =>
+ Taint(
+ AttributeSet(p.projectList.filterNot(isStable(_,
t.unstable)).map(_.toAttribute)),
+ t.totallyOrdered)
+ }
+
+ case f: Filter => analyze(f.child).flatMap { t =>
+ if (isStable(f.condition, t.unstable)) Right(t) else Left(NotRepeatable)
+ }
+
+ case j: Join => analyze(j.left).flatMap { l =>
+ analyze(j.right).flatMap { r =>
+ val unstable = l.unstable ++ r.unstable
+ if (j.condition.forall(isStable(_, unstable))) {
+ Right(Taint(unstable))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+ }
+
+ case a: Aggregate => analyze(a.child).map { t =>
+ // Grouping on an unstable value changes which rows form a group, so
every aggregate result
+ // then depends on it; a grouping expression's own value is as stable as
its input.
+ val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+ val unstable = a.aggregateExpressions.filter { e =>
+ !isStable(e, t.unstable) ||
+ (e.exists(_.isInstanceOf[AggregateExpression]) &&
+ (!stableGroups || !isOrderIrrelevantAggregate(e)))
+ }
+ Taint(AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case w: Window => analyze(w.child).map { t =>
+ val stablePartitions = w.partitionSpec.forall(isStable(_, t.unstable))
+ val unstable = w.windowExpressions.filter { e =>
+ !stablePartitions || !isStable(e, t.unstable) ||
!isOrderIrrelevantWindow(e)
+ }
+ Taint(t.unstable ++ AttributeSet(unstable.map(_.toAttribute)))
+ }
+
+ case u: Union =>
+ val taints = u.children.map(analyze)
+ taints.collectFirst { case Left(reason) => Left(reason) }.getOrElse {
+ val unstable = u.output.zipWithIndex.collect {
+ case (attr, i) if u.children.zip(taints).exists {
+ case (child, Right(taint)) =>
taint.unstable.contains(child.output(i))
+ case _ => false
+ } => attr
+ }
+ Right(Taint(AttributeSet(unstable)))
+ }
+
+ // An inner generate drops the rows for which the generator yields
nothing, so an unstable
+ // generator changes the row set; an outer generate keeps them.
+ case g: Generate => analyze(g.child).flatMap { t =>
+ if (isStable(g.generator, t.unstable)) {
+ Right(t)
+ } else if (g.outer) {
+ Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput)))
+ } else {
+ Left(NotRepeatable)
+ }
+ }
+
+ case e: Expand => analyze(e.child).map { t =>
+ val unstable = e.output.zipWithIndex.collect {
+ case (attr, i) if e.projections.exists(p => !isStable(p(i),
t.unstable)) => attr
+ }
+ Taint(AttributeSet(unstable))
+ }
+
+ case s: Sort => analyze(s.child).map { t =>
+ val stableOrder = s.order.forall(o => isStable(o.child, t.unstable))
+ Taint(t.unstable, totallyOrdered = s.global && stableOrder &&
+ sortedOnUniqueKey(s.child, s.order.map(_.child)))
+ }
+
+ // A limit keeps whichever rows arrive first unless the order is total.
+ case l @ (_: GlobalLimit | _: LocalLimit | _: Offset | _: Tail) =>
+ analyze(l.children.head).flatMap { t =>
+ if (t.totallyOrdered) Right(t) else Left(NotRepeatable)
+ }
+
+ // A sample draws a fresh seed per evaluation unless one is given, and
depends on the input
+ // row order even then: only a seeded sample over a scan, through
projections and filters
+ // that keep the row order, is repeatable.
+ case s: Sample =>
+ val overScan = NodeWithOnlyDeterministicProjectAndFilter.unapply(s.child)
+ .exists(_.isInstanceOf[LeafNode])
+ if (s.seed.isDefined && overScan) analyze(s.child) else
Left(NotRepeatable)
+
+ // The rows and their values are unchanged; a shuffle loses the order.
+ case _: Distinct | _: SubqueryAlias | _: Repartition | _:
RepartitionByExpression |
+ _: RebalancePartitions =>
+ analyze(plan.children.head).map(t => Taint(t.unstable))
+
+ // Observed metrics do not touch the rows.
+ case c: CollectMetrics => analyze(c.child)
+
+ // Anything else, e.g. a typed operator or a script transformation, is not
analyzed.
+ case _ =>
+ Left(s"the hinted side contains ${plan.nodeName}, which cannot be
checked for repeatability")
+ }
+
+ /**
+ * Whether an aggregate expression's value is independent of the input
order. Spark's own
+ * allowlist covers the SQL functions; a Bloom filter aggregate, which this
rule injects for an
+ * inner join, merges commutatively.
+ */
+ private def isOrderIrrelevantAggregate(e: NamedExpression): Boolean = e
match {
+ case Alias(AggregateExpression(_: BloomFilterAggregate, _, _, _, _), _) =>
true
+ case _ => EliminateSorts.isOrderIrrelevantAggs(Seq(e))
Review Comment:
**Blocking (P1):** This inherited allowlist treats `avg(int)` and
`avg(long)` as order-independent based on their integral input type, but
`Average` accumulates non-decimal inputs in a `Double` sum buffer.
Floating-point addition is not associative, so different input or
partial-buffer merge orders can produce different filter-side and join-side
averages; the filter can then remove a matching row. Please classify `Average`
using its actual accumulation type for this correctness check, conservatively
rejecting integral averages whose buffer is floating point without incidentally
broadening `EliminateSorts` behavior.
--
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]