cloud-fan commented on code in PR #58635:
URL: https://github.com/apache/spark/pull/58635#discussion_r4250017527


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala:
##########
@@ -285,6 +285,10 @@ case class DataSourceV2ScanRelation(
     s"RelationV2$outputString $nameWithTimeTravelSpec"
   }
 
+  // The scan is planned once, and each of its executions reads what that plan 
describes, as the
+  // two sides of a self-join do.
+  override def isOutputRepeatable: Boolean = true

Review Comment:
   **Non-blocking (P2):** This returns `true` for every scan relation, but 
`V2ScanRelationPushDown` removes a fully pushed `LIMIT`, `OFFSET`, TopN or 
`TABLESAMPLE` from the plan before this rule runs (JDBC pushes `LIMIT` by 
default and reports it as fully pushed for a single-partition read). So `SELECT 
/*+ RUNTIME_FILTER(d) */ * FROM fact f JOIN (SELECT k FROM jdbc.db.dim LIMIT 
100) d ON f.k = d.k` reaches `RuntimeFilterSourceAnalysis` as a bare trusted 
leaf, while the same `LIMIT` over parquet is rejected as not repeatable. The 
Bloom filter subquery (or a hinted standalone DPP subquery) and the join each 
send their own unordered `LIMIT 100` query, and the database may return 
different rows to each, so matching fact rows can be pruned. Scans that 
absorbed only filters and columns are not affected.
   
   **Recommended change:** When V2ScanRelationPushDown removes a fully pushed 
LIMIT, OFFSET, or TopN, record on the DataSourceV2ScanRelation it builds that 
the scan is not repeatable. Conservatively do the same for a pushed sample or 
aggregate, and make isOutputRepeatable return that recorded value. Add DSv2 
join tests for the hinted Bloom filter and DPP paths.
   
   **Why this works:** A defaulted constructor field on 
DataSourceV2ScanRelation carries the absorbed-operator fact from its only 
producer to the leaf contract. Data fields survive copies and rebuilds, so the 
value reaches RuntimeFilterSourceAnalysis unchanged, and the analysis's 
existing leaf case then rejects the scan with the not-repeatable reason.
   
   **Scope:** Carry the absorbed-operator fact from DSv2 pushdown into the scan 
relation's repeatability claim.
   
   **Compatibility:** Pushdown results, scan planning, and EXPLAIN output for 
DSv2 scans are unchanged.
   
   **Risks:** Adding a constructor field to a widely pattern-matched case class 
requires updating positional patterns in rules and tests. Treating every pushed 
aggregate as non-repeatable may reject some hints that are safe.
   
   **Constraints:** Do not change what is pushed down or the resulting scan; 
change only the repeatability claim. Keep catalyst independent of sql/core scan 
implementation classes.
   
   **Success:** A hinted side that reads a DSv2 scan with a fully pushed 
unordered LIMIT, OFFSET, or TopN is reported as not applied with the 
not-repeatable reason, on both the Bloom filter and DPP paths. A DSv2 scan that 
absorbed only filters and columns remains an accepted hinted source.
   
   **Verification:**
   - **Regression:** With a V2 JDBC catalog that fully absorbs a LIMIT, a 
hinted join on that subquery builds no runtime filter and logs the 
not-repeatable warning.
   - **Compatibility:** A hinted join on a V2 table scan with only filter 
pushdown still gets its Bloom filter, or hinted DPP when the other side is 
partitioned, and returns the unhinted rows.
   - **Compile:** Every construction and positional pattern of 
