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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1200,6 +1200,19 @@ object SQLConf {
       .booleanConf
       .createWithDefault(false)
 
+  val SORT_MERGE_AS_OF_JOIN_CURSOR_SCAN_ENABLED =
+    buildConf("spark.sql.join.sortMergeAsOfJoin.cursorScan.enabled")
+      .internal()
+      .doc("When true, the sort-merge AS-OF join reads the right side once 
with a cursor if " +
+        "the join is backward or forward, has no residual join condition, and 
its as-of " +
+        "condition compares the two sort keys, of the same type. Other joins, 
and joins on " +
+        "STRUCT, ARRAY, TIME or nanosecond timestamp keys, buffer each 
right-side group and " +

Review Comment:
   **Nit (P3):** This list, and the matching one in the `supportsCursorKey` 
comment, now names TIME and nanosecond timestamps, but the allowlist's `case _ 
=> false` also rejects user-defined types. A UDT whose `sqlType` is orderable 
is a valid MATCH_CONDITION operand (`OrderUtils.isOrderable` accepts it), so a 
backward or forward join on the same UDT on both sides meets every condition 
this doc lists and still keeps the buffered scan. Results don't change, but the 
doc reads as if such joins use the cursor scan. Please add user-defined types 
to both lists, or describe the supported key types positively in both places. 
As far as I can tell, UDT is the only orderable key type that is neither 
accepted nor listed: CHAR and VARCHAR extend StringType, and VARIANT, GEOMETRY, 
GEOGRAPHY and MAP aren't orderable.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59957#discussion_r4446870890).
   
   **Verification:**
   - **Inspection:** The cursor-scan config doc and the eligibility comment 
name exactly the orderable key types the allowlist rejects, including 
user-defined types, or exactly the types it accepts.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/AsOfJoinBenchmark.scala:
##########
@@ -75,9 +77,19 @@ object AsOfJoinBenchmark extends SqlBasedBenchmark {
       }
     }
 
-    benchmark.addCase("Sort-merge AS-OF join") { _ =>
+    benchmark.addCase("Sort-merge AS-OF join, buffered scan") { _ =>

Review Comment:
   **Nit (P3):** This renames the case and adds a cursor-scan case, here and in 
the no-equi-key benchmark, and the right `ts` is now BIGINT, but 
`AsOfJoinBenchmark-results.txt` and `AsOfJoinBenchmark-jdk25-results.txt` 
weren't regenerated. They still list only the old 'Sort-merge AS-OF join' rows, 
measured on DOUBLE right keys. So the cursor scan, which is on by default, has 
no committed measurement, and the numbers in the PR description can't be 
checked against the repository. Could you regenerate both files, for example 
with the benchmark GitHub Actions workflow on your fork, and commit them?
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59957#issuecomment-6613192265).
   
   **Verification:**
   - **Inspection:** The committed AS-OF join benchmark results contain exactly 
the cases the benchmark now defines, under their current names, and were 
generated from the current benchmark code on the standard benchmark environment 
for each JDK.



##########
sql/core/src/test/scala/org/apache/spark/sql/AsOfJoinSortMergeSQLSuite.scala:
##########
@@ -1393,4 +1434,407 @@ class AsOfJoinSortMergeSQLSuite extends QueryTest
           |""".stripMargin),
       Row(Seq(Row(1, 2))) :: Nil)
   }
+
+  test("cursor scan: backward match moves forward across trades and keys") {
+    // B has no quotes, C has quotes but no trades, and NULL keys never match. 
A best row kept
+    // from key A would give B a match.
+    checkCursorScan(
+      sql(
+        """
+          |SELECT t.sym, t.ts, q.tag
+          |FROM VALUES ('A', 2), ('A', 6), ('A', 7), ('B', 4), ('D', 5), 
(NULL, 3) AS t(sym, ts)
+          |LEFT ASOF JOIN VALUES ('A', 1, 'a1'), ('A', 3, 'a3'), ('A', 5, 
'a5'), ('A', 8, 'a8'),
+          |  ('C', 1, 'c1'), ('D', 2, 'd2'), ('D', 6, 'd6'), (NULL, 1, 'n1') 
AS q(sym, ts, tag)
+          |  MATCH_CONDITION (t.ts >= q.ts)
+          |  ON t.sym = q.sym
+          |""".stripMargin),
+      Row("A", 2, "a1") :: Row("A", 6, "a5") :: Row("A", 7, "a5") :: Row("B", 
4, null) ::
+        Row("D", 5, "d2") :: Row(null, 3, null) :: Nil)
+  }
+
+  test("cursor scan: forward match is kept while later trades still pass it") {
+    // A3 reuses q4, A6 must drop it, and B1 must not reuse q7 from key A.
+    checkCursorScan(
+      sql(
+        """
+          |SELECT t.sym, t.ts, q.tag
+          |FROM VALUES ('A', 2), ('A', 3), ('A', 6), ('B', 1), ('C', 9) AS 
t(sym, ts)
+          |LEFT ASOF JOIN VALUES ('A', 1, 'q1'), ('A', 4, 'q4'), ('A', 7, 
'q7'),
+          |  ('B', 0, 'b0'), ('B', 2, 'b2'), ('C', 5, 'c5') AS q(sym, ts, tag)
+          |  MATCH_CONDITION (t.ts <= q.ts)
+          |  ON t.sym = q.sym
+          |""".stripMargin),
+      Row("A", 2, "q4") :: Row("A", 3, "q4") :: Row("A", 6, "q7") :: Row("B", 
1, "b2") ::
+        Row("C", 9, null) :: Nil)
+  }
+
+  test("cursor scan: strict and non-strict MATCH_CONDITION ties") {
+    // The second of two equal trades must see the same right rows as the 
first.
+    Seq(">=" -> "q5", ">" -> "q3", "<=" -> "q5", "<" -> "q7").foreach { case 
(op, tag) =>
+      withClue(s"$op: ") {
+        checkCursorScan(
+          sql(
+            s"""
+               |SELECT t.id, q.tag
+               |FROM VALUES (1, 5), (2, 5) AS t(id, ts)
+               |ASOF JOIN VALUES (3, 'q3'), (5, 'q5'), (7, 'q7') AS q(ts, tag)
+               |  MATCH_CONDITION (t.ts $op q.ts)
+               |""".stripMargin),
+          Row(1, tag) :: Row(2, tag) :: Nil)
+      }
+    }
+  }
+
+  test("cursor scan: NULL as-of keys never match and do not move the cursor") {
+    // The NULL trade sorts first. If it moved a forward cursor, trade 2 would 
lose q3.
+    checkCursorScan(
+      sql(
+        """
+          |SELECT t.id, q.tag
+          |FROM VALUES (1, NULL), (2, 2) AS t(id, ts)
+          |LEFT ASOF JOIN VALUES (NULL, 'qn'), (3, 'q3') AS q(ts, tag)
+          |  MATCH_CONDITION (t.ts <= q.ts)
+          |""".stripMargin),
+      Row(1, null) :: Row(2, "q3") :: Nil)
+    // The NULL quote sorts first. If it stopped a backward cursor, trade 2 
would get no match.
+    checkCursorScan(
+      sql(
+        """
+          |SELECT t.id, q.tag
+          |FROM VALUES (1, NULL), (2, 2) AS t(id, ts)
+          |LEFT ASOF JOIN VALUES (NULL, 'qn'), (1, 'q1') AS q(ts, tag)
+          |  MATCH_CONDITION (t.ts >= q.ts)
+          |""".stripMargin),
+      Row(1, null) :: Row(2, "q1") :: Nil)
+  }
+
+  test("cursor scan: scalar MATCH_CONDITION types") {
+    val numbers = Seq("TINYINT", "SMALLINT", "INT", "BIGINT", "FLOAT", 
"DOUBLE", "DECIMAL(38, 18)")
+      .map(t => (t, s"CAST(1 AS $t)", s"CAST(2 AS $t)", s"CAST(3 AS $t)"))
+    def lcase(value: String): String = s"'$value' COLLATE UTF8_LCASE"
+    val others = Seq(
+      ("STRING", "'a'", "'b'", "'c'"),
+      // Binary order is 'B' < 'a' < 'c', but UTF8_LCASE order is 'a' < 'B' < 
'c'.
+      ("STRING COLLATE UTF8_LCASE", lcase("a"), lcase("B"), lcase("c")),
+      ("BINARY", "X'01'", "X'02'", "X'03'"),
+      ("DATE", "DATE'2026-01-01'", "DATE'2026-01-02'", "DATE'2026-01-03'"),
+      ("TIMESTAMP", "TIMESTAMP'2026-01-01 00:00:01'", "TIMESTAMP'2026-01-01 
00:00:02'",
+        "TIMESTAMP'2026-01-01 00:00:03'"),
+      ("TIMESTAMP_NTZ", "TIMESTAMP_NTZ'2026-01-01 00:00:01'",
+        "TIMESTAMP_NTZ'2026-01-01 00:00:02'", "TIMESTAMP_NTZ'2026-01-01 
00:00:03'"),
+      ("INTERVAL YEAR TO MONTH", "INTERVAL '1-1' YEAR TO MONTH", "INTERVAL 
'1-2' YEAR TO MONTH",
+        "INTERVAL '1-3' YEAR TO MONTH"),
+      ("INTERVAL DAY TO SECOND", "INTERVAL '1 00:00:01' DAY TO SECOND",
+        "INTERVAL '1 00:00:02' DAY TO SECOND", "INTERVAL '1 00:00:03' DAY TO 
SECOND"))
+    for {
+      (typeName, before, at, after) <- numbers ++ others
+      (op, tag) <- Seq(">=" -> "before", "<=" -> "after")
+    } {
+      withClue(s"$typeName $op: ") {
+        checkCursorScan(
+          sql(
+            s"""
+               |SELECT q.tag
+               |FROM VALUES ($at) AS t(k)
+               |ASOF JOIN VALUES ($before, 'before'), ($after, 'after') AS 
q(k, tag)
+               |  MATCH_CONDITION (t.k $op q.k)
+               |""".stripMargin),
+          Row(tag) :: Nil)
+      }
+    }
+  }
+
+  test("cursor scan: DOUBLE NaN and negative zero MATCH_CONDITION") {
+    // Spark orders -0.0 equal to 0.0, and NaN above every other value.
+    Seq(">=", "<=").foreach { op =>
+      withClue(s"$op: ") {
+        checkCursorScan(
+          sql(
+            s"""
+               |SELECT t.id, q.tag
+               |FROM VALUES (1, 0.0D), (2, CAST('NaN' AS DOUBLE)) AS t(id, k)
+               |ASOF JOIN VALUES (-0.0D, 'negative zero'), (1.0D, 'one'),
+               |  (CAST('NaN' AS DOUBLE), 'nan') AS q(k, tag)
+               |  MATCH_CONDITION (t.k $op q.k)
+               |""".stripMargin),
+          Row(1, "negative zero") :: Row(2, "nan") :: Nil)
+      }
+    }
+  }
+
+  test("cursor scan: MATCH_CONDITION sides that are expressions of a column") {
+    // Each side sorts on its whole operand, so the cursor walks the operand 
values in order,
+    // even when the operand reverses the column order.
+    Seq(
+      // (match condition, backward rows, forward rows)
+      ("t.k + 10 OP q.k", Seq(Row(1, 8), Row(5, 12)), Seq(Row(1, 12), Row(5, 
20))),
+      ("-t.k OP q.k", Seq(Row(1, -3), Row(5, -6)), Seq(Row(1, 0), Row(5, -3))),
+      ("t.k OP -q.k", Seq(Row(1, 0), Row(5, -3)), Seq(Row(1, -3), Row(5, -6)))
+    ).foreach { case (condition, backwardRows, forwardRows) =>
+      Seq(">=" -> backwardRows, "<=" -> forwardRows).foreach { case (op, 
expected) =>
+        val matchCondition = condition.replace("OP", op)
+        withClue(s"$matchCondition: ") {
+          checkCursorScan(
+            sql(
+              s"""
+                 |SELECT t.k, q.k
+                 |FROM VALUES (1), (5) AS t(k)
+                 |LEFT ASOF JOIN VALUES (-6), (-3), (0), (3), (6), (8), (12), 
(20) AS q(k)
+                 |  MATCH_CONDITION ($matchCondition)
+                 |""".stripMargin),
+            expected)
+        }
+      }
+    }
+  }
+
+  test("cursor scan: STRING against INT MATCH_CONDITION casts the STRING side 
to the " +
+      "comparison type") {
+    Seq(true, false).foreach { ansi =>
+      withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi.toString) {
+        Seq(">=" -> 3, "<=" -> 7).foreach { case (op, expected) =>
+          withClue(s"ansi=$ansi, $op: ") {
+            checkCursorScan(
+              sql(
+                s"""
+                   |SELECT q.n
+                   |FROM VALUES ('5') AS t(s)
+                   |ASOF JOIN VALUES (3), (7) AS q(n)
+                   |  MATCH_CONDITION (t.s $op q.n)
+                   |""".stripMargin),
+              Row(expected) :: Nil)
+          }
+        }
+      }
+    }
+  }
+
+  test("cursor scan: a left row whose key has no right rows never evaluates 
its as-of side") {
+    // Under ANSI, 'bad' cannot be cast to a number and Long.MaxValue * 1000 
overflows. The
+    // buffered scan evaluates the as-of condition only on right rows with the 
same key, so key
+    // B never fails the query. The cursor scan must not evaluate the left 
side of key B either.
+    withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
+      Seq(
+        ("('A', '1'), ('B', 'bad'), ('C', '3')",
+          "('A', 0, 'a0'), ('A', 2, 'a2'), ('C', 2, 'c2'), ('C', 4, 'c4')", 
"t.v"),
+        ("('A', 1L), ('B', 9223372036854775807L), ('C', 3L)",
+          "('A', 0L, 'a0'), ('A', 2000L, 'a2'), ('C', 2000L, 'c2'), ('C', 
4000L, 'c4')",
+          "t.v * 1000")
+      ).foreach { case (leftRows, rightRows, leftSide) =>
+        for {
+          (op, tagA, tagC) <- Seq((">=", "a0", "c2"), ("<=", "a2", "c4"))
+          (joinType, unmatched) <- Seq("LEFT" -> Seq(Row("B", null)), "INNER" 
-> Nil)
+        } {
+          withClue(s"$leftSide $op q.v, $joinType: ") {
+            checkCursorScan(
+              sql(
+                s"""
+                   |SELECT t.k, q.tag
+                   |FROM VALUES $leftRows AS t(k, v)
+                   |$joinType ASOF JOIN VALUES $rightRows AS q(k, v, tag)
+                   |  MATCH_CONDITION ($leftSide $op q.v) ON t.k = q.k
+                   |""".stripMargin),
+              Seq(Row("A", tagA), Row("C", tagC)) ++ unmatched)
+          }
+        }
+      }
+    }
+  }
+
+  test("cursor scan: a failing as-of side fails the query when its key has 
right rows") {
+    withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
+      Seq(
+        ("('A', 'bad')", "('A', 1)", "t.v", "CAST_INVALID_INPUT"),

Review Comment:
   **Nit (P3):** This test only uses right rows with non-NULL as-of keys, and 
the no-right-rows test above covers keys with no right rows at all. Neither 
covers a key whose only right rows have a NULL as-of key, which 
`startCursorGroup` deliberately counts as a right row so that the cursor 
evaluates the left side just as the buffered scan does. If that rule regressed, 
for example by checking the right as-of key before counting the row, then under 
ANSI `('A', 'bad')` against `('A', CAST(NULL AS INT))` would come back 
null-padded from the cursor scan (or be dropped for INNER) while the buffered 
scan throws `CAST_INVALID_INPUT`, and no test would fail. Could you add cases 
here whose right rows for the key have a typed NULL as-of key, such as `('A', 
CAST(NULL AS INT))` and `('A', CAST(NULL AS BIGINT))`, for both operators?
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59957#discussion_r4446870878).
   
   **Verification:**
   - **Regression:** Under ANSI, with the cursor scan off and then on, backward 
and forward joins of a left row whose as-of side fails at runtime, against 
right rows with the same key whose as-of keys are all NULL, both fail with the 
same error condition, for an invalid cast and for an overflow.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala:
##########
@@ -434,6 +464,63 @@ private[joins] class SortMergeAsOfJoinScanner(
     null
   }
 
+  /**
+   * Finds the best right row for `leftRow` by moving a cursor over the right 
input, or null.
+   *
+   * Left rows of a group come in ascending as-of key order. So a right row 
that passes for one
+   * left row also passes for the next (backward), and a row that fails stays 
failed (forward).
+   * The cursor never steps back: backward stops at the first failing row and 
keeps it for the
+   * next left row, and forward drops each failing row, since no later left 
row can match it.
+   */
+  private def findWithCursor(leftRow: InternalRow, leftKey: UnsafeRow): 
InternalRow = {
+    equiKeyOrdering match {
+      case Some(ordering)
+          if cursorGroupKey == null || ordering.compare(leftKey, 
cursorGroupKey) != 0 =>
+        cursorGroupKey = leftKey
+        cursorBest = null
+      case _ =>
+    }
+    val (leftAsOfKey, rightAsOfKey) = cursorKeys
+    // A NULL left as-of key matches nothing. It must not move the cursor, or 
forward would drop
+    // right rows that later left rows need.
+    if (leftAsOfKey.eval(leftRow) == null) return null

Review Comment:
   Thanks, this fixes it. The cursor now returns before it evaluates the left 
as-of side when the key has no right rows, and the new ANSI tests compare both 
scans for an invalid cast and an overflow. This review raises one more test 
case: a key whose right rows all have NULL as-of keys.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4446870878","thread_id":"inline:4446870878","verdict_sha256":"15d852c5f334851eeedc66e635c5985339075ac76c136f07b93dc4aa44c7de6e"}
 -->



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/SortMergeAsOfJoinExec.scala:
##########
@@ -518,3 +605,54 @@ private[joins] class SortMergeAsOfJoinScanner(
     if (lower.isNaN || upper.isNaN) 2 else if (lower.isInfinite || 
upper.isInfinite) 1 else 0
   }
 }
+
+private[execution] object SortMergeAsOfJoinScanner {
+
+  /**
+   * Whether the scan can read the right side once with a cursor, instead of 
buffering each
+   * right-side group and scanning it again for every left row.
+   *
+   * This needs a backward or forward join with no residual condition, whose 
as-of condition
+   * only compares the two sort keys. Both sides are sorted on exactly those 
keys, so the right
+   * rows that pass the condition move forward as the left key grows. Anything 
else, such as a
+   * tolerance, a tuple, or an operand cast that the sort key does not have, 
keeps the buffer.
+   */
+  def supportsCursorScan(
+      direction: AsOfJoinDirection,
+      asOfCondition: Expression,
+      residualCondition: Option[Expression],
+      leftSortExprs: Seq[Expression],
+      rightSortExprs: Seq[Expression]): Boolean = {
+    // The operands as (lesser, greater).
+    val ordered = asOfCondition match {
+      case LessThan(lesser, greater) => Some((lesser, greater))
+      case LessThanOrEqual(lesser, greater) => Some((lesser, greater))
+      case GreaterThan(greater, lesser) => Some((lesser, greater))
+      case GreaterThanOrEqual(greater, lesser) => Some((lesser, greater))
+      case _ => None
+    }
+    // The operands as (left, right): backward needs left >= right, forward 
left <= right.
+    val operands = (direction, ordered) match {
+      case (Backward, Some((lesser, greater))) => Some((greater, lesser))
+      case (Forward, Some((lesser, greater))) => Some((lesser, greater))
+      case _ => None
+    }
+    (leftSortExprs, rightSortExprs, operands) match {
+      case (Seq(leftKey), Seq(rightKey), Some((leftOperand, rightOperand))) =>
+        residualCondition.isEmpty &&
+          leftOperand.semanticEquals(leftKey) &&
+          rightOperand.semanticEquals(rightKey) &&
+          leftKey.dataType == rightKey.dataType &&
+          supportsCursorKey(leftKey.dataType)
+      case _ => false
+    }
+  }
+
+  /** Scalar key types only for now. STRUCT and ARRAY keys keep the buffered 
scan. */

Review Comment:
   Thanks, the comment, the config doc and the test name now name TIME and 
nanosecond timestamps. This review raises one remaining gap in those lists: 
user-defined-type keys.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4446870890","thread_id":"inline:4446870890","verdict_sha256":"15d852c5f334851eeedc66e635c5985339075ac76c136f07b93dc4aa44c7de6e"}
 -->



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