This is an automated email from the ASF dual-hosted git repository.

MaxGekk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new ca4e07a441b0 [SPARK-57718][SQL][TEST] Add JOIN correctness tests for 
nanosecond-precision timestamp types
ca4e07a441b0 is described below

commit ca4e07a441b07e52149f68228e37903411e08082
Author: Stevo Mitric <[email protected]>
AuthorDate: Fri Jun 26 19:33:48 2026 +0200

    [SPARK-57718][SQL][TEST] Add JOIN correctness tests for 
nanosecond-precision timestamp types
    
    ### What changes were proposed in this pull request?
    
    Tests-only coverage for equi-JOINs over the nanosecond-precision timestamp 
types `TIMESTAMP_NTZ(p)` / `TIMESTAMP_LTZ(p)` (`p` in `[7, 9]`). One new suite, 
`TimestampNanosJoinSuiteBase`, with `AnsiOn` / `AnsiOff` subclasses.
    
    The headline assertion is sub-microsecond **join-key** correctness: every 
key in the test relations shares the same `epochMicros` and differs only in 
`nanosWithinMicro`, so a correct join must be driven by the full nanos value —
    - a fully-equal key (incl. the sub-microsecond remainder) must join,
    - an `epochMicros`-equal-but-`nanosWithinMicro`-distinct key must **not** 
join,
    - NULL keys must not match.
    
    ### Why are the changes needed?
    
    Joins over the nanosecond types already work (they ride on the hashing / 
ordering / `UnsafeRow` primitives), but there was no join coverage — Spark's 
join suites are organized by join type/strategy, not by column type. This locks 
the behaviour in, mirroring the MIN/MAX follow-up which was likewise tests-only.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No, tests only.
    
    ### How was this patch tested?
    
    New suites in this PR.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56812 from stevomitric/stevomitric/timestamp-nanos-join-tests.
    
    Authored-by: Stevo Mitric <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
---
 .../spark/sql/TimestampNanosJoinSuiteBase.scala    | 239 +++++++++++++++++++++
 1 file changed, 239 insertions(+)

diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/TimestampNanosJoinSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/TimestampNanosJoinSuiteBase.scala
new file mode 100644
index 000000000000..b6e7b9aef28c
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/TimestampNanosJoinSuiteBase.scala
@@ -0,0 +1,239 @@
+/*
+ * 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
+
+import java.time.{Instant, LocalDateTime}
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.execution.SparkPlan
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, 
ShuffledHashJoinExec, SortMergeJoinExec}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+
+/**
+ * End-to-end equi-JOIN correctness tests over the nanosecond-precision 
timestamp types
+ * `TIMESTAMP_NTZ(p)` / `TIMESTAMP_LTZ(p)` (`p` in `[7, 9]`). Joins over these 
types execute today
+ * with no production change -- they ride on the nanos hash+equals (hash 
joins) and the nanos
+ * ordering+equals (sort-merge join) the types already implement. There is 
currently no join
+ * coverage organized by column type, so this per-type suite adds it.
+ *
+ * Headline risk: SUB-MICROSECOND join-key correctness. The carrier is
+ * `TimestampNanosVal = (epochMicros: Long, nanosWithinMicro: Short in [0, 
999])`. Every join key
+ * in these tables shares the SAME epochMicros (1577836800000000, = 
2020-01-01T00:00:00Z) and
+ * differs ONLY in nanosWithinMicro. So the micro-level path alone cannot tell 
the keys apart -- a
+ * correct join MUST be driven by the full nanos value:
+ *   - keys equal in epochMicros but DIFFERENT in nanosWithinMicro must NOT 
join,
+ *   - keys fully equal (incl. the sub-microsecond remainder) MUST join,
+ *   - NULL keys must NOT match (NULL never equals NULL in an equi-join).
+ * If the sub-microsecond remainder were ignored, every non-null left row 
would spuriously match
+ * every non-null right row on the shared micro and these tests would fail 
loudly.
+ *
+ * Precision-safety: all sub-microsecond remainders are multiples of 100ns 
(200/300/500/700/900),
+ * which are exact at every p in [7, 9] (createDataFrame floors 
nanosWithinMicro to (n/100)*100 at
+ * p=7 and (n/10)*10 at p=8). So the SAME inputs and the SAME expected rows 
are valid verbatim at
+ * all three precisions, and the five distinct remainders never collide even 
at the coarsest p=7.
+ *
+ * Each test forces a specific physical join strategy (broadcast-hash / 
sort-merge / shuffled-hash)
+ * with AQE disabled, asserts that exact exec node fired, and runs under 
whole-stage codegen on and
+ * off -- so the same sub-microsecond equal/distinct relationship is proven on 
the nanos hash path
+ * AND the nanos ordering path, in both codegen modes, for NTZ and LTZ.
+ *
+ * The nanosecond timestamp types are gated behind a preview flag enabled by 
default under tests
+ * (`Utils.isTesting`), so it is not set here. The session time zone is fixed 
so the
+ * `TIMESTAMP_LTZ` (`Instant`) values render deterministically. The two 
subclasses run every test
+ * with ANSI mode on and off.
+ */
+abstract class TimestampNanosJoinSuiteBase extends SharedSparkSession with 
AdaptiveSparkPlanHelper {
+
+  override def sparkConf: SparkConf = super.sparkConf
+    .set(SQLConf.SESSION_LOCAL_TIMEZONE.key, "America/Los_Angeles")
+
+  // Whole-stage codegen on (CODEGEN_ONLY) vs off (NO_CODEGEN). The join exec 
class is identical in
+  // both modes; only the WholeStageCodegenExec wrapper differs, and the 
recursive class-based
+  // assertion below descends through that wrapper, so this toggle is 
orthogonal to the assertion.
+  // Mirrors TimestampNanosFunctionsSuiteBase.
+  protected val codegenModes: Seq[Seq[(String, String)]] = Seq(
+    Seq(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+      SQLConf.CODEGEN_FACTORY_MODE.key -> "CODEGEN_ONLY"),
+    Seq(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "false",
+      SQLConf.CODEGEN_FACTORY_MODE.key -> "NO_CODEGEN"))
+
+  // The three equi-join strategies, each as (label, exec class to assert, 
SQLConf overrides that
+  // force it). AQE is disabled in every entry so the executed plan is final 
and the node is
+  // deterministically findable.
+  //   - BroadcastHashJoin: huge broadcast threshold so the (tiny) build side 
broadcasts.
+  //   - SortMergeJoin: broadcast disabled, prefer-SMJ on, no SHJ force flag.
+  //   - ShuffledHashJoin: broadcast disabled, prefer-SMJ off, plus the 
testing-only force flag --
+  //     the only deterministic SHJ for two tiny EQUAL-sized inputs (bypasses 
the muchSmaller
+  //     heuristic). The flag is a hard-coded string key (no SQLConf constant) 
gated on
+  //     Utils.isTesting, which is true in the test JVM.
+  protected val joinStrategies: Seq[(String, Class[_ <: SparkPlan], 
Seq[(String, String)])] = Seq(
+    ("BroadcastHashJoin", classOf[BroadcastHashJoinExec], Seq(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> Long.MaxValue.toString)),
+    ("SortMergeJoin", classOf[SortMergeJoinExec], Seq(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.PREFER_SORTMERGEJOIN.key -> "true")),
+    ("ShuffledHashJoin", classOf[ShuffledHashJoinExec], Seq(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.PREFER_SORTMERGEJOIN.key -> "false",
+      "spark.sql.join.forceApplyShuffledHashJoin" -> "true")))
+
+  /**
+   * Asserts that exactly one join exec of `execClass` is present in the 
executed plan of `df`.
+   * `collect` (mixed in from AdaptiveSparkPlanHelper) recurses through 
AdaptiveSparkPlanExec,
+   * QueryStageExec and WholeStageCodegenExec, so the inner join node is 
matched in both codegen
+   * modes and under either AQE setting. Forces materialization of the plan 
first.
+   */
+  protected def assertJoinUsed(df: DataFrame, execClass: Class[_ <: 
SparkPlan]): Unit = {
+    val plan = df.queryExecution.executedPlan
+    val matched = collect(plan) { case p if execClass.isInstance(p) => p }
+    assert(matched.size == 1,
+      s"Expected exactly one ${execClass.getSimpleName}, found 
${matched.size}.\n" +
+        s"Executed plan:\n$plan")
+  }
+
+  // ---- relation builders: key column "k" of the given nanos type + an Int 
id column ----
+
+  private def ntzLeft(p: Int): DataFrame =
+    spark.createDataFrame(
+      spark.sparkContext.parallelize(Seq(
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000500"), 1),
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000200"), 2),
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000900"), 3),
+        Row(null, 4))),
+      new StructType().add("k", TimestampNTZNanosType(p)).add("lid", 
IntegerType))
+
+  private def ntzRight(p: Int): DataFrame =
+    spark.createDataFrame(
+      spark.sparkContext.parallelize(Seq(
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000500"), 10),
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000700"), 20),
+        Row(LocalDateTime.parse("2020-01-01T00:00:00.000000300"), 30),
+        Row(null, 40))),
+      new StructType().add("k", TimestampNTZNanosType(p)).add("rid", 
IntegerType))
+
+  private def ltzLeft(p: Int): DataFrame =
+    spark.createDataFrame(
+      spark.sparkContext.parallelize(Seq(
+        Row(Instant.parse("2020-01-01T00:00:00.000000500Z"), 1),
+        Row(Instant.parse("2020-01-01T00:00:00.000000200Z"), 2),
+        Row(Instant.parse("2020-01-01T00:00:00.000000900Z"), 3),
+        Row(null, 4))),
+      new StructType().add("k", TimestampLTZNanosType(p)).add("lid", 
IntegerType))
+
+  private def ltzRight(p: Int): DataFrame =
+    spark.createDataFrame(
+      spark.sparkContext.parallelize(Seq(
+        Row(Instant.parse("2020-01-01T00:00:00.000000500Z"), 10),
+        Row(Instant.parse("2020-01-01T00:00:00.000000700Z"), 20),
+        Row(Instant.parse("2020-01-01T00:00:00.000000300Z"), 30),
+        Row(null, 40))),
+      new StructType().add("k", TimestampLTZNanosType(p)).add("rid", 
IntegerType))
+
+  // Expected join outputs (order-insensitive). Selected as (lid, rid) so each 
row is identifiable.
+  // INNER: only the fully-equal sub-microsecond pair (500ns == 500ns).
+  private val expectedInner: Seq[Row] = Seq(Row(1, 10))
+  // LEFT OUTER: all left rows. lid=1 matched; lid=2 (sub-micro mismatch 200 
vs 700), lid=3
+  // (left-only 900) and lid=4 (NULL key) each get a NULL rid. lid=2's NULL 
rid proves the
+  // sub-microsecond non-match is real (not a same-micro false hit); lid=4's 
proves NULL != NULL.
+  private val expectedLeftOuter: Seq[Row] =
+    Seq(Row(1, 10), Row(2, null), Row(3, null), Row(4, null))
+
+  // 
==========================================================================================
+  // NTZ: inner + left-outer over a sub-microsecond key, every strategy x 
codegen mode x p.
+  // 
==========================================================================================
+  for {
+    (stratName, execClass, stratConf) <- joinStrategies
+    cgConf <- codegenModes
+  } {
+    val cgName = if (cgConf.exists(_ == 
(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true"))) {
+      "codegen on"
+    } else {
+      "codegen off"
+    }
+
+    test(s"NTZ nanos join distinguishes the sub-microsecond remainder - " +
+      s"$stratName - $cgName") {
+      withSQLConf((stratConf ++ cgConf): _*) {
+        Seq(7, 8, 9).foreach { p =>
+          val left = ntzLeft(p)
+          val right = ntzRight(p)
+
+          val inner = left.join(right, left("k") === right("k"), "inner")
+            .select(left("lid"), right("rid"))
+          assertJoinUsed(inner, execClass)
+          checkAnswer(inner, expectedInner)
+
+          val leftOuter = left.join(right, left("k") === right("k"), 
"left_outer")
+            .select(left("lid"), right("rid"))
+          assertJoinUsed(leftOuter, execClass)
+          checkAnswer(leftOuter, expectedLeftOuter)
+        }
+      }
+    }
+  }
+
+  // 
==========================================================================================
+  // LTZ: inner + left-outer over a sub-microsecond key, every strategy x 
codegen mode x p.
+  // 
==========================================================================================
+  for {
+    (stratName, execClass, stratConf) <- joinStrategies
+    cgConf <- codegenModes
+  } {
+    val cgName = if (cgConf.exists(_ == 
(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true"))) {
+      "codegen on"
+    } else {
+      "codegen off"
+    }
+
+    test(s"LTZ nanos join distinguishes the sub-microsecond remainder - " +
+      s"$stratName - $cgName") {
+      withSQLConf((stratConf ++ cgConf): _*) {
+        Seq(7, 8, 9).foreach { p =>
+          val left = ltzLeft(p)
+          val right = ltzRight(p)
+
+          val inner = left.join(right, left("k") === right("k"), "inner")
+            .select(left("lid"), right("rid"))
+          assertJoinUsed(inner, execClass)
+          checkAnswer(inner, expectedInner)
+
+          val leftOuter = left.join(right, left("k") === right("k"), 
"left_outer")
+            .select(left("lid"), right("rid"))
+          assertJoinUsed(leftOuter, execClass)
+          checkAnswer(leftOuter, expectedLeftOuter)
+        }
+      }
+    }
+  }
+}
+
+// Runs the nanosecond timestamp join tests with ANSI mode enabled explicitly.
+class TimestampNanosJoinAnsiOnSuite extends TimestampNanosJoinSuiteBase {
+  override def sparkConf: SparkConf = 
super.sparkConf.set(SQLConf.ANSI_ENABLED.key, "true")
+}
+
+// Runs the nanosecond timestamp join tests with ANSI mode disabled explicitly.
+class TimestampNanosJoinAnsiOffSuite extends TimestampNanosJoinSuiteBase {
+  override def sparkConf: SparkConf = 
super.sparkConf.set(SQLConf.ANSI_ENABLED.key, "false")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to