DataSourceV2ScanRelation remains well-typed with the new defaulted field.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala:
##########
@@ -411,55 +481,158 @@ object InjectRuntimeFilter extends Rule[LogicalPlan] 
with PredicateHelper with J
   private def tryInjectRuntimeFilter(plan: LogicalPlan): LogicalPlan = {
     var filterCounter = 0
     val numFilterThreshold = 
conf.getConf(SQLConf.RUNTIME_FILTER_NUMBER_THRESHOLD)
+    val bloomFilterEnabled = conf.runtimeFilterBloomFilterEnabled
     plan transformUp {
+      case join: Join if join.getTagValue(HINT_DECIDED).isDefined => join

Review Comment:
   **Nit (P3):** `HINT_DECIDED` is a `TreeNodeTag`, which does not survive a 
rebuild of this join. Between the subquery pass and the main-plan pass, 
`PartitionPruning` can insert a DPP filter above a hoisted IN subquery (for 
example `bf1.c1 IN (SELECT /*+ RUNTIME_FILTER(bf3) */ p.f5 FROM bf5part p JOIN 
bf3 ON p.c5 = bf3.c3)` with a selective filter on `bf1`), and 
`PushPredicateThroughJoin` in the DPP pushdown batch builds a new 
`Join(newLeft, newRight, joinType, newJoinCond, hint)` without the old join's 
tags. This rule then revisits the hinted join, finds the Bloom filter it 
injected earlier, and warns that the hint was not applied ('a runtime filter on 
the join key already exists') although it was. Recording the decision in the 
hint data, which every rebuild passes through, would make it durable; it should 
stay out of `toString` and EXPLAIN.
   
   **Verification:**
   - **Regression:** An IN subquery containing a hinted join, whose semi-join 
side receives a DPP filter pushed through that join, produces exactly one Bloom 
filter and no not-applied warning.
   - **Compatibility:** Existing hint composition and subquery warning-count 
tests keep their current results.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala:
##########
@@ -306,10 +316,24 @@ object PartitionPruning extends Rule[LogicalPlan] with 
PredicateHelper with Join
    * meet the following requirements:
    *   (1) it can not be a stream
    *   (2) it needs to contain a selective predicate or a cheaply-recomputable 
materialized input
+   *
+   * (2) is evidence that pruning pays off, which a [[RuntimeFilterHint]] on 
the filtering side
+   * (`hinted`) supplies directly. A hinted side only has to be a repeatable 
source of the
+   * filtering key, see `JoinSelectionHelper.isRepeatableRuntimeFilterSource`, 
since DPP
+   * re-evaluates it. It must not contain a Python UDF either: the filtering 
side is planned as a
+   * standalone subquery without the Python UDF extraction the main plan gets, 
so a Python UDF
+   * could not be evaluated there.
    */
-  private def hasPartitionPruningFilter(plan: LogicalPlan): Boolean = {
-    !plan.isStreaming &&
-      (hasSelectivePredicate(plan) || 
isCheaplyRecomputableMaterializedPlan(plan))
+  private def hasPartitionPruningFilter(
+      plan: LogicalPlan,
+      hinted: Boolean,
+      filteringKey: Expression): Boolean = {
+    if (hinted) {
+      isRepeatableRuntimeFilterSource(plan, filteringKey) && 
!plan.containsPattern(PYTHON_UDF)

Review Comment:
   **Non-blocking (P2):** This guard, and the same 
`containsPattern(PYTHON_UDF)` check in `InjectRuntimeFilter.injectBloomFilter`, 
stand in for 'nothing in the copied side needs the main plan's UDF extraction', 
but only `PythonUDF`, `PythonUDAF` and `PythonAggregate` carry `PYTHON_UDF`. A 
Python UDTF appears as `Generate(PythonUDTF, ...)`, an `UnevaluableGenerator` 
without that pattern, and external UDFs carry `EXTERNAL_UDF`. 
`RuntimeFilterSourceAnalysis` accepts a deterministic UDTF generator, 
`ExtractPythonUDTFs` and `PlanExternalUDFs` rewrite only the main plan, and the 
injected subquery is planned by `QueryExecution.prepareExecutedPlan` without 
the optimizer. So `SELECT /*+ RUNTIME_FILTER(u) */ * FROM fact JOIN my_udtf(1) 
u ON fact.k = u.k` fails with an internal error when the subquery runs, instead 
of being reported as not applied. One shared check in the eligibility predicate 
covering `PYTHON_UDF`, `Generate(PythonUDTF)` and `EXTERNAL_UDF`, used by both 
paths, would close it.
   
   **Verification:**
   - **Regression:** A hinted join with a deterministic Python UDTF side 
succeeds, builds no runtime filter, and logs the not-applied warning, for both 
a shuffle join and a join on a partitioned table.
   - **Behavior:** A hinted side containing a Python UDF is still rejected with 
its existing reason.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -578,3 +623,286 @@ trait JoinSelectionHelper extends Logging {
       conf.getConfString("spark.sql.join.forceApplyShuffledHashJoin", "false") 
== "true"
   }
 }
+
+/**
+ * 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 rows the 
source produces and the
+ * key values they carry 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, and a leaf
+ * such as an RDD that is not checkpointed may return different rows on each 
computation.
+ *
+ * The plan is walked bottom-up, tracking:
+ * - output attributes whose values are unstable: they come from a 
non-deterministic expression, an
+ *   order-dependent aggregate or window function, a subquery whose plan fails 
this same analysis,
+ *   or an expression over such an attribute;
+ * - whether the row multiplicity is unstable, i.e. every row is still present 
but may be repeated
+ *   a different number of times, which an outer generate with an unstable 
generator causes and
+ *   which makes every aggregate over the rows unstable.
+ * 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 repeatable leaf, a limit is over 
anything but a total order),
+ * when a leaf cannot promise the same rows again, and, each with its own 
reason, when an operator
+ * would duplicate an observation or 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 unstableMultiplicity whether rows may be repeated a different 
number of times per
+   *                             evaluation, while each row stays present.
+   * @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,
+      unstableMultiplicity: Boolean = false,
+      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 {
+      analyze(plan) match {
+        case Left(reason) => Some(reason)
+        case Right(t) if !isStable(key, t.unstable) => Some(NotRepeatable)
+        case _ => None
+      }
+    }
+  }
+
+  def isRepeatableExpression(e: Expression): Boolean = isStable(e, 
AttributeSet.empty)
+
+  /**
+   * 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 => !isRepeatableSubquery(s)
+      case _ => false
+    }
+  }
+
+  /**
+   * A subquery is repeatable when its row set is, and so is whatever of its 
output the subquery
+   * exposes: the build keys of a DPP filter, nothing but existence for 
EXISTS, the output columns
+   * otherwise.
+   */
+  private def isRepeatableSubquery(s: SubqueryExpression): Boolean = {
+    !s.plan.isStreaming && analyze(s.plan).exists { t =>
+      s match {
+        case d: DynamicPruningSubquery =>
+          d.broadcastKeyIndices.forall(i => isStable(d.buildKeys(i), 
t.unstable))
+        case _: Exists => true
+        case _ => s.plan.outputSet.intersect(t.unstable).isEmpty
+      }
+    }
+  }
+
+  /** 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 l: LeafNode =>
+      if (l.isOutputRepeatable) {
+        Right(Taint(AttributeSet.empty))
+      } else {
+        Left(s"the hinted side reads ${l.nodeName}, which may return different 
rows when " +
+          "evaluated again")
+      }
+
+    case p: Project => analyze(p.child).map { t =>
+      t.copy(unstable =
+        AttributeSet(p.projectList.filterNot(isStable(_, 
t.unstable)).map(_.toAttribute)))
+    }
+
+    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, l.unstableMultiplicity || 
r.unstableMultiplicity))
+        } else {
+          Left(NotRepeatable)
+        }
+      }
+    }
+
+    case a: Aggregate => analyze(a.child).map { t =>
+      // Grouping on an unstable value changes which rows form a group, and 
unstable multiplicity
+      // changes how many rows each group has, so every aggregate result then 
depends on them; a
+      // grouping expression's own value is as stable as its input. The groups 
themselves occur
+      // once each, whatever the input multiplicity.
+      val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+      val unstable = a.aggregateExpressions.filter { e =>
+        !isStable(e, t.unstable) ||
+          (e.exists(_.isInstanceOf[AggregateExpression]) &&
+            (!stableGroups || t.unstableMultiplicity || !isOrderIrrelevant(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 || t.unstableMultiplicity || !isStable(e, 
t.unstable) ||
+          !isOrderIrrelevantWindow(e)
+      }
+      t.copy(unstable = t.unstable ++ 
AttributeSet(unstable.map(_.toAttribute)),
+        totallyOrdered = false)
+    }
+
+    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),
+          taints.exists(_.exists(_.unstableMultiplicity))))
+      }
+
+    // An inner generate drops the rows for which the generator yields 
nothing, so an unstable
+    // generator changes the row set; an outer generate keeps every row but 
repeats it as many
+    // times as the generator yields values, so an unstable one changes the 
multiplicity.
+    case g: Generate => analyze(g.child).flatMap { t =>
+      if (isStable(g.generator, t.unstable)) {
+        Right(t.copy(totallyOrdered = false))
+      } else if (g.outer) {
+        Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput), 
unstableMultiplicity = true))
+      } 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), t.unstableMultiplicity)
+    }
+
+    case s: Sort => analyze(s.child).map { t =>
+      val stableOrder = s.order.forall(o => isStable(o.child, t.unstable))
+      t.copy(totallyOrdered = s.global && stableOrder && 
!t.unstableMultiplicity &&
+        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 and partitioning even then: only a seeded sample over a 
repeatable leaf, through
+    // projections and filters that keep both, is repeatable. A predicate with 
a subquery is not
+    // such a filter, as its plan may differ between the two evaluations.
+    case s: Sample =>
+      @tailrec
+      def isOrderPreservingScan(p: LogicalPlan): Boolean = p match {
+        case Project(projectList, child) if 
projectList.forall(_.deterministic) =>
+          isOrderPreservingScan(child)
+        case Filter(condition, child)
+            if condition.deterministic && 
!SubqueryExpression.hasSubquery(condition) =>
+          isOrderPreservingScan(child)
+        case l: LeafNode => l.isOutputRepeatable
+        case _ => false
+      }
+      if (s.seed.isDefined && isOrderPreservingScan(s.child)) {

Review Comment:
   **Non-blocking (P2):** A seed alone does not make the sample repeatable 
here: `SampleExec` seeds each partition with `resolvedSeed + partitionIndex`, 
so two evaluations draw the same rows only when they scan the leaf with the 
same partitioning, and the filter subquery and the join choose that separately. 
For `SELECT /*+ RUNTIME_FILTER(d) */ * FROM fact JOIN (SELECT * FROM 
bucketed_dim TABLESAMPLE (50 PERCENT) REPEATABLE (42)) d ON fact.k = d.k` with 
`d` broadcast, `DisableUnnecessaryBucketedScan` disables bucketing for the main 
plan's scan (nothing above the broadcast build side needs its partitioning) but 
keeps it under the Bloom filter subquery's `AllTuples` aggregate, because 
`SampleExec` is not an allowed node there. The two samples differ, and fact 
rows whose keys appear only in the join's sample are pruned. Physical 
partitioning is decided after this rule runs, so rejecting every `Sample` in a 
hinted side, and flipping the tests that accept seeded samples, looks like the 
saf
 e fix.
   
   **Verification:**
   - **Regression:** A hinted join over a bucketed table sampled with 
REPEATABLE, broadcast as the build side, builds no Bloom filter and returns the 
unhinted rows.
   - **Behavior:** Seeded-sample sources that the suites previously accepted, 
including in a DPP pruning-key subquery, are now reported as not applied.



##########
sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala:
##########
@@ -1270,6 +1325,600 @@ class InjectRuntimeFilterSuite extends 
SharedSparkSession
     }
   }
 
+  test("RUNTIME_FILTER hint waives the creation side's selective predicate 
requirement") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      val query = "select * from bf1 join bf2 on bf1.c1 = bf2.c2"
+      // Without a selective predicate on either side there is no evidence a 
filter pays off.
+      assertDidNotRewriteWithBloomFilter(query)
+      // The hint supplies that evidence, and picks which side to build from.
+      assertRewroteWithBloomFilter(
+        "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2")
+      assert(bloomFilterApplicationSideKeys(
+        "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2") == Set("c1"))
+      assert(bloomFilterApplicationSideKeys(
+        "select /*+ RUNTIME_FILTER(bf1) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2") == Set("c2"))
+    }
+  }
+
+  test("RUNTIME_FILTER hint waives the application side's scan size 
threshold") {
+    // A threshold above the application side's scan size, so only the hint 
can trigger a rewrite.
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "1GB",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      assertDidNotRewriteWithBloomFilter(
+        "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+      assertRewroteWithBloomFilter(
+        "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2")
+    }
+  }
+
+  test("RUNTIME_FILTER hint waives the creation side's size threshold") {
+    // A threshold below any creation side's size, so only the hint can 
trigger a rewrite. The
+    // threshold estimates whether the filter is worth building; the filter's 
own size is bounded
+    // by the max number of bits regardless.
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.RUNTIME_BLOOM_FILTER_CREATION_SIDE_THRESHOLD.key -> "1",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      assertDidNotRewriteWithBloomFilter(
+        "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+      assertRewroteWithBloomFilter(
+        "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2")
+    }
+  }
+
+  test("RUNTIME_FILTER hint waives the shuffle requirement") {
+    // `bf2` is below the broadcast threshold, so this is a broadcast join 
with no shuffle below
+    // the application side, where a filter is not expected to pay off.
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000") {
+      assertDidNotRewriteWithBloomFilter(
+        "select * from bf1 join bf2 on bf1.c1 = bf2.c2 where bf2.a2 = 62")
+      assertRewroteWithBloomFilter(
+        "select /*+ RUNTIME_FILTER(bf2) */ * from bf1 join bf2 on bf1.c1 = 
bf2.c2")
+    }
+  }
+
+  test("RUNTIME_FILTER hint waives the application side's single-leaf lineage 
requirement") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      // The application side's key is an aggregate result, which cannot be 
traced to a scan. The
+      // heuristics use that lineage to predict that the filter reaches a 
scan; the filter above
+      // the aggregate is valid regardless, and prunes the aggregate's output 
before the join.
+      val aggregatedApplicationSide =
+        """
+          |FROM   (SELECT max(c1) AS c1 FROM bf1 GROUP BY b1) t
+          |       JOIN bf2
+          |       ON t.c1 = bf2.c2
+        """.stripMargin
+      assertDidNotRewriteWithBloomFilter(s"SELECT * $aggregatedApplicationSide 
WHERE bf2.a2 = 62")
+      assertRewroteWithBloomFilter(
+        s"SELECT /*+ RUNTIME_FILTER(bf2) */ * $aggregatedApplicationSide")
+    }
+  }
+
+  test("RUNTIME_FILTER hint accepts a creation side the heuristics reject") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      // Left to itself the rule looks for a selective predicate over a scan, 
a search an
+      // `Aggregate` stops, so this is not rewritten despite the selective 
predicate below it. The
+      // hint designates the creation side instead of searching for one, so 
the shape stops
+      // mattering.
+      val distinctSource =
+        """
+          |FROM   bf1
+          |       JOIN (SELECT DISTINCT c2 FROM bf2 WHERE a2 = 62) t
+          |       ON bf1.c1 = t.c2
+        """.stripMargin
+      assertDidNotRewriteWithBloomFilter(s"SELECT * $distinctSource")
+      assertRewroteWithBloomFilter(s"SELECT /*+ RUNTIME_FILTER(t) */ * 
$distinctSource")
+      // Likewise for a `Limit` the search does not descend through, here a 
top-n of grouping
+      // keys, whose order is total because the keys are unique.
+      assertRewroteWithBloomFilter(
+        """
+          |SELECT /*+ RUNTIME_FILTER(t) */ *
+          |FROM   bf1
+          |       JOIN (SELECT c2 FROM bf2 WHERE a2 = 62 GROUP BY c2 ORDER BY 
c2 LIMIT 5) t
+          |       ON bf1.c1 = t.c2
+        """.stripMargin)
+    }
+  }
+
+  test("RUNTIME_FILTER hint builds a filter from a computed join key") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      // The filter is built from the hinted side's output, so a join key the 
creation side computes
+      // rather than reads from a scan -- here an aggregate result -- works 
like any other.
+      assertRewroteWithBloomFilter(
+        """
+          |SELECT /*+ RUNTIME_FILTER(t) */ *
+          |FROM   bf1
+          |       JOIN (SELECT max(c2) AS c2 FROM bf2 GROUP BY b2) t
+          |       ON bf1.c1 = t.c2
+        """.stripMargin)
+    }
+  }
+
+  test("RUNTIME_FILTER hint takes effect through one mechanism") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "2000") {
+      // `bf5part` is partitioned by `f5`, and the selective `bf2.a2 = 62` 
makes DPP prune it on
+      // that key. The hint is honored by that DPP filter, so no Bloom filter 
is added: not on the
+      // key DPP already prunes, matching the unhinted behavior asserted 
above, and not on the
+      // other join key either, where the heuristics would add one ("add bloom 
filter if dpp
+      // filter exists on a different column" above).
+      val oneKey = "select /*+ RUNTIME_FILTER(bf2) */ * from bf5part join bf2 
" +
+        "on bf5part.f5 = bf2.c2 where bf2.a2 = 62"
+      assertDidNotRewriteWithBloomFilter(oneKey)
+      assert(hasDynamicPruning(oneKey))
+      val twoKeys = "select /*+ RUNTIME_FILTER(bf2) */ * from bf5part join bf2 
" +
+        "on bf5part.c5 = bf2.c2 and bf5part.f5 = bf2.f2 where bf2.a2 = 62"
+      assertDidNotRewriteWithBloomFilter(twoKeys)
+      assert(hasDynamicPruning(twoKeys))
+    }
+  }
+
+  test("RUNTIME_FILTER hint is honored by a DPP filter only when that filter 
reaches the scan") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      spark.udf.register("bf_nondeterministic_true", udf((_: Int) => 
true).asNondeterministic())
+      // The hint gets a DPP filter on `bf5part`'s partition key, but a 
barrier on the pruned side
+      // keeps it from being pushed to the scan, so it is dropped later: a 
non-deterministic
+      // filter, or a window whose partitioning does not cover the key. The 
Bloom filter needs no
+      // pushdown, so the hint is honored with one instead, and exactly one of 
the two remains.
+      Seq(
+        ("SELECT f5 FROM bf5part WHERE bf_nondeterministic_true(f5)", 1),
+        ("SELECT f5 FROM (SELECT f5, row_number() OVER (PARTITION BY c5 ORDER 
BY f5) rn " +
+          "FROM bf5part) WHERE rn = 1", 1),
+        // The DPP filter is pushed into both union branches, where the limit 
blocks it and the
+        // `Range` cannot use it. The Bloom filter is pushed into both 
branches too.
+        ("SELECT f5 FROM (SELECT f5 FROM bf5part ORDER BY f5 LIMIT 2) " +
+          "UNION ALL SELECT CAST(id AS INT) AS f5 FROM range(3)", 2)
+      ).foreach { case (prunedSide, numBloomFilters) =>
+        val query = "SELECT /*+ RUNTIME_FILTER(bf2) */ f.f5, bf2.c2 " +
+          s"FROM ($prunedSide) f JOIN bf2 ON f.f5 = bf2.c2"
+        assertRewroteWithBloomFilter(query, numBloomFilters)
+        assert(!hasDynamicPruning(query))
+      }
+      // A DPP filter that does reach the scan honors the hint on its own, 
even when the pruned
+      // side is not deterministic elsewhere (here on the other side of a join 
the DPP filter is
+      // pushed past), so no Bloom filter is added on top of it.
+      val reaching = "SELECT /*+ RUNTIME_FILTER(bf2) */ f.f5, f.nd, bf2.c2 
FROM " +
+        "(SELECT p.f5, b.nd FROM bf5part p JOIN " +
+        "(SELECT c3, bf_nondeterministic_true(a3) AS nd FROM bf3) b ON p.c5 = 
b.c3) f " +
+        "JOIN bf2 ON f.f5 = bf2.c2"
+      assertDidNotRewriteWithBloomFilter(reaching)
+      assert(hasDynamicPruning(reaching))
+    }
+  }
+
+  test("RUNTIME_FILTER hint is ignored for a creation side that is not 
repeatable") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      // The filter subquery re-executes the creation side, so one that can 
produce different rows
+      // or keys on re-evaluation could yield keys the join itself never sees 
and prune rows that
+      // do match. Catalyst flags most of these as deterministic.
+      val reason = "the hinted side may produce different rows or join keys 
when evaluated again"
+      spark.udf.register("bf_nondeterministic_array",
+        udf((i: Int) => Seq(i)).asNondeterministic())
+      def query(source: String): String =
+        s"SELECT /*+ RUNTIME_FILTER(t) */ * FROM bf1 JOIN ($source) t ON 
bf1.c1 = t.c2"
+      Seq(
+        // A non-deterministic expression.
+        "SELECT c2 FROM bf2 WHERE rand() < 0.5",
+        // A limit keeps whichever rows arrive first: unordered, or ordered on 
a key that is not
+        // unique, so that ties at the cutoff can be resolved differently.
+        "SELECT c2 FROM bf2 WHERE a2 = 62 LIMIT 5",
+        "SELECT c2 FROM bf2 WHERE a2 = 62 ORDER BY c2 LIMIT 5",
+        // A sample without a seed draws a different sample per evaluation.
+        "SELECT c2 FROM bf2 TABLESAMPLE (50 PERCENT)",
+        // A key computed by an order-dependent aggregate function.
+        "SELECT first(c2) AS c2 FROM bf2 GROUP BY b2",
+        "SELECT any_value(c2) AS c2 FROM bf2 GROUP BY b2",
+        // A key computed by a window function.
+        "SELECT row_number() OVER (ORDER BY c2) AS c2 FROM bf2",
+        // An inner generate drops the rows for which an unstable generator 
yields nothing.
+        "SELECT c2 FROM bf2 LATERAL VIEW 
explode(bf_nondeterministic_array(a2)) x AS v",
+        // A key computed by a subquery whose own plan is not repeatable.
+        "SELECT (SELECT max(c2) FROM bf2 TABLESAMPLE (50 PERCENT)) AS c2 FROM 
bf2",
+        // An outer generate keeps every row but repeats it as many times as 
the unstable
+        // generator yields values, so an aggregate over the rows is unstable.
+        "SELECT count(*) AS c2 FROM bf2 " +
+          "LATERAL VIEW OUTER explode(bf_nondeterministic_array(a2)) x AS v",
+        // An average of integers accumulates in floating point, which is not 
associative.
+        "SELECT avg(c2) AS c2 FROM bf2 GROUP BY b2"
+      ).foreach { source =>
+        assertNoBloomFilters(query(source))
+        assertHintNotApplied(query(source), reason)
+      }
+      // A seeded sample over a scan is repeatable.
+      assertRewroteWithBloomFilter(
+        query("SELECT c2 FROM bf2 TABLESAMPLE (50 PERCENT) REPEATABLE (42)"))
+    }
+  }
+
+  test("RUNTIME_FILTER hint accepts a creation side whose unstable values do 
not reach the key") {
+    
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+      // Only the key values and the row set have to be repeatable. An 
order-dependent value that
+      // is merely carried to the output, next to a stable key, does not 
disqualify the side, and
+      // neither does a filter on a window aggregate whose frame is the whole 
partition.
+      def query(source: String, key: String): String =
+        s"SELECT /*+ RUNTIME_FILTER(t) */ * FROM bf1 JOIN ($source) t ON 
bf1.c1 = t.$key"
+      spark.udf.register("bf_nondeterministic_array",
+        udf((i: Int) => Seq(i)).asNondeterministic())
+      Seq(
+        ("SELECT b2, first(c2) AS c2 FROM bf2 GROUP BY b2", "b2"),
+        ("SELECT c2, v FROM bf2 LATERAL VIEW OUTER 
explode(bf_nondeterministic_array(a2)) x AS v",
+          "c2"),
+        ("SELECT (SELECT max(c2) FROM bf2) AS c2, b2 FROM bf2", "c2"),
+        ("SELECT sum(c2) AS c2 FROM bf2 GROUP BY b2", "c2"),
+        ("SELECT c2, sum(a2) OVER (PARTITION BY b2 ORDER BY c2 " +
+          "ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS s FROM 
bf2", "c2"),
+        ("SELECT c2 FROM (SELECT * FROM bf2) TABLESAMPLE (50 PERCENT) 
REPEATABLE (42) x", "c2"),
+        ("SELECT c2, row_number() OVER (ORDER BY a2) AS rn FROM bf2", "c2"),
+        ("SELECT c2, sum(a2) OVER (PARTITION BY b2) AS s FROM bf2", "c2"),
+        ("SELECT c2 FROM (SELECT c2, sum(a2) OVER (PARTITION BY b2) AS s FROM 
bf2) WHERE s > 0",
+          "c2")
+      ).foreach { case (source, key) =>
+        assertRewroteWithBloomFilter(query(source, key))

Review Comment:
   **Non-blocking (P2):** In this suite's fixtures `bf1.c1` takes only the 
values {8, 23, 33, 58, 66, 74, 86, 88}; no `bf2.c2` value (nor the per-`b2` max 
or sum built from it) is in that set, and no row has `a2 = 62`. So the 
`checkAnswer` inside `assertRewroteWithBloomFilter` compares empty (or, for 
outer joins, null-extended) results with and without the filter, here and in 
most new hinted tests. They pin plan shape, but would still pass if an accepted 
hinted filter dropped every matching row, which is the failure the 
repeatability analysis exists to prevent. The hinted tests need fixtures whose 
keys overlap, compared against the unhinted result; the existing `bf*` tables 
can stay as they are for the unhinted tests.
   
   **Verification:**
   - **Behavior:** Each accepting hinted test's query returns at least one 
joined row with the filter, and the same rows without it.
   - **Regression:** Temporarily forcing a hinted Bloom filter to be built from 
a non-repeatable source makes at least one of these tests fail.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -578,3 +623,286 @@ trait JoinSelectionHelper extends Logging {
       conf.getConfString("spark.sql.join.forceApplyShuffledHashJoin", "false") 
== "true"
   }
 }
+
+/**
+ * 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 rows the 
source produces and the
+ * key values they carry 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, and a leaf
+ * such as an RDD that is not checkpointed may return different rows on each 
computation.
+ *
+ * The plan is walked bottom-up, tracking:
+ * - output attributes whose values are unstable: they come from a 
non-deterministic expression, an
+ *   order-dependent aggregate or window function, a subquery whose plan fails 
this same analysis,
+ *   or an expression over such an attribute;
+ * - whether the row multiplicity is unstable, i.e. every row is still present 
but may be repeated
+ *   a different number of times, which an outer generate with an unstable 
generator causes and
+ *   which makes every aggregate over the rows unstable.
+ * 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 repeatable leaf, a limit is over 
anything but a total order),
+ * when a leaf cannot promise the same rows again, and, each with its own 
reason, when an operator
+ * would duplicate an observation or 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 unstableMultiplicity whether rows may be repeated a different 
number of times per
+   *                             evaluation, while each row stays present.
+   * @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,
+      unstableMultiplicity: Boolean = false,
+      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 {
+      analyze(plan) match {
+        case Left(reason) => Some(reason)
+        case Right(t) if !isStable(key, t.unstable) => Some(NotRepeatable)
+        case _ => None
+      }
+    }
+  }
+
+  def isRepeatableExpression(e: Expression): Boolean = isStable(e, 
AttributeSet.empty)
+
+  /**
+   * 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 => !isRepeatableSubquery(s)
+      case _ => false
+    }
+  }
+
+  /**
+   * A subquery is repeatable when its row set is, and so is whatever of its 
output the subquery
+   * exposes: the build keys of a DPP filter, nothing but existence for 
EXISTS, the output columns
+   * otherwise.
+   */
+  private def isRepeatableSubquery(s: SubqueryExpression): Boolean = {
+    !s.plan.isStreaming && analyze(s.plan).exists { t =>
+      s match {
+        case d: DynamicPruningSubquery =>
+          d.broadcastKeyIndices.forall(i => isStable(d.buildKeys(i), 
t.unstable))
+        case _: Exists => true
+        case _ => s.plan.outputSet.intersect(t.unstable).isEmpty
+      }
+    }
+  }
+
+  /** 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 l: LeafNode =>
+      if (l.isOutputRepeatable) {
+        Right(Taint(AttributeSet.empty))
+      } else {
+        Left(s"the hinted side reads ${l.nodeName}, which may return different 
rows when " +
+          "evaluated again")
+      }
+
+    case p: Project => analyze(p.child).map { t =>
+      t.copy(unstable =
+        AttributeSet(p.projectList.filterNot(isStable(_, 
t.unstable)).map(_.toAttribute)))
+    }
+
+    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, l.unstableMultiplicity || 
r.unstableMultiplicity))
+        } else {
+          Left(NotRepeatable)
+        }
+      }
+    }
+
+    case a: Aggregate => analyze(a.child).map { t =>
+      // Grouping on an unstable value changes which rows form a group, and 
unstable multiplicity
+      // changes how many rows each group has, so every aggregate result then 
depends on them; a
+      // grouping expression's own value is as stable as its input. The groups 
themselves occur
+      // once each, whatever the input multiplicity.
+      val stableGroups = a.groupingExpressions.forall(isStable(_, t.unstable))
+      val unstable = a.aggregateExpressions.filter { e =>
+        !isStable(e, t.unstable) ||
+          (e.exists(_.isInstanceOf[AggregateExpression]) &&
+            (!stableGroups || t.unstableMultiplicity || !isOrderIrrelevant(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 || t.unstableMultiplicity || !isStable(e, 
t.unstable) ||
+          !isOrderIrrelevantWindow(e)
+      }
+      t.copy(unstable = t.unstable ++ 
AttributeSet(unstable.map(_.toAttribute)),
+        totallyOrdered = false)
+    }
+
+    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),
+          taints.exists(_.exists(_.unstableMultiplicity))))
+      }
+
+    // An inner generate drops the rows for which the generator yields 
nothing, so an unstable
+    // generator changes the row set; an outer generate keeps every row but 
repeats it as many
+    // times as the generator yields values, so an unstable one changes the 
multiplicity.
+    case g: Generate => analyze(g.child).flatMap { t =>
+      if (isStable(g.generator, t.unstable)) {
+        Right(t.copy(totallyOrdered = false))
+      } else if (g.outer) {
+        Right(Taint(t.unstable ++ AttributeSet(g.generatorOutput), 
unstableMultiplicity = true))
+      } 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), t.unstableMultiplicity)
+    }
+
+    case s: Sort => analyze(s.child).map { t =>
+      val stableOrder = s.order.forall(o => isStable(o.child, t.unstable))
+      t.copy(totallyOrdered = s.global && stableOrder && 
!t.unstableMultiplicity &&
+        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 and partitioning even then: only a seeded sample over a 
repeatable leaf, through
+    // projections and filters that keep both, is repeatable. A predicate with 
a subquery is not
+    // such a filter, as its plan may differ between the two evaluations.
+    case s: Sample =>
+      @tailrec
+      def isOrderPreservingScan(p: LogicalPlan): Boolean = p match {
+        case Project(projectList, child) if 
projectList.forall(_.deterministic) =>
+          isOrderPreservingScan(child)
+        case Filter(condition, child)
+            if condition.deterministic && 
!SubqueryExpression.hasSubquery(condition) =>
+          isOrderPreservingScan(child)
+        case l: LeafNode => l.isOutputRepeatable
+        case _ => false
+      }
+      if (s.seed.isDefined && isOrderPreservingScan(s.child)) {
+        analyze(s.child)
+      } else {
+        Left(NotRepeatable)
+      }
+
+    // Each distinct row occurs once, whatever the input multiplicity.
+    case d: Distinct => analyze(d.child).map(t => Taint(t.unstable))
+
+    // The rows and their values are unchanged; a shuffle loses the order.
+    case _: SubqueryAlias | _: Repartition | _: RepartitionByExpression | _: 
RebalancePartitions =>
+      analyze(plan.children.head).map(t => t.copy(totallyOrdered = false))
+
+    // The filter would evaluate the observation a second time under the same 
name.
+    case _: CollectMetrics =>
+      Left("the hinted side contains CollectMetrics, whose observation the 
runtime filter " +
+        "would evaluate twice")
+
+    // 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 expression's value is independent of the order its input rows 
arrive in. Only
+   * aggregate functions whose result is exact whatever the accumulation order 
qualify: floating
+   * point sums are not associative, so a sum over a floating point input, an 
average with a
+   * floating point accumulator (every non-decimal, non-interval input) and 
the central moments
+   * are order-dependent. A Bloom filter aggregate, which 
[[InjectRuntimeFilter]] injects for an
+   * inner join, merges commutatively.
+   */
+  private def isOrderIrrelevant(e: Expression): Boolean = e match {
+    case _: AttributeReference => true
+    case ae: AggregateExpression => 
isOrderIrrelevantFunction(ae.aggregateFunction)
+    case _: UserDefinedExpression => false
+    case _ => e.children.forall(isOrderIrrelevant)
+  }
+
+  private def isOrderIrrelevantFunction(f: AggregateFunction): Boolean = f 
match {
+    case _: Min | _: Max | _: Count | _: BitAggregate | _: 
BloomFilterAggregate => true
+    case s: Sum => !isFloatingPoint(s.child.dataType)
+    case a: Average => !isFloatingPoint(a.sumDataType)
+    case _ => false
+  }
+
+  private def isFloatingPoint(dataType: DataType): Boolean = dataType match {
+    case FloatType | DoubleType => true
+    case _ => false
+  }
+
+  /**
+   * A window function's value depends on the row order within its frame, 
unless the frame is
+   * the whole partition and the function is order-irrelevant.
+   */
+  private def isOrderIrrelevantWindow(e: NamedExpression): Boolean = e match {
+    case Alias(WindowExpression(_: AggregateExpression, spec), _) =>
+      val wholePartition = spec.frameSpecification match {
+        case SpecifiedWindowFrame(_, UnboundedPreceding, UnboundedFollowing) 
=> true
+        case UnspecifiedFrame => spec.orderSpec.isEmpty
+        case _ => false
+      }
+      wholePartition && isOrderIrrelevant(e)
+    case _ => false
+  }
+
+  /**
+   * Whether `sortKeys` cover a key of `plan` that is proven unique, so that 
sorting on them is a
+   * total order. The only uniqueness Catalyst can establish is an aggregate's 
grouping keys.

Review Comment:
   **Nit (P3):** Catalyst can establish uniqueness beyond grouping keys: 
`LogicalPlan.distinctKeys` tracks it, and this file already uses it at lines 
232 and 236. Recognizing only an `Aggregate` below `Project`/`Filter` is this 
helper's own conservative choice; saying so would keep a later change from 
assuming a broader check is impossible.
   
   **Verification:**
   - **Inspection:** The comment's description matches the helper's Project, 
Filter, and Aggregate traversal and makes no claim about Catalyst-wide 
uniqueness tracking.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveHints.scala:
##########
@@ -77,22 +78,27 @@ object ResolveHints {
    * is not aliased differently), subquery, or common table expression that 
match the specified
    * name.
    *
+   * [[RuntimeFilterHint]] takes the same per-relation form, e.g. 
"RUNTIME_FILTER(a)", and is
+   * resolved here too, onto the same [[HintInfo]] as a join strategy hint on 
that relation, so
+   * the two can accompany each other.
+   *
    * The hint resolution works by recursively traversing down the query plan 
to find a relation or
    * subquery that matches one of the specified relation aliases. The 
traversal does not go past
    * beyond any view reference, with clause or subquery alias.
    *
    * This rule must happen before common table expressions.
    */
-  object ResolveJoinStrategyHints extends Rule[LogicalPlan] {
+  object ResolveJoinHints extends Rule[LogicalPlan] {

Review Comment:
   **Nit (P3):** After this rename, 
`sql/core/src/test/resources/sql-tests/inputs/hints.sql:7` still says join 
strategy hints 'are resolved by ResolveHints.ResolveJoinStrategyHints'; it is 
the only remaining reference. Updating it to `ResolveHints.ResolveJoinHints` 
(which now also resolves `RUNTIME_FILTER`) does not change the golden output.
   
   **Verification:**
   - **Inspection:** A repository search finds no remaining 
ResolveJoinStrategyHints reference, and the hint golden output is unchanged.



##########
docs/sql-ref-syntax-qry-select-hints.md:
##########
@@ -172,6 +172,40 @@ 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
+
+Runtime filter hints allow users to suggest which side of a join Spark should 
build a runtime
+filter from, to prune the other side. Spark decides on its own whether a 
runtime filter is worth
+building, based on statistics; the hint overrides that decision, but not the 
requirements that make

Review Comment:
   **Nit (P3):** This presents correctness as the only thing that can stop the 
hint, but a hinted join is also reported as not applied when 
`spark.sql.optimizer.runtimeFilter.number.threshold` is reached, when 
`spark.sql.optimizer.runtime.bloomFilter.enabled` is false and DPP does not 
apply, when a join key is not a simple expression, and when the hinted side 
contains a Python UDF. A user reading this cannot tell why a correct filter was 
not built. Two more gaps in this section: 'the same rows and join keys' below 
reads as full-row equality, although unstable values carried only in non-key 
columns are accepted (e.g. `first(c2)` next to a `GROUP BY b2` key); and 
nothing says that a hint that cannot be applied does not make Spark build a 
filter in the opposite direction, which the tests pin. The 
`InjectRuntimeFilter` class doc also says the hint waives the checks that 
estimate payoff, while the simple-key check, which the code describes as a cost 
rule, still applies.
   
   **Verification:**
   - **Inspection:** Each documented limit and rule matches a check in the 
hinted Bloom filter or DPP path, and no retained check is left undocumented.



##########
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:
   The outer-generate case is fixed, thanks. The risk this thread raised still 
applies at Aggregate, though: when a grouping expression is unstable, the case 
returns `Taint(...)` with `unstableMultiplicity = false`, so a `count(*)` over 
its stable grouping columns is accepted as a stable key. For example, with 
`rnd3` a nondeterministic UDF, `SELECT a, count(*) AS c FROM (SELECT a FROM 
(SELECT a, rnd3() AS r FROM dim) GROUP BY a, r) GROUP BY a`. Setting 
`unstableMultiplicity = !stableGroups` there, and the matching change in the 
Distinct case, would close it.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:3995080338","thread_id":"inline:3995080338","verdict_sha256":"41f2e83c8f4fba6960398fb3dff5399ed0cc1480c5477d4d73b8586c2382b847"}
 -->



##########
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:
   Thanks for trimming the section. The remaining sentence, 'The hinted side 
must produce the same rows and join keys whenever it is evaluated', still reads 
as full-row equality, while the analysis accepts unstable values carried in 
non-key columns (for example `first(name)` next to a `GROUP BY id` key). Saying 
that the set of rows and their join-key values must be the same on every 
evaluation, while other columns may differ, would match the implementation.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:3995080346","thread_id":"inline:3995080346","verdict_sha256":"41f2e83c8f4fba6960398fb3dff5399ed0cc1480c5477d4d73b8586c2382b847"}
 -->



-- 
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]

Reply via email to