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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1220,6 +1220,22 @@ object SQLConf {
     .bytesConf(ByteUnit.BYTE)
     .createWithDefaultString("10MB")
 
+  val BROADCAST_RANGE_JOIN_ENABLED = 
buildConf("spark.sql.join.broadcastRangeJoin.enabled")
+    .internal()
+    .doc("When true, joins with range predicates (such as point-in-range, 
interval-overlap, " +
+      "or single inequalities) are planned as broadcast range joins instead of 
broadcast " +
+      "nested loop joins if the build side is eligible for broadcast. 
Supported join types " +
+      "include inner, left outer, right outer, left semi, and left anti. 
Broadcast range joins " +
+      "build an index on the broadcast side to significantly accelerate 
selective range probes, " +
+      "but perform worse than nested loop joins when most, but not all, of the 
build intervals " +

Review Comment:
   **Nit (P3):** I built this head and ran the committed staggered shape (2,000 
build intervals, each either ending before the probe window or spanning it, 
10,000 probes) at lower fractions of spanning intervals, spread across start 
order, against the same query with the flag off (local[1], JDK 17, AQE off, 
best of five, two runs). Range-join speedup over the nested loop join:
   
   | intervals reaching the probe | double keys | long keys |
   |---|---:|---:|
   | 0% | 3.0X-3.1X | 2.2X |
   | 2% | 1.35X-1.47X | 1.8X |
   | 5% | 0.77X-0.98X | 1.25X-1.40X |
   | 10% | 0.63X-0.75X | 1.04X-1.10X |
   | 25% | 0.63X-0.82X | 0.84X-0.98X |
   | 50% | 0.53X-0.59X | 0.83X-0.87X |
   
   A subtree that contains even one interval reaching the probe can't be 
pruned, so a small minority of long or open-ended intervals keeps most of the 
tree alive, and boxed keys pay an `Ordering[Any]` comparison at every visited 
node. This sentence is close for long keys but not for boxed keys. Could the 
doc state the boundary per key family, backed by benchmark rows at about 5%, 
10%, and 25%?
   
   **Verification:**
   - **Inspection:** Every slow-regime statement in the config doc agrees with 
committed benchmark rows that cover interleaved overlap at about 5%, 10%, 25%, 
50%, and 90% for long and double keys in all three JDK results files.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/RangeRelation.scala:
##########
@@ -0,0 +1,1240 @@
+/*
+ * 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 java.io.{Externalizable, ObjectInput, ObjectOutput}
+
+import scala.collection.mutable.ArrayBuffer
+
+import com.esotericsoftware.kryo.{Kryo, KryoSerializable}
+import com.esotericsoftware.kryo.io.{Input, Output}
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{BoundReference, Expression, 
InterpretedProjection}
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildSide}
+import org.apache.spark.sql.catalyst.planning.{IntervalOverlapJoin, 
PartialRangeJoin, PointInRangeJoin, RangeJoinKind}
+import org.apache.spark.sql.catalyst.plans.physical.BroadcastMode
+import org.apache.spark.sql.catalyst.types.PhysicalDataType
+import org.apache.spark.sql.types.{BooleanType, ByteType, DataType, DateType, 
Decimal, DecimalType, IntegerType, LongType, ShortType, TimestampNTZType, 
TimestampType}
+import org.apache.spark.util.{KnownSizeEstimation, SizeEstimator, Utils}
+
+/**
+ * Broadcast payload of a range join. An index only names candidate rows.
+ * [[BroadcastRangeJoinExec]] evaluates the original join condition to filter
+ * candidates. Keys that map to one order-preserving long are stored raw and
+ * compared unboxed ([[LongIntervalIndex]], [[LongPointIndex]]); every other
+ * supported type is compared as boxed values ([[IntervalIndex]],
+ * [[PointIndex]]). [[RangeRelationBroadcastMode]] picks the family from the
+ * build keys' data types.
+ *
+ *  - The interval indexes answer "which ranges overlap this window?"
+ *  - The point indexes answer "which points lie on this side of a bound?"
+ */
+private[execution] sealed trait RangeRelation extends KnownSizeEstimation with 
Serializable {
+  def keyType: DataType
+}
+
+/**
+ * Which [[RangeRelation]] a broadcast materializes.
+ *
+ * A point index has no direction, so one broadcast of a column serves both
+ * `<` and `>`. The join records which side holds the lower bound and scans
+ * accordingly.
+ */
+private[execution] sealed abstract class RangeIndexKind {
+  def matches(rangeJoinKind: RangeJoinKind): Boolean = 
RangeIndexKind(rangeJoinKind) == this
+}
+
+private[execution] case object IntervalIndexKind extends RangeIndexKind
+
+private[execution] case object PointIndexKind extends RangeIndexKind
+
+private[execution] object RangeIndexKind {
+  def apply(rangeJoinKind: RangeJoinKind): RangeIndexKind = rangeJoinKind 
match {
+    case PointInRangeJoin | IntervalOverlapJoin => IntervalIndexKind
+    case _: PartialRangeJoin => PointIndexKind
+  }
+}
+
+/**
+ * The boxed-key index family's shared ordering cache, derived from `keyType`
+ * on first comparison and kept out of the serialized form, so each executor
+ * re-derives its own instance instead of receiving one per broadcast. The
+ * long index family compares raw keys and has no ordering to cache.
+ */
+private[joins] sealed trait BoxedKeyOrdering extends RangeRelation {
+  @transient
+  @volatile private[this] var _ordering: Ordering[Any] = _
+
+  private[joins] def ordering: Ordering[Any] = {
+    var ord = _ordering
+    if (ord == null) {
+      ord = PhysicalDataType.ordering(keyType)
+      _ordering = ord
+    }
+    ord
+  }
+}
+
+/**
+ * The long index family's shared key conversion, derived from `keyType` on
+ * first probe and kept out of the serialized form, so each executor derives
+ * its own converter after deserialization. The interpreted probe path calls
+ * [[toLong]] once per streamed key; generated probe code keeps its own
+ * conversion, emitted at plan time.
+ */
+private[joins] sealed trait LongKeyConversion extends RangeRelation {
+  @transient
+  @volatile private[this] var _toLong: Any => Long = _
+
+  /**
+   * One evaluated streamed key to its order-preserving long. `RangePredicate`
+   * gives both sides of a range predicate one type after analysis, so the
+   * streamed key type is this index's `keyType`.
+   */
+  private[joins] def toLong(key: Any): Long = {
+    var f = _toLong
+    if (f == null) {
+      f = RangeIndex.requiredLongKey(keyType).value
+      _toLong = f
+    }
+    f(key)
+  }
+}
+
+private[joins] object RangeIndex {
+
+  /**
+   * Capacity for a build buffer from the driver's row-count hint, capped at 
2^20 for any
+   * larger hint (well under a hint that large) and equal to the hint itself 
below that.
+   * `BroadcastExchangeExec` only checks the row count against its row-count
+   * limit (up to half a billion for this mode) before calling `transform`, 
and only
+   * checks the built relation's byte size afterward, so sizing a buffer 
directly off an
+   * inflated hint can allocate hundreds of megabytes before a single row is 
read - ahead
+   * of the size check that is supposed to catch an over-large broadcast. 
Starting small
+   * and letting the buffer grow from there bounds only the initial 
allocation; the final
+   * size still tracks the actual row count, which is what the post-build size 
check sees.
+   */
+  def initialCapacity(sizeHint: Option[Long]): Int =
+    sizeHint.map(n => math.min(n, 1L << 20).toInt).getOrElse(64)
+
+  /** Accessor reading a single field from `InternalRow` as a boxed value (or 
null). */
+  def getValue(dt: DataType, ordinal: Int): InternalRow => Any = {
+    val accessor = InternalRow.getAccessor(dt)
+    (input: InternalRow) => accessor(input, ordinal)
+  }
+
+  /**
+   * One key type's bridge to an order-preserving long: reading a projected 
build
+   * field, converting an evaluated streamed value, and turning generated Java 
for
+   * a typed key into generated Java for a long. The three agree, so a build 
key
+   * and a probe key of the same type become the same long.
+   */
+  final case class LongKey(
+      field: (InternalRow, Int) => Long,
+      value: Any => Long,
+      code: String => String)
+
+  /**
+   * An order-preserving long for a key type that has one, or None when the 
type
+   * must stay on the generic boxed index: variable-length keys, floating 
point,
+   * whose SQL equality for -0.0 and NaN has no bit-preserving order, and
+   * decimals wider than `Decimal.MAX_LONG_DIGITS`, whose unscaled value does
+   * not fit a long. This is the only place a key type is specialized; the long
+   * indexes and the generated probe code both consult it.
+   */
+  def longKey(dt: DataType): Option[LongKey] = {
+    def key(field: (InternalRow, Int) => Long, value: Any => Long): LongKey =
+      LongKey(field, value, v => s"((long) $v)")
+    dt match {
+      case _: BooleanType =>
+        Some(LongKey(
+          (r, o) => if (r.getBoolean(o)) 1L else 0L,
+          v => if (v.asInstanceOf[Boolean]) 1L else 0L,
+          v => s"($v ? 1L : 0L)"))
+      case _: ByteType =>
+        Some(key((r, o) => r.getByte(o).toLong, v => 
v.asInstanceOf[Byte].toLong))
+      case _: ShortType =>
+        Some(key((r, o) => r.getShort(o).toLong, v => 
v.asInstanceOf[Short].toLong))
+      case _: IntegerType | _: DateType =>
+        Some(key((r, o) => r.getInt(o).toLong, v => 
v.asInstanceOf[Int].toLong))
+      case _: LongType | _: TimestampType | _: TimestampNTZType =>
+        Some(key((r, o) => r.getLong(o), v => v.asInstanceOf[Long]))
+      case d: DecimalType if d.precision <= Decimal.MAX_LONG_DIGITS =>
+        Some(LongKey(
+          (r, o) => r.getDecimal(o, d.precision, d.scale).toUnscaledLong,
+          v => v.asInstanceOf[Decimal].toUnscaledLong,
+          v => s"$v.toUnscaledLong()"))
+      case _ => None
+    }
+  }
+
+  /**
+   * Every key maps to one order-preserving long. 
[[RangeRelationBroadcastMode]] picks
+   * the index family by this test and [[BroadcastRangeJoinExec]] picks its 
probe
+   * code by the same one, so the two cannot drift apart. `RangePredicate` 
gives
+   * both sides of a range predicate one type after analysis, so testing the
+   * build keys also settles the streamed keys.
+   */
+  def canMapKeysToLong(keys: Seq[Expression]): Boolean =
+    keys.forall(k => longKey(k.dataType).isDefined)
+
+  /** longKey for a type the caller has routed to the long family. */
+  def requiredLongKey(dt: DataType): LongKey = {
+    longKey(dt).getOrElse(
+      throw new IllegalStateException(s"$dt has no order-preserving long 
key."))
+  }
+}
+
+/**
+ * Array-backed candidate scan. The index hands rows to the join in array 
order,
+ * without a closure or a tuple hop: the per-pair overhead then matches a bare
+ * loop over a broadcast row array. One instance per probe; never serialized.
+ */
+private[joins] final class RowSlice(
+    rows: Array[InternalRow],
+    from: Int,
+    until: Int) extends Iterator[InternalRow] {
+  private var i = from
+  def hasNext: Boolean = i < until
+  def next(): InternalRow = {
+    val r = rows(i)
+    i += 1
+    r
+  }
+}
+
+private[joins] object RowSlice {
+  /** A shared empty scan: a null probe bound matches nothing. */
+  val empty: RowSlice = new RowSlice(new Array[InternalRow](0), 0, 0)
+}
+
+// ===== Boxed-key indexes ====================================================
+//
+// Keys are the boxed values `RangeIndex.getValue` reads, compared through
+// `BoxedKeyOrdering`. Floating point, strings, binary, and decimals wider
+// than 18 digits stay here: these types have no order-preserving
+// single-long form, which is the property that routes a key type to the
+// long indexes below (`RangeIndex.longKey`).
+
+/**
+ * Augmented interval tree of build-side intervals. Lows and highs sit in
+ * parallel arrays sorted by low bound, with a row array in the same order
+ * feeding the scan path and `maxHighIdx(mid)` indexing the maximum high
+ * bound in the span rooted at mid. Each key and row object is owned once:
+ * the tree holds one reference per value plus the single row array. Java
+ * serialization keeps these aliases; Kryo's reference resolver does not
+ * track primitive wrappers, so a key shared by `lows` and `highs`, such as
+ * the one key of a degenerate build, can deserialize as two objects.
+ * Subtrees whose max high is below the probe window, or whose low starts
+ * after it, are pruned during search, and a window covering every interval
+ * skips the walk entirely.
+ */
+private[joins] class IntervalIndex private (
+    private[this] var _keyType: DataType,
+    private[this] var lows: Array[Any],
+    private[this] var highs: Array[Any],
+    private[this] var maxHighIdx: Array[Int],
+    private[this] var rows: Array[InternalRow])
+  extends BoxedKeyOrdering with Externalizable with KryoSerializable {
+
+  // Needed for Java/Kryo deserialization; see readExternal/read below.
+  def this() = this(null, null, null, null, null)
+
+  def keyType: DataType = _keyType
+
+  // All-covering window bounds. `lows` is sorted, so the last entry is the
+  // largest low: it bounds every low from above, and coreHigh is the minimum
+  // high, which bounds every high from below. A window overlaps every stored
+  // interval exactly when it holds probeLow <= coreHigh and probeHigh >= 
coreLow,
+  // whether or not the intervals share a common core.
+  // Recomputed in `read` too, since `lows`/`highs` are reassigned there.
+  private[this] var coreLow: Any = IntervalIndex.coreLow(lows)
+  private[this] var coreHigh: Any = IntervalIndex.coreHigh(highs, ordering)
+
+  /**
+   * Measured over the retained arrays, not the index object: SizeEstimator
+   * would consult `estimatedSize` for a KnownSizeEstimation and recurse.
+   * Its identity tracking charges a key shared by `lows` and `highs`, or
+   * aliased across indexes, exactly once.
+   */
+  override def estimatedSize: Long =
+    SizeEstimator.estimate(Array[AnyRef](lows, highs, maxHighIdx, rows))
+
+  def overlapping(low: Any, high: Any): Iterator[InternalRow] = {
+    if (lows.isEmpty || low == null || high == null) return Iterator.empty
+    val ord = ordering
+    val (probeLow, probeHigh) = if (ord.lteq(low, high)) (low, high) else 
(high, low)
+    // A window that covers every interval gets every row the walk would
+    // return, without visiting the tree. Returns all rows iff every interval
+    // overlaps the probe; the condition still re-checks each candidate.
+    if (ord.lteq(probeLow, coreHigh) && ord.gteq(probeHigh, coreLow)) {
+      return new RowSlice(rows, 0, rows.length)
+    }
+    new OverlapIterator(lows, highs, maxHighIdx, rows, ord, probeLow, 
probeHigh)
+  }
+
+  override def writeExternal(out: ObjectOutput): Unit = Utils.tryOrIOException 
{
+    write(out.writeUTF, out.writeInt, (o: Any) => 
out.writeObject(o.asInstanceOf[AnyRef]))
+  }
+
+  override def write(kryo: Kryo, out: Output): Unit = Utils.tryOrIOException {
+    write(out.writeString, out.writeInt,
+      (o: Any) => kryo.writeClassAndObject(out, o.asInstanceOf[AnyRef]))
+  }
+
+  private def write(
+      writeString: String => Unit,
+      writeInt: Int => Unit,
+      writeObj: Any => Unit): Unit = {
+    writeString(keyType.json)
+    val n = lows.length
+    writeInt(n)
+    var i = 0
+    while (i < n) {
+      writeObj(lows(i))
+      writeObj(highs(i))
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      writeInt(maxHighIdx(i))
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      writeObj(rows(i))
+      i += 1
+    }
+  }
+
+  override def readExternal(in: ObjectInput): Unit = Utils.tryOrIOException {
+    read(() => in.readUTF(), () => in.readInt(), () => in.readObject())
+  }
+
+  override def read(kryo: Kryo, in: Input): Unit = Utils.tryOrIOException {
+    read(() => in.readString(), () => in.readInt(), () => 
kryo.readClassAndObject(in))
+  }
+
+  private def read(
+      readString: () => String,
+      readInt: () => Int,
+      readObj: () => AnyRef): Unit = {
+    _keyType = DataType.fromJson(readString())
+    val n = readInt()
+    lows = new Array[Any](n)
+    highs = new Array[Any](n)
+    maxHighIdx = new Array[Int](n)
+    rows = new Array[InternalRow](n)
+    var i = 0
+    while (i < n) {
+      lows(i) = readObj()
+      highs(i) = readObj()
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      maxHighIdx(i) = readInt()
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      rows(i) = readObj().asInstanceOf[InternalRow]
+      i += 1
+    }
+    coreLow = IntervalIndex.coreLow(lows)
+    coreHigh = IntervalIndex.coreHigh(highs, ordering)
+  }
+}
+
+private[joins] object IntervalIndex {
+
+  // Shared by the primary constructor and `read`, which recomputes these
+  // caches after reassigning `lows`/`highs` on deserialization. `lows`/
+  // `highs` are null right after the zero-arg constructor runs, before
+  // `readExternal`/`read` populate them.
+  private[joins] def coreLow(lows: Array[Any]): Any =
+    if (lows == null || lows.isEmpty) null else lows(lows.length - 1)
+
+  // `ordering` is by-name: the zero-arg constructor calls this with `highs ==
+  // null`, before `keyType` is set, and forcing `ordering` eagerly would
+  // throw trying to derive an ordering for a not-yet-known key type.
+  private[joins] def coreHigh(highs: Array[Any], ordering: => Ordering[Any]): 
Any = {
+    if (highs == null || highs.isEmpty) {
+      null
+    } else {
+      val ord = ordering
+      var min = highs(0)
+      var i = 1
+      while (i < highs.length) {
+        if (ord.lt(highs(i), min)) min = highs(i)
+        i += 1
+      }
+      min
+    }
+  }
+
+  def build(
+      keyType: DataType,
+      buildKeys: Seq[Expression],
+      rows: Iterator[InternalRow],
+      sizeHint: Option[Long]): IntervalIndex = {
+    val buffer = new ArrayBuffer[(Any, Any, 
InternalRow)](RangeIndex.initialCapacity(sizeHint))
+    val projection = new InterpretedProjection(buildKeys)
+    val lowOf = RangeIndex.getValue(buildKeys.head.dataType, 0)
+    val ordering = PhysicalDataType.ordering(keyType)
+
+    if (buildKeys.length == 1) {
+      // A point build key is one key used as both ends, so it is copied once
+      // and serves as both bounds, matching `LongIntervalIndex.build`.
+      while (rows.hasNext) {
+        val r = rows.next()
+        val p = projection(r)
+        val rawLo = lowOf(p)
+        if (rawLo != null) {
+          val key = InternalRow.copyValue(rawLo)
+          buffer += ((key, key, r.copy()))
+        }
+      }
+    } else {
+      val highOf = RangeIndex.getValue(buildKeys(1).dataType, 1)
+      while (rows.hasNext) {
+        val r = rows.next()
+        val p = projection(r)
+        val rawLo = lowOf(p)
+        val rawHi = highOf(p)
+        if (rawLo != null && rawHi != null) {
+          val lo = InternalRow.copyValue(rawLo)
+          val hi = InternalRow.copyValue(rawHi)
+          val (normLo, normHi) = if (ordering.lteq(lo, hi)) (lo, hi) else (hi, 
lo)
+          buffer += ((normLo, normHi, r.copy()))
+        }
+      }
+    }
+
+    sortAndBuild(keyType, ordering, buffer.toArray)
+  }
+
+  private def sortAndBuild(
+      keyType: DataType,
+      ordering: Ordering[Any],
+      valid: Array[(Any, Any, InternalRow)]): IntervalIndex = {
+    java.util.Arrays.sort(valid, Ordering.by[(Any, Any, InternalRow), 
Any](_._1)(ordering))
+    val length = valid.length
+    val lows = new Array[Any](length)
+    val highs = new Array[Any](length)
+    val rows = new Array[InternalRow](length)
+    var i = 0
+    while (i < length) {
+      lows(i) = valid(i)._1
+      highs(i) = valid(i)._2
+      rows(i) = valid(i)._3
+      i += 1
+    }
+
+    // `maxHighIdx(mid)` indexes the maximum high bound in the span rooted at
+    // mid. Storing an index instead of aliasing the key keeps each key owned,
+    // and serialized, exactly once.
+    val maxHighIdx = new Array[Int](length)
+
+    def fillMaxHigh(start: Int, end: Int): Int = {
+      if (start >= end) return -1
+      val mid = (start + end) >>> 1
+      var best = mid
+      val left = fillMaxHigh(start, mid)
+      if (left >= 0 && ordering.gt(highs(left), highs(best))) best = left
+      val right = fillMaxHigh(mid + 1, end)
+      if (right >= 0 && ordering.gt(highs(right), highs(best))) best = right
+      maxHighIdx(mid) = best
+      best
+    }
+
+    fillMaxHigh(0, length)
+    new IntervalIndex(keyType, lows, highs, maxHighIdx, rows)
+  }
+}
+
+private[joins] class OverlapIterator(
+    lows: Array[Any],
+    highs: Array[Any],
+    maxHighIdx: Array[Int],
+    rows: Array[InternalRow],
+    ord: Ordering[Any],
+    probeLow: Any,
+    probeHigh: Any) extends Iterator[InternalRow] {
+
+  private[this] val depth = 32 - 
java.lang.Integer.numberOfLeadingZeros(lows.length)
+  private[this] var stackStart = new Array[Int](math.max(8, depth * 3))
+  private[this] var stackEnd = new Array[Int](math.max(8, depth * 3))
+  private[this] var stackTop = 0
+  private[this] var nextRow: InternalRow = null
+
+  push(0, lows.length)
+
+  private[joins] def stackCapacity: Int = stackStart.length
+  private[joins] def pendingSpans: Int = stackTop
+
+  private def push(s: Int, e: Int): Unit = {
+    if (stackTop >= stackStart.length) {
+      stackStart = java.util.Arrays.copyOf(stackStart, stackStart.length * 2)
+      stackEnd = java.util.Arrays.copyOf(stackEnd, stackEnd.length * 2)
+    }
+    stackStart(stackTop) = s
+    stackEnd(stackTop) = e
+    stackTop += 1
+  }
+
+  private def advance(): InternalRow = {
+    while (stackTop > 0) {
+      stackTop -= 1
+      val start = stackStart(stackTop)
+      val end = stackEnd(stackTop)
+      if (start < end) {
+        val mid = (start + end) >>> 1
+        if (ord.gteq(highs(maxHighIdx(mid)), probeLow)) {
+          if (ord.lteq(lows(mid), probeHigh)) {
+            push(mid + 1, end)
+          }
+          push(mid, -1)
+          push(start, mid)
+        }
+      } else if (end == -1) {
+        if (ord.gteq(highs(start), probeLow) && ord.lteq(lows(start), 
probeHigh)) {
+          return rows(start)
+        }
+      }
+    }
+    null
+  }
+
+  override def hasNext: Boolean = {
+    if (nextRow != null) {
+      true
+    } else {
+      nextRow = advance()
+      nextRow != null
+    }
+  }
+
+  override def next(): InternalRow = {
+    if (!hasNext) throw new NoSuchElementException("empty iterator")
+    val r = nextRow
+    nextRow = null
+    r
+  }
+}
+
+/**
+ * Sorted build-side points for single cross-side inequalities (`<`, `<=`, 
`>`, `>=`).
+ * Keys sit beside the rows they name, one owned copy each, in key order.
+ */
+private[joins] class PointIndex private (
+    private[this] var _keyType: DataType,
+    private[this] var keys: Array[Any],
+    private[this] var rows: Array[InternalRow])
+  extends BoxedKeyOrdering with Externalizable with KryoSerializable {
+
+  // Needed for Java/Kryo deserialization; see readExternal/read below.
+  def this() = this(null, null, null)
+
+  def keyType: DataType = _keyType
+
+  private[this] def length: Int = keys.length
+
+  override def estimatedSize: Long = 
SizeEstimator.estimate(Array[AnyRef](keys, rows))
+
+  /** Points whose key is less than or equal to `value`. */
+  def upTo(value: Any): Iterator[InternalRow] =
+    if (value == null) Iterator.empty else slice(0, searchBound(value, 
skipEquals = true))
+
+  /** Points whose key is greater than or equal to `value`. */
+  def from(value: Any): Iterator[InternalRow] =
+    if (value == null) Iterator.empty else slice(searchBound(value, skipEquals 
= false), length)
+
+  /**
+   * Points whose key is strictly less than `value`. For a strict partial-range
+   * predicate the original condition rejects keys equal to the probe value,
+   * so the scan can skip them: a build heavy in duplicated keys would only
+   * hand the join rows that all fail.
+   */
+  def upToExclusive(value: Any): Iterator[InternalRow] =
+    if (value == null) Iterator.empty else slice(0, searchBound(value, 
skipEquals = false))
+
+  /** Points whose key is strictly greater than `value`. */
+  def fromExclusive(value: Any): Iterator[InternalRow] =
+    if (value == null) Iterator.empty else slice(searchBound(value, skipEquals 
= true), length)
+
+  private def searchBound(value: Any, skipEquals: Boolean): Int = {
+    val ord = ordering
+    var lo = 0
+    var hi = length
+    while (lo < hi) {
+      val mid = (lo + hi) >>> 1
+      val c = ord.compare(keys(mid), value)
+      if (c < 0 || (skipEquals && c == 0)) lo = mid + 1 else hi = mid
+    }
+    lo
+  }
+
+  private def slice(from: Int, until: Int): Iterator[InternalRow] = new 
RowSlice(rows, from, until)
+
+  override def writeExternal(out: ObjectOutput): Unit = Utils.tryOrIOException 
{
+    write(out.writeUTF, out.writeInt, (o: Any) => 
out.writeObject(o.asInstanceOf[AnyRef]))
+  }
+
+  override def write(kryo: Kryo, out: Output): Unit = Utils.tryOrIOException {
+    write(out.writeString, out.writeInt,
+      (o: Any) => kryo.writeClassAndObject(out, o.asInstanceOf[AnyRef]))
+  }
+
+  private def write(
+      writeString: String => Unit,
+      writeInt: Int => Unit,
+      writeObj: Any => Unit): Unit = {
+    writeString(keyType.json)
+    val n = keys.length
+    writeInt(n)
+    var i = 0
+    while (i < n) {
+      writeObj(keys(i))
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      writeObj(rows(i))
+      i += 1
+    }
+  }
+
+  override def readExternal(in: ObjectInput): Unit = Utils.tryOrIOException {
+    read(() => in.readUTF(), () => in.readInt(), () => in.readObject())
+  }
+
+  override def read(kryo: Kryo, in: Input): Unit = Utils.tryOrIOException {
+    read(() => in.readString(), () => in.readInt(), () => 
kryo.readClassAndObject(in))
+  }
+
+  private def read(
+      readString: () => String,
+      readInt: () => Int,
+      readObj: () => AnyRef): Unit = {
+    _keyType = DataType.fromJson(readString())
+    val n = readInt()
+    keys = new Array[Any](n)
+    rows = new Array[InternalRow](n)
+    var i = 0
+    while (i < n) {
+      keys(i) = readObj()
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      rows(i) = readObj().asInstanceOf[InternalRow]
+      i += 1
+    }
+  }
+}
+
+private[joins] object PointIndex {
+
+  def build(
+      keyType: DataType,
+      buildKeys: Seq[Expression],
+      rows: Iterator[InternalRow],
+      sizeHint: Option[Long]): PointIndex = {
+    val buffer = new ArrayBuffer[(Any, 
InternalRow)](RangeIndex.initialCapacity(sizeHint))
+    val projection = new InterpretedProjection(buildKeys)
+    val lowOf = RangeIndex.getValue(buildKeys.head.dataType, 0)
+    val ordering = PhysicalDataType.ordering(keyType)
+
+    while (rows.hasNext) {
+      val r = rows.next()
+      val p = projection(r)
+      val rawKey = lowOf(p)
+      if (rawKey != null) {
+        val key = InternalRow.copyValue(rawKey)
+        buffer += ((key, r.copy()))
+      }
+    }
+
+    sortAndBuild(keyType, ordering, buffer.toArray)
+  }
+
+  private def sortAndBuild(
+      keyType: DataType,
+      ordering: Ordering[Any],
+      valid: Array[(Any, InternalRow)]): PointIndex = {
+    java.util.Arrays.sort(valid, Ordering.by[(Any, InternalRow), 
Any](_._1)(ordering))
+    val length = valid.length
+    val keys = new Array[Any](length)
+    val rows = new Array[InternalRow](length)
+    var i = 0
+    while (i < length) {
+      keys(i) = valid(i)._1
+      rows(i) = valid(i)._2
+      i += 1
+    }
+    new PointIndex(keyType, keys, rows)
+  }
+}
+
+// ===== Long-specialized indexes for keys that map to one long ==============
+//
+// Indexes for the key types `RangeIndex.longKey` maps to one order-preserving
+// long, stored as raw longs converted at the build boundary. The key 
comparison
+// itself touches no Spark types once built: the tree walk and the binary 
searches
+// compare raw longs, and generated probe code passes long keys without boxing.
+// The row payload still does - `rows: Array[InternalRow]` and the serialized
+// `_keyType: DataType` are retained alongside the long arrays. Fixed width 
alone
+// is not the routed property: floating point is fixed-width but stays boxed,
+// while compact decimals route here as unscaled longs.
+// `RangeRelationBroadcastMode` picks between the two families once, from the
+// build keys' data types.
+
+/**
+ * Augmented interval tree of build-side intervals, on raw keys. Lows and 
highs sit in
+ * parallel arrays sorted by low bound, with a row array in the same order 
feeding the
+ * scan path and `maxHighs(mid)` holding the maximum high bound in the span 
rooted at
+ * mid - the value itself, unlike [[IntervalIndex.maxHighIdx]], which stores 
an index
+ * into `highs` because boxed values there are shared references worth not 
duplicating.
+ * `highs` here is already an unboxed `Array[Long]`, so storing the max value 
directly
+ * costs 4 bytes/row more than an Int index would (a Long vs. an Int), in 
exchange for
+ * comparing it without the extra `highs(idx)` indirection on every node a 
probe visits.
+ */
+private[joins] class LongIntervalIndex private (
+    private[this] var _keyType: DataType,
+    private[this] var lows: Array[Long],
+    private[this] var highs: Array[Long],
+    private[this] var maxHighs: Array[Long],
+    private[this] var rows: Array[InternalRow])
+  extends LongKeyConversion with Externalizable with KryoSerializable {
+
+  // Needed for Java/Kryo deserialization; see readExternal/read below.
+  def this() = this(null, null, null, null, null)
+
+  def keyType: DataType = _keyType
+
+  // All-covering window bounds on raw keys: `lows` is sorted, so the last 
entry
+  // is the largest low, bounding every low from above, and coreHigh is the
+  // minimum high, bounding every high from below. A window overlaps every
+  // stored interval exactly when it holds probeLow <= coreHigh and
+  // probeHigh >= coreLow, whether or not a common core exists.
+  // Recomputed in `read` too, since `lows`/`highs` are reassigned there.
+  private[this] var coreLow: Long = LongIntervalIndex.coreLow(lows)
+  private[this] var coreHigh: Long = LongIntervalIndex.coreHigh(highs)
+
+  override def estimatedSize: Long =
+    SizeEstimator.estimate(Array[AnyRef](lows, highs, maxHighs, rows))
+
+  def overlapping(low: Long, high: Long): Iterator[InternalRow] = {
+    if (lows.isEmpty) return Iterator.empty
+    var probeLow = low
+    var probeHigh = high
+    if (probeLow > probeHigh) {
+      val t = probeLow
+      probeLow = probeHigh
+      probeHigh = t
+    }
+    // A window that covers every interval gets every row the walk would
+    // return, without visiting the tree. Returns all rows iff every interval
+    // overlaps the probe; the condition still re-checks each candidate.
+    if (probeLow <= coreHigh && probeHigh >= coreLow) {
+      return new RowSlice(rows, 0, rows.length)
+    }
+    new LongOverlapIterator(lows, highs, rows, maxHighs, probeLow, probeHigh)
+  }
+
+  override def writeExternal(out: ObjectOutput): Unit = Utils.tryOrIOException 
{
+    write(out.writeUTF, out.writeInt, out.writeLong,
+      (o: Any) => out.writeObject(o.asInstanceOf[AnyRef]))
+  }
+
+  override def write(kryo: Kryo, out: Output): Unit = Utils.tryOrIOException {
+    write(out.writeString, out.writeInt, out.writeLong,
+      (o: Any) => kryo.writeClassAndObject(out, o.asInstanceOf[AnyRef]))
+  }
+
+  private def write(
+      writeString: String => Unit,
+      writeInt: Int => Unit,
+      writeLong: Long => Unit,
+      writeObj: Any => Unit): Unit = {
+    writeString(keyType.json)
+    val n = lows.length
+    writeInt(n)
+    var i = 0
+    while (i < n) {
+      writeLong(lows(i))
+      writeLong(highs(i))
+      writeLong(maxHighs(i))
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      writeObj(rows(i))
+      i += 1
+    }
+  }
+
+  override def readExternal(in: ObjectInput): Unit = Utils.tryOrIOException {
+    read(() => in.readUTF(), () => in.readInt(), () => in.readLong(), () => 
in.readObject())
+  }
+
+  override def read(kryo: Kryo, in: Input): Unit = Utils.tryOrIOException {
+    read(() => in.readString(), () => in.readInt(), () => in.readLong(),
+      () => kryo.readClassAndObject(in))
+  }
+
+  private def read(
+      readString: () => String,
+      readInt: () => Int,
+      readLong: () => Long,
+      readObj: () => AnyRef): Unit = {
+    _keyType = DataType.fromJson(readString())
+    val n = readInt()
+    lows = new Array[Long](n)
+    highs = new Array[Long](n)
+    maxHighs = new Array[Long](n)
+    rows = new Array[InternalRow](n)
+    var i = 0
+    while (i < n) {
+      lows(i) = readLong()
+      highs(i) = readLong()
+      maxHighs(i) = readLong()
+      i += 1
+    }
+    i = 0
+    while (i < n) {
+      rows(i) = readObj().asInstanceOf[InternalRow]
+      i += 1
+    }
+    coreLow = LongIntervalIndex.coreLow(lows)
+    coreHigh = LongIntervalIndex.coreHigh(highs)
+  }
+}
+
+private[joins] object LongIntervalIndex {
+
+  // Shared by the primary constructor and `read`, which recomputes these
+  // caches after reassigning `lows`/`highs` on deserialization. `lows`/
+  // `highs` are null right after the zero-arg constructor runs, before
+  // `readExternal`/`read` populate them.
+  private[joins] def coreLow(lows: Array[Long]): Long =
+    if (lows == null || lows.isEmpty) {
+      Long.MinValue
+    } else {
+      lows(lows.length - 1)
+    }
+
+  private[joins] def coreHigh(highs: Array[Long]): Long = {
+    if (highs == null || highs.isEmpty) {
+      Long.MaxValue
+    } else {
+      var min = Long.MaxValue
+      var i = 0
+      while (i < highs.length) {
+        if (highs(i) < min) min = highs(i)
+        i += 1
+      }
+      min
+    }
+  }
+
+  def build(
+      keyType: DataType,
+      buildKeys: Seq[Expression],
+      rows: Iterator[InternalRow],
+      sizeHint: Option[Long]): LongIntervalIndex = {
+    val toLongs: Seq[(InternalRow, Int) => Long] =
+      buildKeys.map(k => RangeIndex.requiredLongKey(k.dataType).field)
+    val valid = new ArrayBuffer[(Long, Long, 
InternalRow)](RangeIndex.initialCapacity(sizeHint))
+    val projection = new InterpretedProjection(buildKeys)

Review Comment:
   **Nit (P3):** `InterpretedProjection.apply` allocates a fresh `Array[Any]` 
and `GenericInternalRow` on every call and boxes each primitive key, and 
`LongKey.field` then reads it straight back with `getLong`/`getInt`. For a 
1M-row build that is 1M arrays, 1M rows, and 2M boxed longs on the driver's 
single-threaded broadcast path, which delays the broadcast stage under AQE. The 
builders at lines 399, 662, and 1106 do the same. `HashedRelation` builds its 
keys with one reused `UnsafeProjection.create(key)` (HashedRelation.scala:475). 
Could these builders do the same, keeping `InternalRow.copyValue` for string 
keys, since the reused projection overwrites its buffer, and the row `copy()`?
   
   **Verification:**
   - **Behavior:** For every supported key family, indexes built through the 
reused projection answer the same windows and bounds as before, including null 
keys, inverted intervals, and string keys backed by reused buffers.
   - **Inspection:** Each build creates its key projection once and copies only 
variable-length key values and rows per build row.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/BroadcastRangeJoinExec.scala:
##########
@@ -0,0 +1,592 @@
+/*
+ * 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.SparkException
+import org.apache.spark.broadcast.Broadcast
+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._
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, 
BuildSide}
+import org.apache.spark.sql.catalyst.planning.RangeJoinKind
+import org.apache.spark.sql.catalyst.plans._
+import org.apache.spark.sql.catalyst.plans.physical.{BroadcastDistribution, 
Distribution, Partitioning, UnspecifiedDistribution}
+import org.apache.spark.sql.execution.{CodegenSupport, ExplainUtils, SparkPlan}
+import org.apache.spark.sql.execution.metric.SQLMetrics
+
+/**
+ * Performs a range join on two tables. Supported types are inner, left outer, 
right outer,
+ * left semi, and left anti. The preserved side is streamed: unmatched build 
rows are not
+ * emitted, because the broadcast is copied to every partition. See 
`ExtractRangeJoinKeys`
+ * for the exact set of join-condition shapes that are recognized as a range 
join.
+ *
+ * The build side is an index. Point-in-range and interval overlap both build 
an
+ * [[IntervalIndex]] and probe it with `overlapping`. A single inequality 
builds a
+ * [[PointIndex]] and scans the side of the bound the build side holds. Keys 
that
+ * map to one order-preserving long instead build [[LongIntervalIndex]] or
+ * [[LongPointIndex]], so their probes compare raw longs without boxing. Either
+ * probe returns a superset. A candidate is kept only when the original join
+ * condition is true, so inclusivity stays on the predicate. Heavy overlap can
+ * make one probe return almost every build row.
+ */
+case class BroadcastRangeJoinExec(
+    left: SparkPlan,
+    right: SparkPlan,
+    buildSide: BuildSide,
+    joinType: JoinType,
+    leftKeys: Seq[Expression],
+    rightKeys: Seq[Expression],
+    condition: Option[Expression],
+    rangeJoinKind: RangeJoinKind)
+  extends JoinCodegenSupport {
+
+  if (condition.isEmpty) {
+    throw SparkException.internalError("BroadcastRangeJoinExec requires a join 
condition.")
+  }
+
+  override lazy val metrics = Map(
+    "numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output 
rows"))
+
+  /** BuildRight means the right relation is the broadcast relation. */
+  private val (streamed, broadcast, streamedKeys, buildKeys) = buildSide match 
{
+    case BuildRight => (left, right, leftKeys, rightKeys)
+    case BuildLeft => (right, left, rightKeys, leftKeys)
+  }
+
+  override def simpleStringWithNodeId(): String = {
+    val opId = ExplainUtils.getOpId(this)
+    s"$nodeName $joinType $buildSide ($opId)".trim
+  }
+
+  override def requiredChildDistribution: Seq[Distribution] = {
+    val mode = RangeRelationBroadcastMode(
+      BindReferences.bindReferences(buildKeys, broadcast.output),
+      RangeIndexKind(rangeJoinKind))
+    buildSide match {
+      case BuildLeft =>
+        BroadcastDistribution(mode) :: UnspecifiedDistribution :: Nil
+      case BuildRight =>
+        UnspecifiedDistribution :: BroadcastDistribution(mode) :: Nil
+    }
+  }
+
+  override def outputPartitioning: Partitioning = streamed.outputPartitioning
+
+  override def outputOrdering: Seq[SortOrder] = streamed.outputOrdering
+
+  override def output: Seq[Attribute] = {
+    joinType match {
+      case _: InnerLike =>
+        left.output ++ right.output
+      case LeftOuter =>
+        left.output ++ right.output.map(_.withNullability(true))
+      case RightOuter =>
+        left.output.map(_.withNullability(true)) ++ right.output
+      case LeftSemi | LeftAnti =>
+        left.output
+      case t =>
+        throw SparkException.internalError(s"BroadcastRangeJoin should not 
take $t as the JoinType")
+    }
+  }
+
+  // The point-index scan used to probe this join's build side. See 
`PointScan.forJoin`.
+  private lazy val pointScan: PointScan = PointScan.forJoin(rangeJoinKind, 
buildSide)
+
+  private def perPartition(
+      relation: Broadcast[RangeRelation])(
+      f: BroadcastRangeJoinExec.ProbeContext => Iterator[InternalRow]): 
RDD[InternalRow] = {
+    val boundStreamKeys = BindReferences.bindReferences(streamedKeys, 
streamed.output)
+    val cond = condition.get
+    val schema = left.output ++ right.output
+    val numBuildFields = broadcast.output.length
+    val kind = rangeJoinKind
+    val side = buildSide
+    streamed.execute().mapPartitionsInternal { streamedIter =>
+      val candidateFn = BroadcastRangeJoinExec.makeCandidateFn(
+        relation.value, boundStreamKeys, PointScan.forJoin(kind, side))
+      f(new BroadcastRangeJoinExec.ProbeContext(
+        streamedIter,
+        Predicate.create(cond, schema).eval _,
+        new JoinedRow,
+        new GenericInternalRow(numBuildFields),
+        candidateFn,
+        side))
+    }
+  }
+
+  private def innerJoin(relation: Broadcast[RangeRelation]): RDD[InternalRow] 
= {
+    perPartition(relation) { p =>
+      p.stream.flatMap { row =>
+        p.candidates(row).flatMap { buildRow =>
+          val joined = p.join(row, buildRow)
+          if (p.condition(joined)) Iterator.single(joined) else Iterator.empty

Review Comment:
   **Nit (P3):** `flatMap` with `Iterator.single(joined)` allocates one 
iterator per accepted pair, and because flatMap holds it in its 
current-iterator field, escape analysis can't remove it. 
`BroadcastNestedLoopJoinExec.innerJoin` does the same work with 
`buildRows.iterator.map(r => joinedRow(streamedRow, 
r)).filter(boundCondition)`. `p.candidates(row).map(p.join(row, 
_)).filter(p.condition)` returns the same rows here, since the result 
projection copies each row before the iterator advances. This only matters on 
the interpreted path: whole-stage codegen disabled, or the join falling out of 
it.
   
   **Verification:**
   - **Behavior:** With whole-stage codegen disabled, inner range joins for 
every key family and build side return the nested-loop result.
   - **Inspection:** The interpreted inner-join path creates no iterator per 
accepted candidate.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/RangeJoinSQLSuite.scala:
##########
@@ -0,0 +1,793 @@
+/*
+ * 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.SparkException
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.planning.{IntervalOverlapJoin, 
LessPartialRangeJoin}
+import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti, LeftOuter, 
LeftSemi}
+import org.apache.spark.sql.execution._
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.DoubleType
+
+/**
+ * Planner and end-to-end coverage for broadcast range join.
+ *
+ * Which condition is recognized: `ExtractRangeJoinKeysSuite`.
+ * What the index returns: `RangeIndexSuite`.
+ * What the operator returns: `RangeJoinSuite`.
+ * This suite checks that the planner chooses the operator, and that the SQL
+ * result matches broadcast nested loop join where that comparison is the 
point.
+ */
+class RangeJoinSQLSuite extends QueryTest with SharedSparkSession with 
AdaptiveSparkPlanHelper {
+
+  private val pointCond =
+    "s.ip_add_int >= l.BEGIN_IP2LONG AND s.ip_add_int <= l.END_IP2LONG"
+
+  private def findRangeJoin(plan: SparkPlan): Seq[BroadcastRangeJoinExec] = {
+    collect(plan) { case j: BroadcastRangeJoinExec => j }
+  }
+
+  /** `n` range joins sit in one whole-stage, not in nested stages or input 
adapters. */
+  private def oneCodegenStageOwns(plan: SparkPlan, n: Int): Boolean = {
+    exists(plan) {
+      case w: WholeStageCodegenExec =>
+        def count(p: SparkPlan): Int = p match {
+          case _: InputAdapter | _: WholeStageCodegenExec => 0
+          case j: BroadcastRangeJoinExec => 1 + j.children.map(count).sum
+          case other => other.children.map(count).sum
+        }
+        count(w.child) == n
+      case _ => false
+    }
+  }
+
+  /** The range join is compiled into a whole-stage, not hidden under an input 
adapter. */
+  private def rangeJoinIsCodegen(plan: SparkPlan): Boolean = {
+    exists(plan) {
+      case w: WholeStageCodegenExec =>
+        def contains(p: SparkPlan): Boolean = p match {
+          case _: InputAdapter => false
+          case _: BroadcastRangeJoinExec => true
+          case other => other.children.exists(contains)
+        }
+        contains(w.child)
+      case _ => false
+    }
+  }
+
+  private def findBroadcastNestedLoopJoin(plan: SparkPlan): 
Seq[BroadcastNestedLoopJoinExec] = {
+    collect(plan) { case j: BroadcastNestedLoopJoinExec => j }
+  }
+
+  private def findCartesianProduct(plan: SparkPlan): Seq[CartesianProductExec] 
= {
+    collect(plan) { case c: CartesianProductExec => c }
+  }
+
+  private def withRangeJoin(extra: (String, String)*)(f: => Unit): Unit = {
+    withSQLConf((Seq(SQLConf.BROADCAST_RANGE_JOIN_ENABLED.key -> "true") ++ 
extra): _*)(f)
+  }
+
+  /**
+   * One query per key family and join type that a range join can plan, shared 
by
+   * "whole-stage codegen covers each range-join probe" and "interpreted 
execution
+   * covers each range-join probe" so the two coverage lists cannot drift 
apart.
+   */
+  private def probeCoverageQueries: Seq[String] = Seq(
+    s"SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l ON $pointCond",
+    s"SELECT s.id, l.zip_id FROM signin_ip s LEFT JOIN ip_lookup l ON 
$pointCond",
+    s"SELECT s.id, l.zip_id FROM signin_ip s RIGHT JOIN ip_lookup l ON 
$pointCond",
+    s"SELECT s.id FROM signin_ip s LEFT SEMI JOIN ip_lookup l ON $pointCond",
+    s"SELECT s.id FROM signin_ip s LEFT ANTI JOIN ip_lookup l ON $pointCond",
+    "SELECT p.p, r.lo, r.hi FROM dec_points p JOIN dec_ranges r ON p.p >= r.lo 
AND p.p <= r.hi",
+    "SELECT /*+ BROADCAST(r) */ p.s, r.lo, r.hi FROM str_points p JOIN 
str_ranges r " +
+      "ON p.s >= r.lo AND p.s <= r.hi",
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM intervals_a a JOIN intervals_b b " +
+      "ON a.lo < b.hi AND b.lo < a.hi",
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM intervals_a a LEFT JOIN intervals_b b 
ON a.hi < b.lo",
+    "SELECT /*+ BROADCAST(a) */ a.lo, b.hi FROM intervals_a a JOIN intervals_b 
b ON a.lo < b.hi",
+    // DOUBLE keys stay on the boxed interval index (see 
setupDoubleIntervalViews), unlike
+    // the INT two-key overlap above, which is long-specialized.
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM dbl_ranges_a a JOIN dbl_ranges_b b " +
+      "ON a.lo < b.hi AND b.lo < a.hi",
+    // Double and string keys stay on the boxed point index, so these queries 
reach
+    // its generated probe code (or, interpreted, the per-index dispatch in
+    // BroadcastRangeJoinExec.makeCandidateFn), which int partial ranges never 
do.
+    "SELECT p.d, b.lo FROM dbl_partial_points p JOIN dbl_partial_bounds b ON 
p.d < b.lo",

Review Comment:
   **Nit (P3):** These four boxed partial-range queries are all `<`/`>`, so 
`case points: PointIndex` in `makeCandidateFn` only ever runs 
`upToExclusive`/`fromExclusive`. The inclusive operator checks in 
`RangeJoinSuite` use int keys, which take the separate `LongPointIndex` arm, 
and `RangeIndexSuite` calls `PointIndex.upTo`/`from` directly. If the boxed 
`UpTo` arm mapped to `upToExclusive`, a codegen-off `p.d <= b.lo` would drop 
every build row equal to the probe, which the condition recheck can't recover. 
Could the list add `<=`/`>=` on DOUBLE and STRING in both directions, with 
probe values equal to build keys?
   
   **Verification:**
   - **Regression:** With whole-stage codegen enabled and disabled, 
less-or-equal and greater-or-equal range joins on double and string keys whose 
probe values equal build keys return the nested-loop result. The check fails if 
the boxed inclusive scan is replaced by its exclusive form.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/BroadcastRangeJoinExec.scala:
##########
@@ -0,0 +1,592 @@
+/*
+ * 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.SparkException
+import org.apache.spark.broadcast.Broadcast
+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._
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, 
BuildSide}
+import org.apache.spark.sql.catalyst.planning.RangeJoinKind
+import org.apache.spark.sql.catalyst.plans._
+import org.apache.spark.sql.catalyst.plans.physical.{BroadcastDistribution, 
Distribution, Partitioning, UnspecifiedDistribution}
+import org.apache.spark.sql.execution.{CodegenSupport, ExplainUtils, SparkPlan}
+import org.apache.spark.sql.execution.metric.SQLMetrics
+
+/**
+ * Performs a range join on two tables. Supported types are inner, left outer, 
right outer,
+ * left semi, and left anti. The preserved side is streamed: unmatched build 
rows are not
+ * emitted, because the broadcast is copied to every partition. See 
`ExtractRangeJoinKeys`
+ * for the exact set of join-condition shapes that are recognized as a range 
join.
+ *
+ * The build side is an index. Point-in-range and interval overlap both build 
an
+ * [[IntervalIndex]] and probe it with `overlapping`. A single inequality 
builds a
+ * [[PointIndex]] and scans the side of the bound the build side holds. Keys 
that
+ * map to one order-preserving long instead build [[LongIntervalIndex]] or
+ * [[LongPointIndex]], so their probes compare raw longs without boxing. Either
+ * probe returns a superset. A candidate is kept only when the original join
+ * condition is true, so inclusivity stays on the predicate. Heavy overlap can
+ * make one probe return almost every build row.
+ */
+case class BroadcastRangeJoinExec(
+    left: SparkPlan,
+    right: SparkPlan,
+    buildSide: BuildSide,
+    joinType: JoinType,
+    leftKeys: Seq[Expression],
+    rightKeys: Seq[Expression],
+    condition: Option[Expression],
+    rangeJoinKind: RangeJoinKind)
+  extends JoinCodegenSupport {
+
+  if (condition.isEmpty) {
+    throw SparkException.internalError("BroadcastRangeJoinExec requires a join 
condition.")
+  }
+
+  override lazy val metrics = Map(
+    "numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output 
rows"))
+
+  /** BuildRight means the right relation is the broadcast relation. */
+  private val (streamed, broadcast, streamedKeys, buildKeys) = buildSide match 
{
+    case BuildRight => (left, right, leftKeys, rightKeys)
+    case BuildLeft => (right, left, rightKeys, leftKeys)
+  }
+
+  override def simpleStringWithNodeId(): String = {
+    val opId = ExplainUtils.getOpId(this)
+    s"$nodeName $joinType $buildSide ($opId)".trim
+  }
+
+  override def requiredChildDistribution: Seq[Distribution] = {
+    val mode = RangeRelationBroadcastMode(
+      BindReferences.bindReferences(buildKeys, broadcast.output),
+      RangeIndexKind(rangeJoinKind))
+    buildSide match {
+      case BuildLeft =>
+        BroadcastDistribution(mode) :: UnspecifiedDistribution :: Nil
+      case BuildRight =>
+        UnspecifiedDistribution :: BroadcastDistribution(mode) :: Nil
+    }
+  }
+
+  override def outputPartitioning: Partitioning = streamed.outputPartitioning
+
+  override def outputOrdering: Seq[SortOrder] = streamed.outputOrdering
+
+  override def output: Seq[Attribute] = {
+    joinType match {
+      case _: InnerLike =>
+        left.output ++ right.output
+      case LeftOuter =>
+        left.output ++ right.output.map(_.withNullability(true))
+      case RightOuter =>
+        left.output.map(_.withNullability(true)) ++ right.output
+      case LeftSemi | LeftAnti =>
+        left.output
+      case t =>
+        throw SparkException.internalError(s"BroadcastRangeJoin should not 
take $t as the JoinType")
+    }
+  }
+
+  // The point-index scan used to probe this join's build side. See 
`PointScan.forJoin`.
+  private lazy val pointScan: PointScan = PointScan.forJoin(rangeJoinKind, 
buildSide)
+
+  private def perPartition(
+      relation: Broadcast[RangeRelation])(
+      f: BroadcastRangeJoinExec.ProbeContext => Iterator[InternalRow]): 
RDD[InternalRow] = {
+    val boundStreamKeys = BindReferences.bindReferences(streamedKeys, 
streamed.output)
+    val cond = condition.get
+    val schema = left.output ++ right.output
+    val numBuildFields = broadcast.output.length
+    val kind = rangeJoinKind
+    val side = buildSide
+    streamed.execute().mapPartitionsInternal { streamedIter =>
+      val candidateFn = BroadcastRangeJoinExec.makeCandidateFn(
+        relation.value, boundStreamKeys, PointScan.forJoin(kind, side))
+      f(new BroadcastRangeJoinExec.ProbeContext(
+        streamedIter,
+        Predicate.create(cond, schema).eval _,
+        new JoinedRow,
+        new GenericInternalRow(numBuildFields),
+        candidateFn,
+        side))
+    }
+  }
+
+  private def innerJoin(relation: Broadcast[RangeRelation]): RDD[InternalRow] 
= {
+    perPartition(relation) { p =>
+      p.stream.flatMap { row =>
+        p.candidates(row).flatMap { buildRow =>
+          val joined = p.join(row, buildRow)
+          if (p.condition(joined)) Iterator.single(joined) else Iterator.empty
+        }
+      }
+    }
+  }
+
+  /**
+   * Emit each candidate the condition accepts. When none were, emit the stream
+   * row once with the build side padded by nulls.
+   *
+   * The loop test checks `streamRow` before `stream.hasNext`. A whole-stage
+   * stream reuses one `UnsafeRow`, and `hasNext` fills that buffer. It must
+   * not run while the current row is still being joined.
+   */
+  private def outerJoin(relation: Broadcast[RangeRelation]): RDD[InternalRow] 
= {
+    perPartition(relation) { p =>
+      new Iterator[InternalRow] {
+        private var streamRow: InternalRow = null
+        private var matches: Iterator[InternalRow] = Iterator.empty
+        private var foundMatch: Boolean = false
+        private var resultRow: InternalRow = null
+
+        private def findNext(): Boolean = {
+          while (streamRow != null || p.stream.hasNext) {

Review Comment:
   **Nit (P3):** The ordering here is load-bearing. When the join falls out of 
whole-stage codegen (output wider than `spark.sql.codegen.maxFields`, or a 
`CodegenFallback` conjunct) while its streamed child stays code-generated, 
`hasNext` refills the child's shared `UnsafeRow`. Every interpreted-path test 
turns whole-stage codegen off globally, though, so the streamed child never 
reuses a buffer, and reordering this to `p.stream.hasNext || streamRow != null` 
would keep every test green. A VALUES view doesn't help either: 
`CollapseCodegenStages` never makes a `LocalTableScanExec` a whole-stage root, 
and it hands out its own rows. Could you add left and right outer cases with 
codegen on, the join excluded through the field limit, a streamed side computed 
over `range()` so it reuses its output row, and several matches per streamed 
row?
   
   **Verification:**
   - **Regression:** With whole-stage codegen enabled, the range join excluded 
from it, and its streamed side produced by a code-generated operator that 
reuses its output row, left and right outer range joins whose streamed rows 
each match several build rows return the nested-loop result. The check fails if 
the stream is advanced while candidates for the current row remain.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/RangeIndexSuite.scala:
##########
@@ -0,0 +1,1086 @@
+/*
+ * 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 java.io.{ByteArrayInputStream, ByteArrayOutputStream, 
ObjectInputStream, ObjectOutputStream}
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.BoundReference
+import org.apache.spark.sql.catalyst.types.PhysicalDataType
+import org.apache.spark.sql.types._
+import org.apache.spark.unsafe.Platform
+import org.apache.spark.unsafe.types.UTF8String
+
+/**
+ * Unit tests for the range-join indexes: the boxed pair ([[IntervalIndex]],
+ * [[PointIndex]]), the raw-long pair ([[LongIntervalIndex]], 
[[LongPointIndex]]),
+ * and the broadcast-mode routing that picks between the two families.
+ * Operator-level behavior is covered by `RangeJoinSuite`.
+ */
+class RangeIndexSuite extends SparkFunSuite {
+
+  private def row(id: Int): InternalRow = InternalRow(id)
+
+  private def span(lo: Any, hi: Any, id: Int): (Any, Any, InternalRow) = (lo, 
hi, row(id))
+
+  private def buildIntervals(spans: Seq[(Any, Any, InternalRow)]): 
IntervalIndex =
+    buildIntervals(IntegerType, spans)
+
+  /**
+   * Builds the boxed interval index through the production build. The row
+   * layout puts the asserted id at ordinal 0, exactly where a real build row
+   * carries its columns; the keys bind to ordinals 1 and 2.
+   */
+  private def buildIntervals(
+      keyType: DataType,
+      spans: Seq[(Any, Any, InternalRow)]): IntervalIndex = {
+    val keys = Seq(
+      BoundReference(1, keyType, nullable = true),
+      BoundReference(2, keyType, nullable = true))
+    IntervalIndex.build(keyType, keys,
+      spans.iterator.map(t => InternalRow(t._3.getInt(0), t._1, t._2)),
+      Some(spans.size.toLong))
+  }
+
+  /** The point-index build with the same row layout: id at 0, key at 1. */
+  private def buildPoints(keyType: DataType, keyed: Seq[(Any, InternalRow)]): 
PointIndex = {
+    val key = Seq(BoundReference(1, keyType, nullable = true))
+    PointIndex.build(keyType, key,
+      keyed.iterator.map(t => InternalRow(t._2.getInt(0), t._1)),
+      Some(keyed.size.toLong))
+  }
+
+  private def ids(rows: Iterator[InternalRow]): List[Int] = 
rows.map(_.getInt(0)).toList
+
+  private def probeIds(index: IntervalIndex, low: Any, high: Any): List[Int] =
+    ids(index.overlapping(low, high))
+
+  /**
+   * An iterator that hands back the same mutable row on every `next()` call, 
each field
+   * set to the current index. Mirrors how Spark's row iterators commonly 
reuse a single
+   * buffer (e.g. `UnsafeRow` deserialization) instead of allocating a fresh 
row per record.
+   */
+  private def reusingRowIterator(
+      fields: Int,
+      n: Int): (org.apache.spark.sql.catalyst.expressions.GenericInternalRow,
+      Iterator[InternalRow]) = {
+    val shared = new 
org.apache.spark.sql.catalyst.expressions.GenericInternalRow(fields)
+    val iter = new Iterator[InternalRow] {
+      private var i = 0
+      override def hasNext: Boolean = i < n
+      override def next(): InternalRow = {
+        var c = 0
+        while (c < fields) { shared.update(c, i); c += 1 }
+        i += 1
+        shared
+      }
+    }
+    (shared, iter)
+  }
+
+  private def reusingByteBufferStringIterator(
+      n: Int): (Array[Byte], Iterator[InternalRow]) = {
+    val sharedBytes = new Array[Byte](4)
+    val sharedRow = new 
org.apache.spark.sql.catalyst.expressions.GenericInternalRow(2)
+    val iter = new Iterator[InternalRow] {
+      private var i = 0
+      override def hasNext: Boolean = i < n
+      override def next(): InternalRow = {
+        val s = f"$i%04d"
+        java.lang.System.arraycopy(s.getBytes("UTF-8"), 0, sharedBytes, 0, 4)
+        val utf8 = UTF8String.fromAddress(sharedBytes, 
Platform.BYTE_ARRAY_OFFSET, 4)
+        sharedRow.update(0, utf8)
+        sharedRow.update(1, i)
+        i += 1
+        sharedRow
+      }
+    }
+    (sharedBytes, iter)
+  }
+
+  private def javaRoundTrip(index: IntervalIndex): (Array[Byte], 
IntervalIndex) = {
+    val bytes = new ByteArrayOutputStream()
+    val out = new ObjectOutputStream(bytes)
+    out.writeObject(index)
+    out.close()
+    val raw = bytes.toByteArray
+    val in = new ObjectInputStream(new ByteArrayInputStream(raw))
+    try {
+      (raw, in.readObject().asInstanceOf[IntervalIndex])
+    } finally {
+      in.close()
+    }
+  }
+
+  test("build keeps a point, orders an inverted interval, and drops a null 
bound") {
+    val index = buildIntervals(Seq(
+      span(5, 5, 5),
+      span(1, 3, 1),
+      span(3, 1, 3),
+      (null, 1, row(1))))
+    // (3, 1) is stored as [1, 3]. The null bound is absent.
+    assert(ids(index.overlapping(2, 2)).sorted == List(1, 3))
+    assert(ids(index.overlapping(5, 5)) == List(5))
+    assert(ids(index.overlapping(4, 4)).isEmpty)
+  }
+
+  test("IntervalIndex.build defends against a build-side iterator reusing its 
row buffer") {
+    // If build() captured keys or rows by reference instead of copying them, 
every stored
+    // entry would collapse to the iterator's last value once the shared 
buffer is mutated
+    // again after the build-side iterator is drained -- the hazard guarded 
against is any
+    // producer whose rows or projected keys alias a reused buffer (e.g. a 
typed UDF result),
+    // not specifically BroadcastExchangeExec: its own row iterator decodes a 
fresh UnsafeRow
+    // per record.
+    val n = 50
+    val buildKeys = Seq(
+      BoundReference(0, IntegerType, nullable = true),
+      BoundReference(1, IntegerType, nullable = true))
+    val (shared, rows) = reusingRowIterator(fields = 3, n)
+    val index = IntervalIndex.build(IntegerType, buildKeys, rows, Some(n))
+
+    // Simulate the caller reusing the buffer after the build-side iterator is 
exhausted.
+    shared.update(0, -1)
+    shared.update(1, -1)
+    shared.update(2, -1)
+
+    (0 until n).foreach { i =>
+      val payloads = index.overlapping(i, i).map(_.getInt(2)).toList
+      assert(payloads == List(i), s"interval $i was corrupted by later reuse 
of the row buffer")
+    }
+  }
+
+  test("IntervalIndex.build defends against string keys sharing a reused byte 
buffer") {
+    val n = 50
+    val buildKeys = Seq(
+      BoundReference(0, StringType, nullable = true),
+      BoundReference(0, StringType, nullable = true))
+    val (sharedBytes, rows) = reusingByteBufferStringIterator(n)
+    val index = IntervalIndex.build(StringType, buildKeys, rows, Some(n))
+
+    java.util.Arrays.fill(sharedBytes, 'X'.toByte)
+
+    (0 until n).foreach { i =>
+      val key = UTF8String.fromString(f"$i%04d")
+      val matching = index.overlapping(key, key).toList
+      assert(matching.size == 1, s"interval $i was corrupted by shared buffer 
reuse")
+      assert(matching.head.getInt(1) == i)
+    }
+  }
+
+  test("a one-key interval build owns a single copy of the point key") {
+    // One build key is one key used as both ends. The build must copy it once
+    // and serve both bounds from that copy, instead of copying twice.
+    val n = 50
+    val oneKey = Seq(BoundReference(0, StringType, nullable = true))
+    val (sharedBytes, rows) = reusingByteBufferStringIterator(n)
+    val index = IntervalIndex.build(StringType, oneKey, rows, Some(n))
+
+    java.util.Arrays.fill(sharedBytes, 'X'.toByte)
+
+    (0 until n).foreach { i =>
+      val key = UTF8String.fromString(f"$i%04d")
+      val matching = index.overlapping(key, key).toList
+      assert(matching.size == 1, s"interval $i was corrupted by shared buffer 
reuse")
+      assert(matching.head.getInt(1) == i)
+    }
+
+    // SizeEstimator's identity tracking charges the shared bounds once, so the
+    // estimate stays below the same build made from two independently copied 
keys.
+    val twoKeys = Seq(
+      BoundReference(0, StringType, nullable = true),
+      BoundReference(0, StringType, nullable = true))
+    val (_, moreRows) = reusingByteBufferStringIterator(n)
+    val twoCopy = IntervalIndex.build(StringType, twoKeys, moreRows, Some(n))
+    assert(index.estimatedSize < twoCopy.estimatedSize)
+  }
+
+  test("an inverted build interval reaches the windows the condition accepts") 
{
+    // Int keys route to the long index, so this also pins swap normalization 
there.
+    val index = RangeRelationBroadcastMode(
+      Seq(BoundReference(0, IntegerType, nullable = true),
+        BoundReference(1, IntegerType, nullable = true)),
+      IntervalIndexKind)
+      .transform(Array(InternalRow(3, 1), InternalRow(0, 5)))
+      .asInstanceOf[LongIntervalIndex]
+    assert(index.overlapping(0L, 4L).map(_.getInt(0)).toSet == Set(3, 0))
+    assert(index.overlapping(4L, 9L).map(_.getInt(0)).toList == List(0))
+  }
+
+  test("interval tree returns each overlapping row once") {
+    // Spans arrive out of low order: [10, 20] then [0, 5].
+    val unsorted = buildIntervals(Seq(span(10, 20, 0), span(0, 5, 1)))
+    assert(probeIds(unsorted, 3, 3) == List(1))
+    assert(probeIds(unsorted, 15, 15) == List(0))
+
+    // Probe 50 is a build key. Row 0 spans it; row 1 starts there.
+    val spanning = buildIntervals(Seq(span(0, 100, 0), span(50, 200, 1)))
+    assert(probeIds(spanning, 50, 50).toSet == Set(0, 1))
+    assert(probeIds(spanning, 25, 25) == List(0))
+    assert(probeIds(spanning, 150, 150) == List(1))
+
+    val duplicated = buildIntervals(Seq(span(0, 100, 0), span(0, 100, 1)))
+    assert(probeIds(duplicated, 50, 50).sorted == List(0, 1))
+
+    val nested = buildIntervals(Seq(
+      span(0, 100, 0), span(10, 20, 1), span(10, 50, 2), span(90, 110, 3)))
+    def assertOnce(low: Int, expected: Set[Int]): Unit = {
+      val actual = probeIds(nested, low, low)
+      assert(actual.toSet == expected, s"probe $low -> $actual")
+      assert(actual.size == expected.size, s"probe $low duplicated $actual")
+    }
+    assertOnce(0, Set(0))
+    assertOnce(10, Set(0, 1, 2))
+    assertOnce(15, Set(0, 1, 2))
+    assertOnce(30, Set(0, 2))
+    assertOnce(95, Set(0, 3))
+    assertOnce(105, Set(3))
+    assertOnce(200, Set.empty)
+
+    assert(probeIds(buildIntervals(Seq.empty), 1, 1).isEmpty)
+
+    val withNull = buildIntervals(Seq(span(0, 10, 0), span(5, 5, 1), (null, 4, 
row(9))))
+    assert(probeIds(withNull, null, 5).isEmpty)
+    assert(probeIds(withNull, 5, null).isEmpty)
+    // The null bound is dropped, so two intervals remain and the estimate 
covers both.
+    val one = buildIntervals(Seq(span(0, 10, 0)))
+    assert(withNull.estimatedSize > one.estimatedSize)
+
+    // At an interior endpoint, the range that ends and the one that starts
+    // are each a candidate once.
+    val chained = buildIntervals(Seq(span(0, 1, 0), span(1, 2, 1), span(2, 3, 
2)))
+    assert(probeIds(chained, 0, 0) == List(0))
+    assert(probeIds(chained, 1, 1).sorted == List(0, 1))
+    assert(probeIds(chained, 2, 2).sorted == List(1, 2))
+    assert(probeIds(chained, 3, 3) == List(2))
+
+    // A probe whose endpoints are reversed is the closed window between them.
+    // [10, 25] meets [0, 30], [15, 40], and the point at 15.
+    val inverted = buildIntervals(Seq(span(0, 30, 0), span(15, 40, 1), 
span(15, 15, 2)))
+    assert(probeIds(inverted, 25, 10).sorted == List(0, 1, 2))
+    assert(probeIds(inverted, 10, 20).sorted == List(0, 1, 2))
+  }
+
+  test("interval tree is correct under heavy build-side overlap") {
+    // Every interval nests inside the one before it, so a point in the middle
+    // hits about half of the rows. The tree still stores one node per row.

Review Comment:
   **Nit (P3):** With `span(i, 2 * n - i, i)` for i < 300, every interval 
contains 300, so a probe in the middle hits all 300 rows. It's the probe at `n 
/ 2 = 150` that hits about half (151). Could the comment say that?
   
   **Verification:**
   - **Inspection:** The comment's statement of how many rows the middle and 
quarter-domain probes hit matches the nested-interval fixture it documents.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/metric/SQLMetricsSuite.scala:
##########
@@ -632,6 +632,26 @@ class SQLMetricsSuite extends SharedSparkSession with 
SQLMetricsTestUtils
     }
   }
 
+  test("BroadcastRangeJoin metrics") {
+    withSQLConf(SQLConf.BROADCAST_RANGE_JOIN_ENABLED.key -> "true") {
+      withTempView("rangeProbe", "rangeBuild") {
+        sql("CREATE OR REPLACE TEMP VIEW rangeProbe(p) AS VALUES (5), (15), 
(25)")
+        sql("CREATE OR REPLACE TEMP VIEW rangeBuild(lo, hi) AS VALUES (0, 10), 
(10, 20), (20, 30)")
+        // ... -> BroadcastRangeJoin(nodeId = 1) -> TungstenProject(nodeId = 0)
+        val query = "SELECT /*+ BROADCAST(b) */ p.p, b.lo, b.hi FROM 
rangeProbe p " +
+          "JOIN rangeBuild b ON p.p >= b.lo AND p.p <= b.hi"

Review Comment:
   **Nit (P3):** Thanks for adding this. With probes 5/15/25 against 
[0,10]/[10,20]/[20,30], each probe surfaces exactly one candidate through the 
tree walk and every candidate passes, so moving `$numOutput.add(1)` in 
`codegenInner` outside `$checkCondition` would still report 3. The outer 
(including the null-padded row), left semi, and left anti increments in 
`codegenOuter`, `codegenLeftExistence`, and the interpreted map in `doExecute` 
are still never read. Could the test add a fixture where some candidates fail 
the condition, plus left and right outer joins with unmatched streamed rows and 
left semi/anti joins, with whole-stage codegen on and off? Only the reported 
counts are at stake, not query results.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59027#pullrequestreview-5628337351).
   
   **Verification:**
   - **Regression:** With whole-stage codegen enabled and disabled, the range 
join reports a 'number of output rows' equal to the rows it returns for: inner 
joins whose candidates include rows the condition rejects, left and right outer 
joins with unmatched streamed rows, and left semi and left anti joins. The 
assertion fails if any path counts a rejected candidate, double-counts a 
null-padded row, or skips an output row.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/planning/ExtractRangeJoinKeysSuite.scala:
##########
@@ -0,0 +1,214 @@
+/*
+ * 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.catalyst.planning
+
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.plans.{Inner, PlanTest}
+import org.apache.spark.sql.catalyst.plans.logical.{Join, JoinHint, 
LocalRelation}
+import org.apache.spark.sql.types.{DayTimeIntervalType, DoubleType, 
IntegerType}
+
+/**
+ * Plan-level coverage for [[ExtractRangeJoinKeys]]: which condition shapes are
+ * recognized, which `RangeJoinKind` tag is produced, and what residual 
remains.
+ */
+class ExtractRangeJoinKeysSuite extends PlanTest {
+
+  private val points = LocalRelation($"p".int)
+  private val ranges = LocalRelation($"lo".int, $"hi".int)
+  private val intervalsA = LocalRelation($"lo".int, $"hi".int)
+  private val intervalsB = LocalRelation($"lo".int, $"hi".int)
+
+  private val p = points.output.head
+  private val rLo = ranges.output(0)
+  private val rHi = ranges.output(1)
+  private val aLo = intervalsA.output(0)
+  private val aHi = intervalsA.output(1)
+  private val bLo = intervalsB.output(0)
+  private val bHi = intervalsB.output(1)
+
+  private def join(left: LocalRelation, right: LocalRelation, cond: 
Expression): Join =
+    Join(left, right, Inner, Some(cond), JoinHint.NONE)
+
+  private def assertKeys(
+      left: LocalRelation,
+      right: LocalRelation,
+      cond: Expression,
+      expected: RangeJoinKind,
+      leftKeys: Seq[Expression],
+      rightKeys: Seq[Expression]): Unit = {
+    join(left, right, cond) match {
+      case ExtractRangeJoinKeys(_, actualLeft, actualRight, rangeJoinKind) =>
+        assert(rangeJoinKind === expected)
+        assert(actualLeft === leftKeys)
+        assert(actualRight === rightKeys)
+      case other => fail(s"expected $expected, got $other")
+    }
+  }
+
+  private def assertUnrecognized(plan: Join): Unit = {
+    assert(ExtractRangeJoinKeys.unapply(plan).isEmpty)
+  }
+
+  test("point-in-range keys are the point and the two bounds") {
+    // Inclusivity and conjunct order stay on the condition. BETWEEN lowers to 
a With,
+    // but RewriteWithExpression resolves it to a plain And of comparisons 
before physical
+    // planning runs, so by the time this extractor sees the condition it is 
already one
+    // of these shapes.
+    val inclusive = And(GreaterThanOrEqual(p, rLo), LessThanOrEqual(p, rHi))
+    val exclusive = And(GreaterThan(p, rLo), LessThan(p, rHi))
+    val highFirst = And(LessThanOrEqual(p, rHi), GreaterThan(p, rLo))
+    val lowFirst = And(GreaterThanOrEqual(p, rLo), LessThan(p, rHi))
+    Seq(inclusive, exclusive, highFirst, lowFirst).foreach { cond =>
+      assertKeys(points, ranges, cond, PointInRangeJoin, Seq(p), Seq(rLo, rHi))
+    }
+    assertKeys(
+      points, ranges, And(inclusive, Not(EqualTo(p, rLo))),
+      PointInRangeJoin, Seq(p), Seq(rLo, rHi))
+  }
+
+  test("point-in-range tie-break: the earliest candidate pair wins") {
+    // Three predicates give three candidate pairs under 
predicates.combinations(2):
+    // (P0, P1), (P0, P2), (P1, P2). (P0, P1) and (P0, P2) are each 
independently a
+    // valid point-in-range join -- p bounded by (rLo3, rHi3), and separately 
by
+    // (rLo3, rHi2) -- while (P1, P2) shares no endpoint. combinations(2) 
enumerates

Review Comment:
   **Nit (P3):** After `RangePredicate` normalization, `p <= rHi3` and `p <= 
rHi2` both have `p` as their low bound, so they do share an endpoint. They 
aren't a point-in-range pair because neither one's high equals the other's low, 
and the interval-overlap regrouping fails `place` because each side would mix 
both relations. Could the comment say that?
   
   **Verification:**
   - **Inspection:** The comment's explanation of why the later predicate pair 
is rejected matches the extractor's chaining and placement rules for those 
predicates.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/RangeJoinSQLSuite.scala:
##########
@@ -0,0 +1,793 @@
+/*
+ * 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.SparkException
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.planning.{IntervalOverlapJoin, 
LessPartialRangeJoin}
+import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti, LeftOuter, 
LeftSemi}
+import org.apache.spark.sql.execution._
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.DoubleType
+
+/**
+ * Planner and end-to-end coverage for broadcast range join.
+ *
+ * Which condition is recognized: `ExtractRangeJoinKeysSuite`.
+ * What the index returns: `RangeIndexSuite`.
+ * What the operator returns: `RangeJoinSuite`.
+ * This suite checks that the planner chooses the operator, and that the SQL
+ * result matches broadcast nested loop join where that comparison is the 
point.
+ */
+class RangeJoinSQLSuite extends QueryTest with SharedSparkSession with 
AdaptiveSparkPlanHelper {
+
+  private val pointCond =
+    "s.ip_add_int >= l.BEGIN_IP2LONG AND s.ip_add_int <= l.END_IP2LONG"
+
+  private def findRangeJoin(plan: SparkPlan): Seq[BroadcastRangeJoinExec] = {
+    collect(plan) { case j: BroadcastRangeJoinExec => j }
+  }
+
+  /** `n` range joins sit in one whole-stage, not in nested stages or input 
adapters. */
+  private def oneCodegenStageOwns(plan: SparkPlan, n: Int): Boolean = {
+    exists(plan) {
+      case w: WholeStageCodegenExec =>
+        def count(p: SparkPlan): Int = p match {
+          case _: InputAdapter | _: WholeStageCodegenExec => 0
+          case j: BroadcastRangeJoinExec => 1 + j.children.map(count).sum
+          case other => other.children.map(count).sum
+        }
+        count(w.child) == n
+      case _ => false
+    }
+  }
+
+  /** The range join is compiled into a whole-stage, not hidden under an input 
adapter. */
+  private def rangeJoinIsCodegen(plan: SparkPlan): Boolean = {
+    exists(plan) {
+      case w: WholeStageCodegenExec =>
+        def contains(p: SparkPlan): Boolean = p match {
+          case _: InputAdapter => false
+          case _: BroadcastRangeJoinExec => true
+          case other => other.children.exists(contains)
+        }
+        contains(w.child)
+      case _ => false
+    }
+  }
+
+  private def findBroadcastNestedLoopJoin(plan: SparkPlan): 
Seq[BroadcastNestedLoopJoinExec] = {
+    collect(plan) { case j: BroadcastNestedLoopJoinExec => j }
+  }
+
+  private def findCartesianProduct(plan: SparkPlan): Seq[CartesianProductExec] 
= {
+    collect(plan) { case c: CartesianProductExec => c }
+  }
+
+  private def withRangeJoin(extra: (String, String)*)(f: => Unit): Unit = {
+    withSQLConf((Seq(SQLConf.BROADCAST_RANGE_JOIN_ENABLED.key -> "true") ++ 
extra): _*)(f)
+  }
+
+  /**
+   * One query per key family and join type that a range join can plan, shared 
by
+   * "whole-stage codegen covers each range-join probe" and "interpreted 
execution
+   * covers each range-join probe" so the two coverage lists cannot drift 
apart.
+   */
+  private def probeCoverageQueries: Seq[String] = Seq(
+    s"SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l ON $pointCond",
+    s"SELECT s.id, l.zip_id FROM signin_ip s LEFT JOIN ip_lookup l ON 
$pointCond",
+    s"SELECT s.id, l.zip_id FROM signin_ip s RIGHT JOIN ip_lookup l ON 
$pointCond",
+    s"SELECT s.id FROM signin_ip s LEFT SEMI JOIN ip_lookup l ON $pointCond",
+    s"SELECT s.id FROM signin_ip s LEFT ANTI JOIN ip_lookup l ON $pointCond",
+    "SELECT p.p, r.lo, r.hi FROM dec_points p JOIN dec_ranges r ON p.p >= r.lo 
AND p.p <= r.hi",
+    "SELECT /*+ BROADCAST(r) */ p.s, r.lo, r.hi FROM str_points p JOIN 
str_ranges r " +
+      "ON p.s >= r.lo AND p.s <= r.hi",
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM intervals_a a JOIN intervals_b b " +
+      "ON a.lo < b.hi AND b.lo < a.hi",
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM intervals_a a LEFT JOIN intervals_b b 
ON a.hi < b.lo",
+    "SELECT /*+ BROADCAST(a) */ a.lo, b.hi FROM intervals_a a JOIN intervals_b 
b ON a.lo < b.hi",
+    // DOUBLE keys stay on the boxed interval index (see 
setupDoubleIntervalViews), unlike
+    // the INT two-key overlap above, which is long-specialized.
+    "SELECT a.lo, a.hi, b.lo, b.hi FROM dbl_ranges_a a JOIN dbl_ranges_b b " +
+      "ON a.lo < b.hi AND b.lo < a.hi",
+    // Double and string keys stay on the boxed point index, so these queries 
reach
+    // its generated probe code (or, interpreted, the per-index dispatch in
+    // BroadcastRangeJoinExec.makeCandidateFn), which int partial ranges never 
do.
+    "SELECT p.d, b.lo FROM dbl_partial_points p JOIN dbl_partial_bounds b ON 
p.d < b.lo",
+    "SELECT p.d, b.lo FROM dbl_partial_bounds b JOIN dbl_partial_points p ON 
p.d > b.lo",
+    "SELECT p.s, b.lo FROM str_partial_points p JOIN str_partial_bounds b ON 
p.s < b.lo",
+    "SELECT p.s, b.lo FROM str_partial_bounds b JOIN str_partial_points p ON 
p.s > b.lo")
+
+  private def setupIpLookupViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW signin_ip(id, ip_add_int) AS VALUES
+        |  ('1', 1467630666L),
+        |  ('2', 1186929717L),
+        |  ('3', 3501544352L),
+        |  ('31', 3501544320L),
+        |  ('32', 3501544383L),
+        |  ('33', 3501544330L),
+        |  ('34', 3501544390L),
+        |  ('4', 1626243295L),
+        |  ('41', 1626243072L),
+        |  ('42', 1626243327L),
+        |  ('5', 1206355995L),
+        |  ('5', 1206355995L),
+        |  ('6', CAST(NULL AS BIGINT)),
+        |  ('7', 0L)
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW ip_lookup(
+        |    BEGIN_IP2LONG, END_IP2LONG, TWO_CHAR_CNTRY_CD, zip_id) AS VALUES
+        |  (1467630592L, 1467630847L, 'de', 2380520L),
+        |  (1186929664L, 1186930175L, 'us', 6723936L),
+        |  (1186929664L, 1186930175L, 'us', 6723936L),
+        |  (3501544320L, 3501544383L, 'ca', 12804631L),
+        |  (3501544330L, 3501544390L, 'ca', 12804631001L),
+        |  (1626243072L, 1626243327L, 'us', 8849753L),
+        |  (1206355968L, 1206356223L, 'us', 10138801L),
+        |  (CAST(NULL AS BIGINT), 1206356223L, 'us', 10138801L),
+        |  (1206355968L, CAST(NULL AS BIGINT), 'us', 10138801L),
+        |  (CAST(NULL AS BIGINT), CAST(NULL AS BIGINT), 'us', 10138801L)
+        |""".stripMargin)
+  }
+
+  private def setupIntervalViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW intervals_a(lo, hi) AS VALUES
+        |  (-1, 0), (0, 1), (0, 2), (1, 5)
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW intervals_b(lo, hi) AS VALUES
+        |  (-2, -1), (-4, -2), (1, 3), (5, 7)
+        |""".stripMargin)
+  }
+
+  /**
+   * Two-column DOUBLE ranges, so a two-key interval-overlap query here builds 
a boxed
+   * `IntervalIndex` (DoubleType has no bit-preserving long encoding) with 
both bounds
+   * supplied by the streamed side - the `case intervals: IntervalIndex` 
branch of
+   * `BroadcastRangeJoinExec.makeCandidateFn` that `intervals_a`/`intervals_b` 
(INT,
+   * long-specialized) never reaches.
+   */
+  private def setupDoubleIntervalViews(): Unit = {
+    // An unsuffixed literal like `1.0` resolves to DECIMAL, not DOUBLE (only 
`1.0D` or a
+    // cast does), and DECIMAL(2, 1) maps to LongIntervalIndex, defeating the 
point of this
+    // view. The `D` suffix keeps these columns on the boxed IntervalIndex 
path.
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dbl_ranges_a(lo, hi) AS VALUES
+        |  (-1.0D, 0.0D), (0.0D, 1.0D), (0.0D, 2.0D), (1.0D, 5.0D)
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dbl_ranges_b(lo, hi) AS VALUES
+        |  (-2.0D, -1.0D), (-4.0D, -2.0D), (1.0D, 3.0D), (5.0D, 7.0D)
+        |""".stripMargin)
+    assert(sql("SELECT lo, hi FROM 
dbl_ranges_a").schema.fields.forall(_.dataType == DoubleType))
+    assert(sql("SELECT lo, hi FROM 
dbl_ranges_b").schema.fields.forall(_.dataType == DoubleType))
+  }
+
+  private def setupDecimalViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dec_ranges(lo, hi) AS VALUES
+        |  (CAST(0.00 AS DECIMAL(10, 2)), CAST(2.50 AS DECIMAL(10, 2))),
+        |  (CAST(3.00 AS DECIMAL(10, 2)), CAST(5.00 AS DECIMAL(10, 2)))
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dec_points(p) AS VALUES
+        |  (CAST(1.25 AS DECIMAL(10, 2))),
+        |  (CAST(3.00 AS DECIMAL(10, 2))),
+        |  (CAST(9.00 AS DECIMAL(10, 2)))
+        |""".stripMargin)
+  }
+
+  private def setupCollatedStrings(): Unit = {
+    // 'M' and 'm' compare equal under UNICODE_CI and differ as bytes.
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW str_ranges(lo, hi) AS VALUES
+        |  (CAST('a' AS STRING COLLATE UNICODE_CI), CAST('a' AS STRING COLLATE 
UNICODE_CI)),
+        |  (CAST('b' AS STRING COLLATE UNICODE_CI), CAST('b' AS STRING COLLATE 
UNICODE_CI)),
+        |  (CAST('c' AS STRING COLLATE UNICODE_CI), CAST('M' AS STRING COLLATE 
UNICODE_CI)),
+        |  (CAST('m' AS STRING COLLATE UNICODE_CI), CAST('z' AS STRING COLLATE 
UNICODE_CI))
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW str_points(s) AS VALUES
+        |  (CAST('M' AS STRING COLLATE UNICODE_CI)),
+        |  (CAST('m' AS STRING COLLATE UNICODE_CI))
+        |""".stripMargin)
+  }
+
+  /** Points and bounds for single-inequality joins on boxed key families. */
+  private def setupPartialRangeViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dbl_partial_points(d) AS VALUES
+        |  (0.5), (3.0), (6.0),
+        |  (CAST(NULL AS DOUBLE))
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dbl_partial_bounds(lo) AS VALUES
+        |  (1.0), (4.0), (7.5)
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW str_partial_points(s) AS VALUES
+        |  ('a'), ('m'), ('z')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW str_partial_bounds(lo) AS VALUES
+        |  ('b'), ('n')
+        |""".stripMargin)
+  }
+
+  private def setupDateAndTimestampViews(): Unit = {
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dt_ranges(lo, hi) AS VALUES
+        |  (DATE'2024-01-01', DATE'2024-01-31'),
+        |  (DATE'2024-06-01', DATE'2024-06-30')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW dt_points(d) AS VALUES
+        |  (DATE'2024-01-15'),
+        |  (DATE'2024-05-01'),
+        |  (DATE'2024-06-15')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW ts_ranges(lo, hi) AS VALUES
+        |  (TIMESTAMP'2024-01-01 00:00:00', TIMESTAMP'2024-01-01 12:00:00')
+        |""".stripMargin)
+    sql(
+      """
+        |CREATE OR REPLACE TEMP VIEW ts_points(t) AS VALUES
+        |  (TIMESTAMP'2024-01-01 06:00:00'),
+        |  (TIMESTAMP'2024-01-01 18:00:00')
+        |""".stripMargin)
+  }
+
+  test("range join is disabled by default and falls back to nested loop join") 
{
+    setupIpLookupViews()
+    val df = sql(s"SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l ON 
$pointCond")
+    assert(findRangeJoin(df.queryExecution.executedPlan).isEmpty)
+    assert(findBroadcastNestedLoopJoin(df.queryExecution.executedPlan).size == 
1)
+  }
+
+  test("point-in-range matches the nested loop result") {
+    setupIpLookupViews()
+    val query = s"SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l ON 
$pointCond"
+    val expected = sql(query).collect()
+    withRangeJoin() {
+      val df = sql(query)
+      assert(findRangeJoin(df.queryExecution.executedPlan).size == 1)
+      checkAnswer(df, expected)
+    }
+  }
+
+  test("BETWEEN is planned as a point-in-range join") {
+    setupIpLookupViews()
+    val query =
+      "SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l " +
+        "ON s.ip_add_int BETWEEN l.BEGIN_IP2LONG AND l.END_IP2LONG"
+    val expected = sql(query).collect()
+    withRangeJoin(SQLConf.ALWAYS_INLINE_COMMON_EXPR.key -> "false") {
+      val df = sql(query)
+      val planned = findRangeJoin(df.queryExecution.executedPlan)
+      assert(planned.size == 1)
+      // BETWEEN lowers to one point expression. The other side keeps (low, 
high).
+      val keyCounts = Set(planned.head.leftKeys.length, 
planned.head.rightKeys.length)
+      assert(keyCounts == Set(1, 2))
+      checkAnswer(df, expected)
+    }
+  }
+
+  test("hints select range join, nested loop, or cartesian") {
+    setupIpLookupViews()
+    setupIntervalViews()
+    val expected = sql(
+      s"SELECT s.id, l.zip_id FROM signin_ip s JOIN ip_lookup l ON 
$pointCond").collect()
+    withRangeJoin() {
+      val broadcast = sql(
+        "SELECT /*+ BROADCAST(l) */ s.id, l.zip_id FROM signin_ip s JOIN 
ip_lookup l " +
+          s"ON $pointCond")
+      val joins = findRangeJoin(broadcast.queryExecution.executedPlan)
+      assert(joins.size == 1 && joins.head.buildSide == BuildRight)
+      checkAnswer(broadcast, expected)
+
+      val dualHintInner = sql(
+        "SELECT /*+ BROADCAST(s), BROADCAST(l) */ s.id, l.zip_id " +
+          s"FROM signin_ip s JOIN ip_lookup l ON $pointCond")
+      val dualJoins = findRangeJoin(dualHintInner.queryExecution.executedPlan)
+      assert(dualJoins.size == 1 && dualJoins.head.buildSide == BuildRight)
+      checkAnswer(dualHintInner, expected)
+
+      val dualHintLeftOuter = sql(
+        "SELECT /*+ BROADCAST(s), BROADCAST(l) */ s.id, l.zip_id " +
+          s"FROM signin_ip s LEFT JOIN ip_lookup l ON $pointCond")
+      val dualOuterJoins = 
findRangeJoin(dualHintLeftOuter.queryExecution.executedPlan)
+      assert(dualOuterJoins.size == 1 && dualOuterJoins.head.buildSide == 
BuildRight)
+
+      val unsupported = sql(
+        "SELECT /*+ BROADCAST(a) */ a.lo, b.hi FROM intervals_a a LEFT JOIN 
intervals_b b " +
+          "ON a.lo < b.hi")
+      val loops = 
findBroadcastNestedLoopJoin(unsupported.queryExecution.executedPlan)
+      assert(findRangeJoin(unsupported.queryExecution.executedPlan).isEmpty)
+      assert(loops.size == 1 && loops.head.buildSide == BuildLeft)
+
+      val cartesian = sql(
+        "SELECT /*+ SHUFFLE_REPLICATE_NL(s) */ s.id, l.zip_id " +
+          s"FROM signin_ip s JOIN ip_lookup l ON $pointCond")
+      val plan = cartesian.queryExecution.executedPlan
+      assert(findRangeJoin(plan).isEmpty)
+      assert(findCartesianProduct(plan).size == 1)
+    }
+  }
+
+  test("supported join types match nested loop; full outer does not") {
+    setupIpLookupViews()
+    Seq(
+      s"SELECT s.id, l.zip_id FROM signin_ip s LEFT JOIN ip_lookup l ON 
$pointCond",
+      s"SELECT s.id, l.zip_id FROM signin_ip s RIGHT JOIN ip_lookup l ON 
$pointCond",
+      s"SELECT s.id FROM signin_ip s LEFT SEMI JOIN ip_lookup l ON $pointCond",
+      s"SELECT s.id FROM signin_ip s LEFT ANTI JOIN ip_lookup l ON $pointCond"
+    ).foreach { query =>
+      val expected = sql(query).collect()
+      withRangeJoin() {
+        val df = sql(query)
+        assert(findRangeJoin(df.queryExecution.executedPlan).size == 1, query)
+        checkAnswer(df, expected)
+      }
+    }
+    val fullOuter =
+      s"SELECT s.id, l.zip_id FROM signin_ip s FULL OUTER JOIN ip_lookup l ON 
$pointCond"
+    withRangeJoin() {
+      assert(findRangeJoin(sql(fullOuter).queryExecution.executedPlan).isEmpty)
+    }
+  }
+
+  test("partial range left outer broadcasts the right side") {
+    setupIntervalViews()
+    // No b.lo is strictly greater than a.hi (5), so the left row (1, 5) is 
kept with nulls.
+    val query =
+      "SELECT a.lo, a.hi, b.lo, b.hi FROM intervals_a a LEFT JOIN intervals_b 
b " +
+        "ON a.hi < b.lo"
+    val expected = sql(query).collect()
+    assert(expected.exists(_.anyNull))
+    withRangeJoin() {
+      val df = sql(query)
+      val planned = findRangeJoin(df.queryExecution.executedPlan)
+      assert(planned.size == 1)
+      assert(planned.head.joinType == LeftOuter)
+      assert(planned.head.buildSide == BuildRight)
+      assert(planned.head.rangeJoinKind == LessPartialRangeJoin(strict = true))
+      checkAnswer(df, expected)
+    }
+  }
+
+  test("strict inequalities over duplicated build keys match the nested loop 
join") {
+    // 10,000 build rows over 100 dates, 100 per date. Under `<`, a point

Review Comment:
   **Nit (P3):** `dup_dates` and `probe_dates` are `id % 100` and `id` from 
`range()`, so BIGINT, and the candidate-count test in `RangeJoinSuite` (lines 
306-308) builds IntegerType rows while also talking about dates. Behavior is 
unaffected, but it reads like DateType coverage of the strict scans. Could the 
names and comments say keys or values instead?
   
   **Verification:**
   - **Inspection:** The view names, test comments, and fixture descriptions of 
both duplicated-key strict-scan tests describe the integer key type the 
fixtures use and make no date-type coverage claim.



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