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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -512,6 +512,14 @@ trait JoinSelectionHelper extends Logging {
       hint.rightHint.exists(_.strategy.contains(SHUFFLE_MERGE))
   }
 
+  def hintToSkewJoinLeft(hint: JoinHint): Boolean = {

Review Comment:
   **Nit (P3):** These helpers are only used in the equi-join branch. `SELECT 
/*+ SKEW_JOIN(t1(k1)) */ * FROM t1 JOIN t2 ON t1.k1 < t2.k1` takes the non-equi 
branch, where `checkHintNonEquiJoin` only reports SHUFFLE_HASH and MERGE. The 
hint then disappears without the 'hint not supported' warning that every other 
unsplittable case logs. Adding SKEW_JOIN to `checkHintNonEquiJoin` with the 'no 
equi-join keys' reason, plus a test, would close the gap.
   
   **Verification:**
   - **Regression:** A SKEW_JOIN-hinted join on a non-equality condition 
returns the unhinted result and logs a warning naming skew_join and the missing 
equi-join keys.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala:
##########
@@ -256,6 +256,66 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
       case j @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, nonEquiCond,
           _, left, right, hint) =>
         val hashJoinSupport = hashJoinSupported(leftKeys, rightKeys)
+
+        // Splits the join on the hot keys of the hinted side, see 
`SkewKeyJoinExec`. The hint is
+        // ignored if the join cannot be split, in which case the join is 
planned without it.
+        def createSkewKeyJoin(): Option[Seq[SparkPlan]] = {
+          val skewLeft = hintToSkewJoinLeft(hint) &&
+            joins.SkewKeyJoinExec.supportsJoinType(joinType, skewLeft = true)
+          val skewRight = !skewLeft && hintToSkewJoinRight(hint) &&
+            joins.SkewKeyJoinExec.supportsJoinType(joinType, skewLeft = false)
+          val hintInfo = if (skewLeft || (!skewRight && 
hintToSkewJoinLeft(hint))) {
+            hint.leftHint
+          } else {
+            hint.rightHint
+          }
+          def notSupported(reason: String): Option[Seq[SparkPlan]] = {
+            hintInfo.foreach(hintErrorHandler.joinHintNotSupported(_, reason))
+            None
+          }
+
+          if (!hintToSkewJoinLeft(hint) && !hintToSkewJoinRight(hint)) {
+            None
+          } else if (!skewLeft && !skewRight) {
+            notSupported(s"${joinType.sql.toLowerCase(Locale.ROOT)} join with 
the hinted " +
+              "side as the side to split")
+          } else if (!left.deterministic || !right.deterministic) {
+            throw QueryCompilationErrors.invalidSkewJoinHintError(
+              "the join has non-deterministic expressions, but both sides of 
the join are " +
+                "read twice")
+          } else if (!RowOrdering.isOrderable(leftKeys)) {
+            notSupported("the join keys are not sortable")
+          } else {
+            val skewChild = if (skewLeft) left else right
+            val skewKeys = if (skewLeft) leftKeys else rightKeys
+            val otherKeys = if (skewLeft) rightKeys else leftKeys
+            val skewColumns = hintInfo.get.skewColumns.map { column =>
+              skewChild.output.find(a => conf.resolver(a.name, 
column)).getOrElse {

Review Comment:
   **Nit (P3):** The names are looked up in `skewChild.output`, which is the 
optimized join child, after `ColumnPruning` and after `EliminateResolvedHint` 
has lifted the hint through any unary nodes, rather than in the relation named 
in the hint. With `t1(k1, k2, v)`:
   
   - `SELECT /*+ SKEW_JOIN(t1(v)) */ * FROM t1 JOIN t2 ON t1.k1 = t2.k1` keeps 
`v`, so the hint is ignored with the 'not all equi-join keys' warning (the 
suite's case);
   - `SELECT /*+ SKEW_JOIN(t1(v)) */ t1.k1, t2.w FROM t1 JOIN t2 ON t1.k1 = 
t2.k1` prunes `v`, so planning throws `INVALID_SKEW_JOIN_HINT: the column v is 
not found in the relation`, although `t1` has `v`.
   
   A real typo is also reported only at physical planning (EXPLAIN or an 
action), not at analysis. And the comment above `createSkewKeyJoin` says the 
hint is ignored whenever the join cannot be split, which neither throw path 
matches.
   
   **Recommended change:** Validate SKEW_JOIN column names against the hinted 
relation once it is resolved, for example with a check over ResolvedHint nodes 
that carry SKEW_JOIN during analysis checking. Raise INVALID_SKEW_JOIN_HINT 
only for names the relation does not have. In the planner, treat a name missing 
from the optimized join child as not an equi-join key and ignore the hint with 
the existing warning. Update the planner comment to say which cases fail the 
query and which only ignore the hint. Add analysis and planning tests for both 
outcomes.
   
   **Why this works:** The relation the user named decides existence before 
pruning or lifting can change what is visible, so an error appears at analysis 
and only for real typos. The planner then decides only join-key membership, 
where a name absent from the child always means the column cannot be a key.
   
   **Scope:** Move the column-existence check for SKEW_JOIN to analysis and 
make the planner treat missing names as not join keys.
   
   **Risks:** The check must run after the hinted relation is resolved and must 
not reject hints on views or subquery aliases whose output names the column 
with different case under case-insensitive resolution.
   
   **Constraints:** A column the relation does not have still fails with 
INVALID_SKEW_JOIN_HINT, as the change declares.
   
   **Exclusions:**
   - Accepting SKEW_JOIN without columns or with DataFrame string parameters, 
which depends on the open question about the supported hint forms.
   
   **Success:** A hinted column that exists in the relation but is not an 
equi-join key is ignored with the warning, whatever the query selects. A hinted 
column the relation does not have fails at analysis, before EXPLAIN or 
execution.
   
   **Verification:**
   - **Regression:** SKEW_JOIN on a non-key column of the hinted relation is 
ignored with the not-all-equi-join-keys warning both when the query selects 
that column and when column pruning removes it.
   - **Behavior:** SKEW_JOIN on a column the hinted relation lacks fails during 
analysis with INVALID_SKEW_JOIN_HINT.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala:
##########
@@ -256,6 +256,66 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
       case j @ ExtractEquiJoinKeys(joinType, leftKeys, rightKeys, nonEquiCond,
           _, left, right, hint) =>
         val hashJoinSupport = hashJoinSupported(leftKeys, rightKeys)
+
+        // Splits the join on the hot keys of the hinted side, see 
`SkewKeyJoinExec`. The hint is
+        // ignored if the join cannot be split, in which case the join is 
planned without it.
+        def createSkewKeyJoin(): Option[Seq[SparkPlan]] = {
+          val skewLeft = hintToSkewJoinLeft(hint) &&
+            joins.SkewKeyJoinExec.supportsJoinType(joinType, skewLeft = true)
+          val skewRight = !skewLeft && hintToSkewJoinRight(hint) &&
+            joins.SkewKeyJoinExec.supportsJoinType(joinType, skewLeft = false)
+          val hintInfo = if (skewLeft || (!skewRight && 
hintToSkewJoinLeft(hint))) {
+            hint.leftHint
+          } else {
+            hint.rightHint
+          }
+          def notSupported(reason: String): Option[Seq[SparkPlan]] = {
+            hintInfo.foreach(hintErrorHandler.joinHintNotSupported(_, reason))
+            None
+          }
+
+          if (!hintToSkewJoinLeft(hint) && !hintToSkewJoinRight(hint)) {
+            None
+          } else if (!skewLeft && !skewRight) {
+            notSupported(s"${joinType.sql.toLowerCase(Locale.ROOT)} join with 
the hinted " +
+              "side as the side to split")
+          } else if (!left.deterministic || !right.deterministic) {
+            throw QueryCompilationErrors.invalidSkewJoinHintError(
+              "the join has non-deterministic expressions, but both sides of 
the join are " +
+                "read twice")
+          } else if (!RowOrdering.isOrderable(leftKeys)) {
+            notSupported("the join keys are not sortable")
+          } else {
+            val skewChild = if (skewLeft) left else right
+            val skewKeys = if (skewLeft) leftKeys else rightKeys
+            val otherKeys = if (skewLeft) rightKeys else leftKeys
+            val skewColumns = hintInfo.get.skewColumns.map { column =>
+              skewChild.output.find(a => conf.resolver(a.name, 
column)).getOrElse {
+                throw QueryCompilationErrors.invalidSkewJoinHintError(
+                  s"the column $column is not found in the relation")
+              }
+            }
+            val skewKeyIndices = skewColumns.map { column =>
+              skewKeys.indexWhere {
+                case a: Attribute => a.semanticEquals(column)
+                case _ => false
+              }
+            }
+            if (skewKeyIndices.contains(-1)) {
+              notSupported("the columns are not all equi-join keys")
+            } else if (!skewKeyIndices.forall { i =>
+                joins.SkewKeyJoinExec.isSupportedKeyType(skewKeys(i).dataType) 
&&
+                  otherKeys(i).isInstanceOf[Attribute] &&
+                  otherKeys(i).dataType == skewKeys(i).dataType}) {
+              notSupported("the types of the join keys are not supported or 
differ between " +
+                "the two sides")
+            } else {
+              Some(Seq(joins.SkewKeyJoinExec(leftKeys, rightKeys, 
skewKeyIndices, joinType,

Review Comment:
   **Non-blocking (P2):** This path checks the join type, determinism, 
orderability and the hinted columns' types, but not `hashJoinSupport`, which is 
computed a few lines up. Once sampling finds a hot key, 
`SkewKeyJoinExec.doExecute` builds `BroadcastHashJoinExec(leftKeys, rightKeys, 
...)` over all equi-join keys, and the `HashJoin` trait asserts that every key 
is binary-stable (HashJoin.scala:45-46). `RewriteCollationJoin` only wraps 
`Equality(AttributeReference, AttributeReference)`, so a collated key on an 
expression reaches this point unwrapped, e.g.
   
   ```sql
   SELECT /*+ SKEW_JOIN(t1(id)) */ * FROM table1 t1 JOIN table3 t3
     ON t1.id = t3.id AND t3.col1 = t1.col1 COLLATE UTF8_LCASE_RTRIM
   ```
   
   The hinted key `id` passes every check, and as soon as it has a hot key the 
query fails with `java.lang.AssertionError`. Without the hint it runs as a sort 
merge join. Could the skew path fall back through `notSupported` when 
`hashJoinSupport` is false, as the broadcast and shuffled hash paths do?
   
   **Verification:**
   - **Regression:** A hinted join with a hot key on the hinted column whose 
other equi-join key is a non-binary-collated string expression, which the 
collation join rewrite leaves unwrapped, returns the same rows as the unhinted 
query instead of failing, and logs the hint-not-supported warning.
   - **Behavior:** Hinted joins whose keys all support hash joins are still 
split and find their hot keys.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinExec.scala:
##########
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, 
RightOuter}
+import org.apache.spark.sql.execution.{BinaryExecNode, FilterExec, SparkPlan, 
UnionExec}
+import org.apache.spark.sql.execution.exchange.EnsureRequirements
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+/**
+ * Joins two sides on equi-join keys, where some keys are so frequent on both 
sides that all their
+ * output rows would end up in a single task of a shuffled join. The shuffle 
blocks of such a key
+ * can all be small, as the key is spread thinly over many mappers, so 
adaptive skew join handling,
+ * which looks at the sizes of the shuffle blocks, does not help.
+ *
+ * This operator samples the skewed side to find the hot keys, and then splits 
the join in two:
+ *  - the rows of the hot keys of both sides are joined with a 
[[BroadcastHashJoinExec]], which
+ *    streams the skewed side in its existing partitioning, so that the output 
of a hot key is
+ *    produced by all the tasks that hold its rows, and
+ *  - all the other rows are joined with a [[SortMergeJoinExec]].
+ * The results are unioned. As every row is joined in exactly one of the two 
branches, this gives
+ * the same result as a single join. The rows with a null key are never hot, 
as they do not match
+ * any row, and so are only handled by the sort merge join.
+ *
+ * Each side is read twice, once per branch, so both sides must be 
deterministic. The rows of the

Review Comment:
   **Nit (P3):** `findHotKeys` runs a separate job over `skewChild.execute()` 
before either branch runs. With hot keys the hinted side is therefore computed 
three times (sampling, broadcast-join stream, sort-merge shuffle), and twice 
when no key is hot; the other side is computed twice. The non-determinism error 
reason in `SparkStrategies` ('both sides of the join are read twice') and the 
PR description have the same undercount.
   
   **Verification:**
   - **Inspection:** The operator doc states that the hinted side is computed 
for sampling plus each branch and the other side once per branch.
   - **Behavior:** The non-determinism rejection still raises 
INVALID_SKEW_JOIN_HINT, with the corrected reason.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinExec.scala:
##########
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, 
RightOuter}
+import org.apache.spark.sql.execution.{BinaryExecNode, FilterExec, SparkPlan, 
UnionExec}
+import org.apache.spark.sql.execution.exchange.EnsureRequirements
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+/**
+ * Joins two sides on equi-join keys, where some keys are so frequent on both 
sides that all their
+ * output rows would end up in a single task of a shuffled join. The shuffle 
blocks of such a key
+ * can all be small, as the key is spread thinly over many mappers, so 
adaptive skew join handling,
+ * which looks at the sizes of the shuffle blocks, does not help.
+ *
+ * This operator samples the skewed side to find the hot keys, and then splits 
the join in two:
+ *  - the rows of the hot keys of both sides are joined with a 
[[BroadcastHashJoinExec]], which
+ *    streams the skewed side in its existing partitioning, so that the output 
of a hot key is
+ *    produced by all the tasks that hold its rows, and
+ *  - all the other rows are joined with a [[SortMergeJoinExec]].
+ * The results are unioned. As every row is joined in exactly one of the two 
branches, this gives
+ * the same result as a single join. The rows with a null key are never hot, 
as they do not match
+ * any row, and so are only handled by the sort merge join.
+ *
+ * Each side is read twice, once per branch, so both sides must be 
deterministic. The rows of the
+ * hot keys of the side that is not skewed are broadcast, so this is expected 
to be a small number.
+ * If no key is hot, the operator only runs the sort merge join.
+ *
+ * @param leftKeys the left join keys.
+ * @param rightKeys the right join keys.
+ * @param skewKeyIndices the indices of the join keys to find the hot keys on. 
These keys of the
+ *                       skewed side must be attributes of the supported key 
types, and the
+ *                       corresponding keys of the other side must have the 
same types.
+ * @param joinType the type of the join: inner, or an outer join whose 
preserved side is the
+ *                 skewed side, as the other side is broadcast.
+ * @param condition the non equi-join condition.
+ * @param skewLeft whether the left side is the skewed side, otherwise the 
right side is.
+ */
+case class SkewKeyJoinExec(
+    leftKeys: Seq[Expression],
+    rightKeys: Seq[Expression],
+    skewKeyIndices: Seq[Int],
+    joinType: JoinType,
+    condition: Option[Expression],
+    skewLeft: Boolean,
+    left: SparkPlan,
+    right: SparkPlan) extends BinaryExecNode {
+
+  override lazy val metrics = Map(
+    "numHotKeys" -> SQLMetrics.createMetric(sparkContext, "number of hot 
keys"))
+
+  // The output of the join is the same for all the join strategies.
+  override lazy val output: Seq[Attribute] = sortMergeJoin(left, right).output
+
+  private def skewChild: SparkPlan = if (skewLeft) left else right
+
+  private def skewSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) leftKeys else rightKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def otherSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) rightKeys else leftKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def sortMergeJoin(l: SparkPlan, r: SparkPlan): SparkPlan = {
+    SortMergeJoinExec(leftKeys, rightKeys, joinType, condition, l, r)
+  }
+
+  private def hotKeyFilter(keys: Seq[Expression], hotKeys: Set[Seq[Any]], hot: 
Boolean)
+    : SparkPlan => SparkPlan = {
+    FilterExec(SkewHotKeyFilter(keys, hotKeys, hot), _)
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    val hotKeys = findHotKeys()
+    longMetric("numHotKeys").set(hotKeys.size)
+    val plan = if (hotKeys.isEmpty) {
+      sortMergeJoin(left, right)
+    } else {
+      val (hotSkew, coldSkew) = (hotKeyFilter(skewSideKeys, hotKeys, hot = 
true),
+        hotKeyFilter(skewSideKeys, hotKeys, hot = false))
+      val (hotOther, coldOther) = (hotKeyFilter(otherSideKeys, hotKeys, hot = 
true),
+        hotKeyFilter(otherSideKeys, hotKeys, hot = false))
+      val (hotLeft, hotRight, coldLeft, coldRight) = if (skewLeft) {
+        (hotSkew(left), hotOther(right), coldSkew(left), coldOther(right))
+      } else {
+        (hotOther(left), hotSkew(right), coldOther(left), coldSkew(right))
+      }
+      // The skewed side is streamed and the other side is broadcast.
+      val buildSide = if (skewLeft) BuildRight else BuildLeft
+      UnionExec(Seq(
+        BroadcastHashJoinExec(
+          leftKeys, rightKeys, joinType, buildSide, condition, hotLeft, 
hotRight),
+        sortMergeJoin(coldLeft, coldRight)))
+    }
+    // The shuffles and the broadcast of the two branches are planned here, as 
which keys are hot
+    // is only known once the skewed side has been sampled.
+    EnsureRequirements().apply(plan).execute()

Review Comment:
   **Non-blocking (P2):** Everything the split actually executes is built here 
at execution time: `FilterExec(SkewHotKeyFilter)`, the `BroadcastHashJoinExec`, 
the `SortMergeJoinExec`, the `UnionExec`, and the exchanges that 
`EnsureRequirements()` adds. None of it is part of the planned tree, so for 
every hinted join that finds hot keys:
   
   - AQE never turns the cold-branch shuffles or the broadcast into query 
stages, so there is no partition coalescing, no `OptimizeSkewedJoin` on the 
cold branch and no runtime statistics;
   - `CollapseCodegenStages` and exchange reuse never see the inner operators;
   - EXPLAIN and the SQL UI show only `SkewKeyJoinExec`;
   - query-scoped shuffle cleanup misses these shuffles, and the sampling 
`reduceByKey` shuffle too, because `SQLExecution.extractShuffleIds` only looks 
at the AQE context or at `ShuffleExchangeLike` nodes in the executed plan;
   - because `SkewKeyJoinExec` declares no `requiredChildDistribution`, 
`DisableUnnecessaryBucketedScan` turns off bucketed scans below it, and the 
inner sort merge join reshuffles them.
   
   The code comment says this is needed because the hot keys are only known 
after sampling. A data-dependent value does not force execution-time planning, 
though: `InjectRuntimeFilter` plans its bloom filter as a predicate over a 
`ScalarSubquery`. Even `UnionLoopExec`, which does build plans at runtime, 
sends them through `Dataset.ofRows(...).queryExecution` rather than hand-built 
physical operators.
   
   **Recommended change:** Plan the skew split instead of building it at 
execution time. Compute the hot-key set with a planned subquery over a sample 
of the hinted side: an aggregate that counts rows per key and in total and 
keeps the qualifying keys. Then plan the join as a union of a broadcast hash 
join over rows whose keys are in that set and a sort merge join over the 
remaining rows, with both sides filtered by a membership predicate that reads 
the subquery result. Add join and AQE tests that assert the broadcast, the 
cold-branch shuffles and the subquery appear in the executed plan.
   
   **Why this works:** As with the runtime bloom filter, the only 
data-dependent value becomes a subquery result. Every operator and exchange of 
the split is then part of the planned tree, where EnsureRequirements, AQE 
stages with coalescing and skew handling, whole-stage codegen, exchange reuse, 
EXPLAIN, the SQL UI and query-scoped shuffle cleanup already apply without 
manual relays.
   
   **Scope:** Move hot-key discovery and the hot/cold split from 
SkewKeyJoinExec.doExecute into planning, using a hot-key subquery and a planned 
union of the two joins.
   
   **Compatibility:** Every row is joined in exactly one branch, null keys only 
in the sort merge join branch, and outer-join semantics on the hinted side are 
unchanged.
   
   **Risks:** Duplicating each join side in the planned union must keep the 
original output attribute IDs on the first branch and fresh IDs on the second, 
or references above the join break. The planned hot-key subquery must stay 
comparable in cost to the current sampling pass.
   
   **Constraints:** Unhinted queries and joins whose SKEW_JOIN hint is ignored 
must plan exactly as they do now. Each row must still be joined in exactly one 
branch, with null keys only in the sort merge join branch.
   
   **Exclusions:**
   - Changing which keys count as hot or bounding the broadcast of the other 
side, both of which depend on the open questions about how hot keys are 
discovered and how large that broadcast may be.
   
   **Success:** The executed plan of a hinted join with hot keys contains the 
broadcast exchange, both cold-branch shuffle exchanges and the hot-key subquery 
as planned nodes. With AQE on, the cold-branch shuffles are query stages 
subject to partition coalescing and skew handling. Query-scoped shuffle cleanup 
sees every shuffle the hinted join creates.
   
   **Verification:**
   - **Behavior:** For a hinted join with hot keys, with AQE on and off, the 
executed plan exposes the broadcast exchange, both cold-branch shuffle 
exchanges and the hot-key subquery, and the result matches the unhinted query.
   - **Regression:** With AQE partition coalescing enabled, the cold-branch 
shuffles are coalesced like any other query shuffle. With shuffle-dependency 
file cleanup enabled, no shuffle created by the hinted join survives the end of 
the query.
   - **Compatibility:** Unhinted joins and joins whose SKEW_JOIN hint is 
ignored produce the same physical plans as before.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinExec.scala:
##########
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, 
RightOuter}
+import org.apache.spark.sql.execution.{BinaryExecNode, FilterExec, SparkPlan, 
UnionExec}
+import org.apache.spark.sql.execution.exchange.EnsureRequirements
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+/**
+ * Joins two sides on equi-join keys, where some keys are so frequent on both 
sides that all their
+ * output rows would end up in a single task of a shuffled join. The shuffle 
blocks of such a key
+ * can all be small, as the key is spread thinly over many mappers, so 
adaptive skew join handling,
+ * which looks at the sizes of the shuffle blocks, does not help.
+ *
+ * This operator samples the skewed side to find the hot keys, and then splits 
the join in two:
+ *  - the rows of the hot keys of both sides are joined with a 
[[BroadcastHashJoinExec]], which
+ *    streams the skewed side in its existing partitioning, so that the output 
of a hot key is
+ *    produced by all the tasks that hold its rows, and
+ *  - all the other rows are joined with a [[SortMergeJoinExec]].
+ * The results are unioned. As every row is joined in exactly one of the two 
branches, this gives
+ * the same result as a single join. The rows with a null key are never hot, 
as they do not match
+ * any row, and so are only handled by the sort merge join.
+ *
+ * Each side is read twice, once per branch, so both sides must be 
deterministic. The rows of the
+ * hot keys of the side that is not skewed are broadcast, so this is expected 
to be a small number.
+ * If no key is hot, the operator only runs the sort merge join.
+ *
+ * @param leftKeys the left join keys.
+ * @param rightKeys the right join keys.
+ * @param skewKeyIndices the indices of the join keys to find the hot keys on. 
These keys of the
+ *                       skewed side must be attributes of the supported key 
types, and the
+ *                       corresponding keys of the other side must have the 
same types.
+ * @param joinType the type of the join: inner, or an outer join whose 
preserved side is the
+ *                 skewed side, as the other side is broadcast.
+ * @param condition the non equi-join condition.
+ * @param skewLeft whether the left side is the skewed side, otherwise the 
right side is.
+ */
+case class SkewKeyJoinExec(
+    leftKeys: Seq[Expression],
+    rightKeys: Seq[Expression],
+    skewKeyIndices: Seq[Int],
+    joinType: JoinType,
+    condition: Option[Expression],
+    skewLeft: Boolean,
+    left: SparkPlan,
+    right: SparkPlan) extends BinaryExecNode {
+
+  override lazy val metrics = Map(
+    "numHotKeys" -> SQLMetrics.createMetric(sparkContext, "number of hot 
keys"))
+
+  // The output of the join is the same for all the join strategies.
+  override lazy val output: Seq[Attribute] = sortMergeJoin(left, right).output
+
+  private def skewChild: SparkPlan = if (skewLeft) left else right
+
+  private def skewSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) leftKeys else rightKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def otherSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) rightKeys else leftKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def sortMergeJoin(l: SparkPlan, r: SparkPlan): SparkPlan = {
+    SortMergeJoinExec(leftKeys, rightKeys, joinType, condition, l, r)
+  }
+
+  private def hotKeyFilter(keys: Seq[Expression], hotKeys: Set[Seq[Any]], hot: 
Boolean)
+    : SparkPlan => SparkPlan = {
+    FilterExec(SkewHotKeyFilter(keys, hotKeys, hot), _)
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    val hotKeys = findHotKeys()
+    longMetric("numHotKeys").set(hotKeys.size)

Review Comment:
   **Nit (P3):** Driver-side `SQLMetric` updates have to be posted with 
`SQLMetrics.postDriverMetricUpdates(sparkContext, executionId, ...)`; see the 
`SQLMetric` doc, and `BroadcastExchangeExec` or `UnionLoopExec` for examples. 
Without that call the SQL UI and history server never receive the value. It is 
the only signal of what the hint did, because the inner plan is otherwise 
invisible. The suite reads the metric from the plan object, so it cannot catch 
this.
   
   **Verification:**
   - **Behavior:** The number of hot keys for a hinted join is visible in the 
metrics recorded for its SQL execution, not only on the in-memory plan object.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinExec.scala:
##########
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, 
RightOuter}
+import org.apache.spark.sql.execution.{BinaryExecNode, FilterExec, SparkPlan, 
UnionExec}
+import org.apache.spark.sql.execution.exchange.EnsureRequirements
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+/**
+ * Joins two sides on equi-join keys, where some keys are so frequent on both 
sides that all their
+ * output rows would end up in a single task of a shuffled join. The shuffle 
blocks of such a key
+ * can all be small, as the key is spread thinly over many mappers, so 
adaptive skew join handling,
+ * which looks at the sizes of the shuffle blocks, does not help.
+ *
+ * This operator samples the skewed side to find the hot keys, and then splits 
the join in two:
+ *  - the rows of the hot keys of both sides are joined with a 
[[BroadcastHashJoinExec]], which
+ *    streams the skewed side in its existing partitioning, so that the output 
of a hot key is
+ *    produced by all the tasks that hold its rows, and
+ *  - all the other rows are joined with a [[SortMergeJoinExec]].
+ * The results are unioned. As every row is joined in exactly one of the two 
branches, this gives
+ * the same result as a single join. The rows with a null key are never hot, 
as they do not match
+ * any row, and so are only handled by the sort merge join.
+ *
+ * Each side is read twice, once per branch, so both sides must be 
deterministic. The rows of the
+ * hot keys of the side that is not skewed are broadcast, so this is expected 
to be a small number.
+ * If no key is hot, the operator only runs the sort merge join.
+ *
+ * @param leftKeys the left join keys.
+ * @param rightKeys the right join keys.
+ * @param skewKeyIndices the indices of the join keys to find the hot keys on. 
These keys of the
+ *                       skewed side must be attributes of the supported key 
types, and the
+ *                       corresponding keys of the other side must have the 
same types.
+ * @param joinType the type of the join: inner, or an outer join whose 
preserved side is the
+ *                 skewed side, as the other side is broadcast.
+ * @param condition the non equi-join condition.
+ * @param skewLeft whether the left side is the skewed side, otherwise the 
right side is.
+ */
+case class SkewKeyJoinExec(
+    leftKeys: Seq[Expression],
+    rightKeys: Seq[Expression],
+    skewKeyIndices: Seq[Int],
+    joinType: JoinType,
+    condition: Option[Expression],
+    skewLeft: Boolean,
+    left: SparkPlan,
+    right: SparkPlan) extends BinaryExecNode {
+
+  override lazy val metrics = Map(
+    "numHotKeys" -> SQLMetrics.createMetric(sparkContext, "number of hot 
keys"))
+
+  // The output of the join is the same for all the join strategies.
+  override lazy val output: Seq[Attribute] = sortMergeJoin(left, right).output
+
+  private def skewChild: SparkPlan = if (skewLeft) left else right
+
+  private def skewSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) leftKeys else rightKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def otherSideKeys: Seq[Expression] = {
+    val keys = if (skewLeft) rightKeys else leftKeys
+    skewKeyIndices.map(keys)
+  }
+
+  private def sortMergeJoin(l: SparkPlan, r: SparkPlan): SparkPlan = {
+    SortMergeJoinExec(leftKeys, rightKeys, joinType, condition, l, r)
+  }
+
+  private def hotKeyFilter(keys: Seq[Expression], hotKeys: Set[Seq[Any]], hot: 
Boolean)
+    : SparkPlan => SparkPlan = {
+    FilterExec(SkewHotKeyFilter(keys, hotKeys, hot), _)
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    val hotKeys = findHotKeys()
+    longMetric("numHotKeys").set(hotKeys.size)
+    val plan = if (hotKeys.isEmpty) {
+      sortMergeJoin(left, right)
+    } else {
+      val (hotSkew, coldSkew) = (hotKeyFilter(skewSideKeys, hotKeys, hot = 
true),
+        hotKeyFilter(skewSideKeys, hotKeys, hot = false))
+      val (hotOther, coldOther) = (hotKeyFilter(otherSideKeys, hotKeys, hot = 
true),
+        hotKeyFilter(otherSideKeys, hotKeys, hot = false))
+      val (hotLeft, hotRight, coldLeft, coldRight) = if (skewLeft) {
+        (hotSkew(left), hotOther(right), coldSkew(left), coldOther(right))
+      } else {
+        (hotOther(left), hotSkew(right), coldOther(left), coldSkew(right))
+      }
+      // The skewed side is streamed and the other side is broadcast.
+      val buildSide = if (skewLeft) BuildRight else BuildLeft
+      UnionExec(Seq(
+        BroadcastHashJoinExec(
+          leftKeys, rightKeys, joinType, buildSide, condition, hotLeft, 
hotRight),
+        sortMergeJoin(coldLeft, coldRight)))
+    }
+    // The shuffles and the broadcast of the two branches are planned here, as 
which keys are hot
+    // is only known once the skewed side has been sampled.
+    EnsureRequirements().apply(plan).execute()
+  }
+
+  /**
+   * Samples the skewed side to find the keys that are hot. A key is hot if 
its estimated number
+   * of rows is at least `spark.sql.skewJoinHint.minHotKeyRows` and at least 
the average number of
+   * rows of a shuffle partition, so that all the rows of the key alone fill a 
task. At most
+   * `spark.sql.skewJoinHint.maxHotKeys` of the most frequent keys are 
returned.
+   */
+  private def findHotKeys(): Set[Seq[Any]] = {
+    val fraction = conf.getConf(SQLConf.SKEW_JOIN_HINT_SAMPLE_FRACTION)
+    val minHotKeyRows = conf.getConf(SQLConf.SKEW_JOIN_HINT_MIN_HOT_KEY_ROWS)
+    val maxHotKeys = conf.getConf(SQLConf.SKEW_JOIN_HINT_MAX_HOT_KEYS)
+    val numPartitions = conf.numShufflePartitions
+
+    val keys = skewSideKeys
+    val keyTypes = keys.map(_.dataType)
+    val childOutput = skewChild.output
+    val sampledRows = sparkContext.longAccumulator("sampled rows of the skewed 
side")
+
+    val keyCounts = skewChild.execute()
+      .sample(withReplacement = false, fraction, SkewKeyJoinExec.SAMPLE_SEED)
+      .mapPartitionsInternal { iter =>
+        val project = UnsafeProjection.create(keys, childOutput)
+        iter.flatMap { row =>
+          sampledRows.add(1L)

Review Comment:
   **Nit (P3):** Accumulator updates made inside transformations may be applied 
more than once when tasks or stages are re-executed (see 'Accumulators' in the 
RDD programming guide). If a fetch failure in the `top()` stage resubmits the 
sampling map stage, the recomputed tasks add their rows to `sampledRows` again. 
The per-key counts stay correct, because the reducer reads each map output 
once, but `estimatedRows` grows, and with it `max(minHotKeyRows, estimatedRows 
/ numPartitions)`, so real hot keys can drop out. Deriving the total from the 
job's result, for example by carrying per-partition counts through the 
aggregation, avoids this.
   
   **Verification:**
   - **Inspection:** No accumulator updated inside a transformation feeds the 
hot-key threshold; the sampled total comes from the job's returned result.
   - **Behavior:** Hot-key detection on the existing fixtures finds the same 
keys as before.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinSuite.scala:
##########
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.logging.log4j.Level
+
+import org.apache.spark.sql.{AnalysisException, DataFrame, QueryTest}
+import org.apache.spark.sql.catalyst.plans.logical.{HintInfo, Join, JoinHint, 
SKEW_JOIN}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+class SkewKeyJoinSuite extends QueryTest with SharedSparkSession with 
AdaptiveSparkPlanHelper {
+
+  // The key 7 is hot on both sides, the key 9 is less frequent, and every 
tenth row has a null key.
+  // The second key has three values.
+  private def createTables(): Unit = {
+    sql(
+      """SELECT CASE WHEN id % 10 = 0 THEN NULL WHEN id % 2 = 0 THEN 7 WHEN id 
% 10 = 5 THEN 9
+          |    ELSE id END AS k1,
+        |  id % 3 AS k2, id AS v
+        |FROM range(1000)""".stripMargin).createOrReplaceTempView("t1")
+    sql(
+      """SELECT CASE WHEN id % 10 = 0 THEN NULL WHEN id % 2 = 0 THEN 7 WHEN id 
% 10 = 5 THEN 9
+          |    ELSE id END AS k1,
+        |  id % 3 AS k2, id AS w
+        |FROM range(300)""".stripMargin).createOrReplaceTempView("t2")
+    sql("SELECT CAST(k1 AS INT) AS i, w FROM t2").createOrReplaceTempView("t3")
+  }
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    createTables()
+  }
+
+  private def withSkewJoinConfs(f: => Unit): Unit = {
+    withSQLConf(
+      SQLConf.SHUFFLE_PARTITIONS.key -> "4",
+      SQLConf.SKEW_JOIN_HINT_SAMPLE_FRACTION.key -> "1.0",
+      SQLConf.SKEW_JOIN_HINT_MIN_HOT_KEY_ROWS.key -> "10",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1")(f)
+  }
+
+  private def skewKeyJoins(df: DataFrame): Seq[SkewKeyJoinExec] = {
+    collect(df.queryExecution.executedPlan) { case j: SkewKeyJoinExec => j }
+  }
+
+  /**
+   * Checks that the query with the hint is planned as a [[SkewKeyJoinExec]] 
that finds hot keys,
+   * and returns the same rows as the query without the hint.
+   */
+  private def checkSkewKeyJoin(hint: String, joinClause: String, hasHotKeys: 
Boolean = true)
+    : Unit = {
+    val hinted = sql(s"SELECT /*+ $hint */ * FROM $joinClause")
+    val expected = sql(s"SELECT * FROM $joinClause")
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      checkAnswer(hinted, expected)
+      val joins = skewKeyJoins(hinted)
+      assert(joins.size == 1)
+      assert((joins.head.metrics("numHotKeys").value > 0) == hasHotKeys)
+    }
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+      checkAnswer(sql(s"SELECT /*+ $hint */ * FROM $joinClause"), expected)
+    }
+  }
+
+  test("SKEW_JOIN hint is resolved on the hinted relation") {
+    val df = sql("SELECT /*+ SKEW_JOIN(t1(k1, k2)) */ * FROM t1 JOIN t2 ON 
t1.k1 = t2.k1")
+    val hints = df.queryExecution.optimizedPlan.collect { case j: Join => 
j.hint }
+    assert(hints == Seq(
+      JoinHint(Some(HintInfo(Some(SKEW_JOIN), Seq("k1", "k2"))), None)))
+
+    val df2 = sql("SELECT /*+ SKEWJOIN(t2(k1)) */ * FROM t1 JOIN t2 ON t1.k1 = 
t2.k1")
+    val hints2 = df2.queryExecution.optimizedPlan.collect { case j: Join => 
j.hint }
+    assert(hints2 == Seq(JoinHint(None, Some(HintInfo(Some(SKEW_JOIN), 
Seq("k1"))))))
+  }
+
+  test("inner join with a hint on the left side") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t1(k1))", "t1 JOIN t2 ON t1.k1 = t2.k1")
+    }
+  }
+
+  test("inner join with a hint on the right side") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t2(k1))", "t1 JOIN t2 ON t1.k1 = t2.k1")
+    }
+  }
+
+  test("inner join on multiple keys") {
+    withSkewJoinConfs {
+      // The rows of the key 7 are split over the values of the second key, so 
use more
+      // partitions for the pairs to be hot.
+      withSQLConf(SQLConf.SHUFFLE_PARTITIONS.key -> "100") {
+        checkSkewKeyJoin("SKEW_JOIN(t1(k1, k2))",
+          "t1 JOIN t2 ON t1.k1 = t2.k1 AND t1.k2 = t2.k2")
+      }
+    }
+  }
+
+  test("hint on a subset of the join keys") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t1(k1))", "t1 JOIN t2 ON t1.k1 = t2.k1 AND 
t1.k2 = t2.k2")
+    }
+  }
+
+  test("inner join with a non equi-join condition") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t1(k1))", "t1 JOIN t2 ON t1.k1 = t2.k1 AND 
t1.v > t2.w")
+    }
+  }
+
+  test("left outer join with a hint on the left side") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t1(k1))", "t1 LEFT JOIN t2 ON t1.k1 = t2.k1")
+      checkSkewKeyJoin("SKEW_JOIN(t1(k1))",
+        "t1 LEFT JOIN t2 ON t1.k1 = t2.k1 AND t1.v > t2.w")
+    }
+  }
+
+  test("right outer join with a hint on the right side") {
+    withSkewJoinConfs {
+      checkSkewKeyJoin("SKEW_JOIN(t2(k1))", "t1 RIGHT JOIN t2 ON t1.k1 = 
t2.k1")
+      checkSkewKeyJoin("SKEW_JOIN(t2(k1))",
+        "t1 RIGHT JOIN t2 ON t1.k1 = t2.k1 AND t1.v > t2.w")
+    }
+  }
+
+  test("null keys are not hot and keep their outer join semantics") {
+    withSkewJoinConfs {
+      val query = "SELECT /*+ SKEW_JOIN(t1(k1)) */ t1.v, t2.w FROM t1 LEFT 
JOIN t2 " +
+        "ON t1.k1 = t2.k1 WHERE t1.k1 IS NULL"

Review Comment:
   **Nit (P3):** A few of the behaviors the suite names cannot actually fail:
   
   1. This test filters `WHERE t1.k1 IS NULL` on the preserved side, and that 
filter is pushed below the join. The operator only sees null keys, finds 
nothing hot and runs the plain sort merge join, and nothing asserts the 
operator or `numHotKeys`. The cap test would not notice either: with 100 
partitions, null (100 rows) still ranks below 7 (401) and 9 (101).
   2. The cap test only checks the count, so keeping the least frequent key 
passes.
   3. With fraction 0.5, `minHotKeyRows` 10 and 4 partitions, key 7 stays hot 
with or without the 1/fraction scaling.
   4. No test merges SKEW_JOIN with another hint on the same relation. If 
`HintInfo.merge` dropped `skewColumns`, every row would be hot and the whole 
other side broadcast, and the suite would still pass.
   5. The both-sides test uses the same column name on both relations and only 
an inner join, so picking the wrong side's columns for a right outer join 
passes.
   
   **Verification:**
   - **Regression:** A test fails if null keys are counted as hot, if the cap 
keeps a less frequent key, if sampled counts are not scaled by the sampling 
fraction, if merging SKEW_JOIN with another hint on the same relation drops its 
columns, or if a right outer join hinted on both sides uses the left side's 
columns.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1579,6 +1579,38 @@ object SQLConf {
       .bytesConf(ByteUnit.BYTE)
       .createWithDefaultString("256MB")
 
+  val SKEW_JOIN_HINT_SAMPLE_FRACTION =
+    buildConf("spark.sql.skewJoinHint.sampleFraction")
+      .doc("The fraction of the rows of the hinted side that is sampled to 
find the hot keys " +
+        "for a join with a SKEW_JOIN hint.")
+      .version("4.4.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .doubleConf
+      .checkValue(v => v > 0.0 && v <= 1.0, "The sample fraction must be in 
(0, 1].")
+      .createWithDefault(0.1)
+
+  val SKEW_JOIN_HINT_MIN_HOT_KEY_ROWS =
+    buildConf("spark.sql.skewJoinHint.minHotKeyRows")
+      .doc("The minimum estimated number of rows a key must have on the hinted 
side to be " +
+        "treated as hot for a join with a SKEW_JOIN hint. A key is hot if it 
has at least this " +
+        "many rows and at least as many rows as the average shuffle partition, 
which is the " +
+        s"estimated row count of the hinted side divided by 
'${SHUFFLE_PARTITIONS.key}'.")

Review Comment:
   **Nit (P3):** `findHotKeys` divides by `conf.numShufflePartitions`. That 
returns `spark.sql.adaptive.coalescePartitions.initialPartitionNum` when AQE 
and partition coalescing are enabled (the default) and that config is set, so 
with `initialPartitionNum=2000` and `spark.sql.shuffle.partitions=200` the 
threshold uses 2000, not the documented 200. The cold-branch shuffle also uses 
the effective number, so the doc should describe that rather than name 
`spark.sql.shuffle.partitions`.
   
   **Verification:**
   - **Inspection:** The minHotKeyRows doc names the same partition count that 
hot-key selection uses, including when adaptive partition coalescing sets an 
initial partition number.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala:
##########
@@ -339,7 +399,8 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
         if (hint.isEmpty) {
           createJoinWithoutHint()
         } else {
-          createBroadcastHashJoin(true)
+          createSkewKeyJoin()

Review Comment:
   **Nit (P3):** With `createSkewKeyJoin()` first, `/*+ SKEW_JOIN(t1(k)), 
BROADCAST(t2) */` runs a sampling job plus a shuffled sort merge join instead 
of the requested broadcast join, with no `hintOverridden` warning. The SQL 
hints reference, `sql-performance-tuning.md`, the `JoinStrategyHint` scaladoc 
and the hint-order comment in `JoinSelection` all still say BROADCAST over 
MERGE over SHUFFLE_HASH over SHUFFLE_REPLICATE_NL. The hint itself is not 
documented either: there is nothing on the `relation(columns)` syntax, the 
supported join types and sides, which cases are ignored and which rejected, or 
the `spark.sql.skewJoinHint.*` configs. Broadcasting the other side already 
avoids the single-task hot key, so keeping BROADCAST first and ranking 
SKEW_JOIN right after it seems the natural order. Whichever order you pick, 
please document it consistently and add a test that combines the hints.
   
   **Verification:**
   - **Behavior:** A join with SKEW_JOIN on one side and BROADCAST on the other 
plans a broadcast hash join, and with MERGE on the other side it plans the skew 
split.
   - **Inspection:** The SQL hints reference, performance tuning guide, 
JoinStrategyHint scaladoc and planner hint-order comment state the same 
priority order, including SKEW_JOIN, and the reference documents its syntax, 
supported joins, error cases and configs.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SkewKeyJoinExec.scala:
##########
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.joins
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftOuter, 
RightOuter}
+import org.apache.spark.sql.execution.{BinaryExecNode, FilterExec, SparkPlan, 
UnionExec}
+import org.apache.spark.sql.execution.exchange.EnsureRequirements
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+/**
+ * Joins two sides on equi-join keys, where some keys are so frequent on both 
sides that all their
+ * output rows would end up in a single task of a shuffled join. The shuffle 
blocks of such a key
+ * can all be small, as the key is spread thinly over many mappers, so 
adaptive skew join handling,
+ * which looks at the sizes of the shuffle blocks, does not help.

Review Comment:
   **Nit (P3):** `OptimizeSkewedJoin` decides skew from 
`mapStats.bytesByPartitionId`, the total size of each reduce partition summed 
over all mappers. A key spread thinly over many mappers therefore still shows 
up as a large partition and gets split. The case AQE misses is a key whose 
partition stays below `max(skewedPartitionThresholdInBytes, 
skewedPartitionFactor * median)` while its output (rows on one side times rows 
on the other) explodes. The SKEW_JOIN scaladoc in `hints.scala` ('even though 
no shuffle block is oversized') uses the same framing.
   
   **Verification:**
   - **Inspection:** The operator and hint docs attribute AQE's skew detection 
to reduce-partition size totals, not block sizes, and describe the 
output-explosion case the hint targets.



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