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 90dbc533da00 [SPARK-57735][SQL] Support nanosecond-precision timestamp 
types in the in-memory columnar cache
90dbc533da00 is described below

commit 90dbc533da0020392ebb4822ddd447c068515e74
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Mon Jun 29 12:27:43 2026 +0200

    [SPARK-57735][SQL] Support nanosecond-precision timestamp types in the 
in-memory columnar cache
    
    ### What changes were proposed in this pull request?
    
    The default in-memory columnar cache serializer 
(`DefaultCachedBatchSerializer`) did not support `TimestampNTZNanosType` / 
`TimestampLTZNanosType`. Caching a DataFrame with such a column failed at 
materialization with `not support type: TimestampNTZNanosType(9)`, because none 
of the cache's type-dispatch sites had a case for them.
    
    This adds full support, following the fixed-width multi-field pattern 
already used by `CalendarInterval`. The physical value `TimestampNanosVal` is a 
fixed 16-byte payload (an 8-byte `epochMicros` plus an 8-byte word holding 
`nanosWithinMicro`), so it maps cleanly onto that pattern:
    
    - **`ColumnType`**: a `TIMESTAMP_NANOS` column type (with 
`TIMESTAMP_NTZ_NANOS` / `TIMESTAMP_LTZ_NANOS` singletons) whose 
`append`/`extract` read and write the 16-byte payload, with a 
`MutableUnsafeRow` direct-copy fast path.
    - **`ColumnBuilder`, `ColumnAccessor`**: builder and accessor classes plus 
dispatch cases.
    - **`ColumnStats`**: a `TimestampNanosColumnStats` collector (fixed size, 
no min/max bounds).
    - **`GenerateColumnAccessor`**: the codegen accessor-class selection and 
initialization branch.
    
    `TIMESTAMP_NTZ` and `TIMESTAMP_LTZ` nanos types share the same storage and 
differ only by physical type and row getter/setter, so the encode/decode logic 
is shared between them.
    
    ### Why are the changes needed?
    
    Nanosecond-precision timestamp types are otherwise unsupported by the 
cache, so `df.cache()` on a column of these types throws. With this change such 
DataFrames cache and read back correctly, consistent with the microsecond 
`TIMESTAMP_NTZ` / `TIMESTAMP` types which the cache already supports.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Previously, caching a DataFrame containing a `TIMESTAMP_NTZ(p)` / 
`TIMESTAMP_LTZ(p)` column with `p` in the nanosecond range threw `not support 
type`. Now it caches and reads back the values, including sub-microsecond 
precision.
    
    ### How was this patch tested?
    
    - `ColumnTypeSuite`: append/extract round-trip for `TIMESTAMP_NTZ_NANOS` 
and `TIMESTAMP_LTZ_NANOS` (random values), plus `defaultSize` checks.
    - `InMemoryColumnarQuerySuite`: an end-to-end cache roundtrip for both 
nanos types, with the vectorized reader both on and off, covering 
sub-microsecond precision and null values.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    Closes #56842 from viirya/nanos-timestamp-default-cache.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
---
 .../sql/execution/columnar/ColumnAccessor.scala    | 12 +++-
 .../sql/execution/columnar/ColumnBuilder.scala     |  8 +++
 .../spark/sql/execution/columnar/ColumnStats.scala | 26 +++++++-
 .../spark/sql/execution/columnar/ColumnType.scala  | 71 +++++++++++++++++++++-
 .../columnar/GenerateColumnAccessor.scala          |  5 +-
 .../sql/execution/columnar/ColumnStatsSuite.scala  | 37 +++++++++++
 .../sql/execution/columnar/ColumnTypeSuite.scala   |  4 +-
 .../execution/columnar/ColumnarDataTypeUtils.scala |  2 +
 .../sql/execution/columnar/ColumnarTestUtils.scala |  6 +-
 .../columnar/InMemoryColumnarQuerySuite.scala      | 30 +++++++++
 .../columnar/PartitionBatchPruningSuite.scala      | 58 ++++++++++++++++++
 11 files changed, 253 insertions(+), 6 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnAccessor.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnAccessor.scala
index 6fb1f5263b51..2446e107545c 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnAccessor.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnAccessor.scala
@@ -28,7 +28,7 @@ import org.apache.spark.sql.errors.QueryExecutionErrors
 import 
org.apache.spark.sql.execution.columnar.compression.CompressibleColumnAccessor
 import org.apache.spark.sql.execution.vectorized.WritableColumnVector
 import org.apache.spark.sql.types._
-import org.apache.spark.unsafe.types.{CalendarInterval, VariantVal}
+import org.apache.spark.unsafe.types.{CalendarInterval, TimestampNanosVal, 
VariantVal}
 
 /**
  * An `Iterator` like trait used to extract values from columnar byte buffer. 
When a value is
@@ -115,6 +115,14 @@ private[columnar] class VariantColumnAccessor(buffer: 
ByteBuffer)
   extends BasicColumnAccessor[VariantVal](buffer, VARIANT)
   with NullableColumnAccessor
 
+private[columnar] class TimestampNTZNanosColumnAccessor(buffer: ByteBuffer)
+  extends BasicColumnAccessor[TimestampNanosVal](buffer, TIMESTAMP_NTZ_NANOS)
+  with NullableColumnAccessor
+
+private[columnar] class TimestampLTZNanosColumnAccessor(buffer: ByteBuffer)
+  extends BasicColumnAccessor[TimestampNanosVal](buffer, TIMESTAMP_LTZ_NANOS)
+  with NullableColumnAccessor
+
 private[columnar] class CompactDecimalColumnAccessor(buffer: ByteBuffer, 
dataType: DecimalType)
   extends NativeColumnAccessor(buffer, COMPACT_DECIMAL(dataType))
 
@@ -153,6 +161,8 @@ private[sql] object ColumnAccessor {
       case DoubleType => new DoubleColumnAccessor(buf)
       case s: StringType => new StringColumnAccessor(buf, s)
       case BinaryType => new BinaryColumnAccessor(buf)
+      case _: TimestampNTZNanosType => new TimestampNTZNanosColumnAccessor(buf)
+      case _: TimestampLTZNanosType => new TimestampLTZNanosColumnAccessor(buf)
       case dt: DecimalType if dt.precision <= Decimal.MAX_LONG_DIGITS =>
         new CompactDecimalColumnAccessor(buf, dt)
       case dt: DecimalType => new DecimalColumnAccessor(buf, dt)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnBuilder.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnBuilder.scala
index a63569b19a01..cfd0ea005e8c 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnBuilder.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnBuilder.scala
@@ -134,6 +134,12 @@ class IntervalColumnBuilder extends 
ComplexColumnBuilder(new IntervalColumnStats
 private[columnar]
 class VariantColumnBuilder extends ComplexColumnBuilder(new 
VariantColumnStats, VARIANT)
 
+private[columnar] class TimestampNTZNanosColumnBuilder
+  extends ComplexColumnBuilder(new TimestampNanosColumnStats, 
TIMESTAMP_NTZ_NANOS)
+
+private[columnar] class TimestampLTZNanosColumnBuilder
+  extends ComplexColumnBuilder(new TimestampNanosColumnStats, 
TIMESTAMP_LTZ_NANOS)
+
 private[columnar] class CompactDecimalColumnBuilder(dataType: DecimalType)
   extends NativeColumnBuilder(new DecimalColumnStats(dataType), 
COMPACT_DECIMAL(dataType))
 
@@ -193,6 +199,8 @@ private[columnar] object ColumnBuilder {
       case BinaryType => new BinaryColumnBuilder
       case CalendarIntervalType => new IntervalColumnBuilder
       case VariantType => new VariantColumnBuilder
+      case _: TimestampNTZNanosType => new TimestampNTZNanosColumnBuilder
+      case _: TimestampLTZNanosType => new TimestampLTZNanosColumnBuilder
       case dt: DecimalType if dt.precision <= Decimal.MAX_LONG_DIGITS =>
         new CompactDecimalColumnBuilder(dt)
       case dt: DecimalType => new DecimalColumnBuilder(dt)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnStats.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnStats.scala
index 4e4b3667fa24..c09c94ff4201 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnStats.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnStats.scala
@@ -20,7 +20,7 @@ package org.apache.spark.sql.execution.columnar
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeReference}
 import org.apache.spark.sql.types._
-import org.apache.spark.unsafe.types.UTF8String
+import org.apache.spark.unsafe.types.{TimestampNanosVal, UTF8String}
 
 class ColumnStatisticsSchema(a: Attribute) extends Serializable {
   val upperBound = AttributeReference(a.name + ".upperBound", a.dataType, 
nullable = true)()
@@ -326,6 +326,30 @@ private[columnar] final class IntervalColumnStats extends 
ColumnStats {
     Array[Any](null, null, nullCount, count, sizeInBytes)
 }
 
+private[columnar] final class TimestampNanosColumnStats extends ColumnStats {
+  protected var upper: TimestampNanosVal = null
+  protected var lower: TimestampNanosVal = null
+
+  override def gatherStats(row: InternalRow, ordinal: Int): Unit = {
+    if (!row.isNullAt(ordinal)) {
+      // TimestampNanosVal has a total order matching calendar order, so 
collect min/max bounds
+      // (like DecimalColumnStats, not IntervalColumnStats) to enable 
partition pruning, matching
+      // the micro-precision timestamp path (LongColumnStats). NTZ and LTZ 
share the same physical
+      // payload, so a single getter reads the value for both.
+      val value = row.getTimestampNTZNanos(ordinal)
+      if (upper == null || value.compareTo(upper) > 0) upper = value
+      if (lower == null || value.compareTo(lower) < 0) lower = value
+      sizeInBytes += TimestampNanosVal.SIZE_IN_BYTES
+      count += 1
+    } else {
+      gatherNullStats()
+    }
+  }
+
+  override def collectedStatistics: Array[Any] =
+    Array[Any](lower, upper, nullCount, count, sizeInBytes)
+}
+
 private[columnar] final class DecimalColumnStats(precision: Int, scale: Int) 
extends ColumnStats {
   def this(dt: DecimalType) = this(dt.precision, dt.scale)
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnType.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnType.scala
index df250e529e2c..cf4309b52142 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnType.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ColumnType.scala
@@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.types._
 import org.apache.spark.sql.errors.ExecutionErrors
 import org.apache.spark.sql.types._
 import org.apache.spark.unsafe.Platform
-import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String, VariantVal}
+import org.apache.spark.unsafe.types.{CalendarInterval, TimestampNanosVal, 
UTF8String, VariantVal}
 
 
 /**
@@ -815,6 +815,73 @@ private[columnar] object CALENDAR_INTERVAL extends 
ColumnType[CalendarInterval]
   }
 }
 
+/**
+ * Used to append/extract [[TimestampNanosVal]] into/from the underlying 
[[ByteBuffer]] of a
+ * column. The on-buffer layout mirrors the UnsafeRow variable-length payload: 
an 8-byte
+ * epochMicros followed by an 8-byte word holding nanosWithinMicro 
(zero-extended), 16 bytes total
+ * (see TimestampNanosRowValues). NTZ and LTZ share this storage and differ 
only by physical type,
+ * so the two singletons below pass their own physicalType and row 
getter/setter.
+ */
+private[columnar] abstract class TIMESTAMP_NANOS(physicalType: 
PhysicalDataType)
+  extends ColumnType[TimestampNanosVal] {
+
+  override def dataType: PhysicalDataType = physicalType
+
+  override def defaultSize: Int = TimestampNanosVal.SIZE_IN_BYTES
+
+  protected def getNanos(row: InternalRow, ordinal: Int): TimestampNanosVal
+  protected def setNanos(row: InternalRow, ordinal: Int, value: 
TimestampNanosVal): Unit
+
+  override def getField(row: InternalRow, ordinal: Int): TimestampNanosVal = 
getNanos(row, ordinal)
+
+  override def setField(row: InternalRow, ordinal: Int, value: 
TimestampNanosVal): Unit =
+    setNanos(row, ordinal, value)
+
+  override def extract(buffer: ByteBuffer): TimestampNanosVal = {
+    val epochMicros = ByteBufferHelper.getLong(buffer)
+    // The nanos field is stored in a full 8-byte word (matching the UnsafeRow 
payload), so read a
+    // long and narrow it; the writer guarantees the value is in [0, 999].
+    val nanosWithinMicro = ByteBufferHelper.getLong(buffer).toShort
+    TimestampNanosVal.fromTrustedRowBytes(epochMicros, nanosWithinMicro)
+  }
+
+  // Copy the fixed 16-byte payload straight into the UnsafeRow, like 
CALENDAR_INTERVAL.
+  override def extract(buffer: ByteBuffer, row: InternalRow, ordinal: Int): 
Unit = {
+    row match {
+      case mutable: MutableUnsafeRow =>
+        val cursor = buffer.position()
+        buffer.position(cursor + defaultSize)
+        mutable.writer.write(ordinal, buffer.array(),
+          buffer.arrayOffset() + cursor, defaultSize)
+      case _ =>
+        setField(row, ordinal, extract(buffer))
+    }
+  }
+
+  override def append(v: TimestampNanosVal, buffer: ByteBuffer): Unit = {
+    ByteBufferHelper.putLong(buffer, v.epochMicros)
+    ByteBufferHelper.putLong(buffer, v.nanosWithinMicro.toLong)
+  }
+}
+
+private[columnar] object TIMESTAMP_NTZ_NANOS
+  extends TIMESTAMP_NANOS(PhysicalTimestampNTZNanosType) {
+  override protected def getNanos(row: InternalRow, ordinal: Int): 
TimestampNanosVal =
+    row.getTimestampNTZNanos(ordinal)
+  override protected def setNanos(
+      row: InternalRow, ordinal: Int, value: TimestampNanosVal): Unit =
+    row.setTimestampNTZNanos(ordinal, value)
+}
+
+private[columnar] object TIMESTAMP_LTZ_NANOS
+  extends TIMESTAMP_NANOS(PhysicalTimestampLTZNanosType) {
+  override protected def getNanos(row: InternalRow, ordinal: Int): 
TimestampNanosVal =
+    row.getTimestampLTZNanos(ordinal)
+  override protected def setNanos(
+      row: InternalRow, ordinal: Int, value: TimestampNanosVal): Unit =
+    row.setTimestampLTZNanos(ordinal, value)
+}
+
 /**
  * Used to append/extract Java VariantVals into/from the underlying 
[[ByteBuffer]] of a column.
  *
@@ -876,6 +943,8 @@ private[columnar] object ColumnType {
       case s: StringType => STRING(s)
       case BinaryType => BINARY
       case i: CalendarIntervalType => CALENDAR_INTERVAL
+      case _: TimestampNTZNanosType => TIMESTAMP_NTZ_NANOS
+      case _: TimestampLTZNanosType => TIMESTAMP_LTZ_NANOS
       case dt: DecimalType if dt.precision <= Decimal.MAX_LONG_DIGITS => 
COMPACT_DECIMAL(dt)
       case dt: DecimalType => LARGE_DECIMAL(dt)
       case arr: ArrayType => ARRAY(PhysicalArrayType(arr.elementType, 
arr.containsNull))
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/GenerateColumnAccessor.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/GenerateColumnAccessor.scala
index dd64d92bed71..14ab652b4f07 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/GenerateColumnAccessor.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/GenerateColumnAccessor.scala
@@ -90,6 +90,8 @@ object GenerateColumnAccessor extends 
CodeGenerator[Seq[DataType], ColumnarItera
         case BinaryType => classOf[BinaryColumnAccessor].getName
         case CalendarIntervalType => classOf[IntervalColumnAccessor].getName
         case VariantType => classOf[VariantColumnAccessor].getName
+        case _: TimestampNTZNanosType => 
classOf[TimestampNTZNanosColumnAccessor].getName
+        case _: TimestampLTZNanosType => 
classOf[TimestampLTZNanosColumnAccessor].getName
         case dt: DecimalType if dt.precision <= Decimal.MAX_LONG_DIGITS =>
           classOf[CompactDecimalColumnAccessor].getName
         case dt: DecimalType => classOf[DecimalColumnAccessor].getName
@@ -102,7 +104,8 @@ object GenerateColumnAccessor extends 
CodeGenerator[Seq[DataType], ColumnarItera
       val createCode = dt match {
         case t if CodeGenerator.isPrimitiveType(dt) =>
           s"$accessorName = new 
$accessorCls(ByteBuffer.wrap(buffers[$index]).order(nativeOrder));"
-        case NullType | BinaryType | CalendarIntervalType | VariantType =>
+        case NullType | BinaryType | CalendarIntervalType | VariantType |
+            _: TimestampNTZNanosType | _: TimestampLTZNanosType =>
           s"$accessorName = new 
$accessorCls(ByteBuffer.wrap(buffers[$index]).order(nativeOrder));"
         case other =>
           s"""$accessorName = new 
$accessorCls(ByteBuffer.wrap(buffers[$index]).order(nativeOrder),
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnStatsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnStatsSuite.scala
index bdb118b91fa2..ac01a03c684d 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnStatsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnStatsSuite.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql.execution.columnar
 import org.apache.spark.SparkFunSuite
 import org.apache.spark.sql.catalyst.types.PhysicalDataType
 import org.apache.spark.sql.types.StringType
+import org.apache.spark.unsafe.types.TimestampNanosVal
 
 class ColumnStatsSuite extends SparkFunSuite {
   testColumnStats(classOf[BooleanColumnStats], BOOLEAN, Array(true, false, 0))
@@ -32,6 +33,8 @@ class ColumnStatsSuite extends SparkFunSuite {
   testDecimalColumnStats(Array(null, null, 0))
   testIntervalColumnStats(Array(null, null, 0))
   testStringColumnStats(Array(null, null, 0))
+  testTimestampNanosColumnStats(TIMESTAMP_NTZ_NANOS, Array(null, null, 0))
+  testTimestampNanosColumnStats(TIMESTAMP_LTZ_NANOS, Array(null, null, 0))
 
   def testColumnStats[T <: PhysicalDataType, U <: ColumnStats](
       columnStatsClass: Class[U],
@@ -143,6 +146,40 @@ class ColumnStatsSuite extends SparkFunSuite {
     }
   }
 
+  def testTimestampNanosColumnStats(
+      columnType: ColumnType[TimestampNanosVal],
+      initialStatistics: Array[Any]): Unit = {
+
+    val columnStatsName = classOf[TimestampNanosColumnStats].getSimpleName
+
+    test(s"$columnStatsName ($columnType): empty") {
+      val columnStats = new TimestampNanosColumnStats
+      columnStats.collectedStatistics.zip(initialStatistics).foreach {
+        case (actual, expected) => assert(actual === expected)
+      }
+    }
+
+    test(s"$columnStatsName ($columnType): non-empty collects min/max bounds") 
{
+      import org.apache.spark.sql.execution.columnar.ColumnarTestUtils._
+
+      val columnStats = new TimestampNanosColumnStats
+      val rows = Seq.fill(10)(makeRandomRow(columnType)) ++ 
Seq.fill(10)(makeNullRow(1))
+      rows.foreach(columnStats.gatherStats(_, 0))
+
+      val values = rows.take(10).map(_.get(0,
+        ColumnarDataTypeUtils.toLogicalDataType(columnType.dataType))
+        .asInstanceOf[TimestampNanosVal])
+      val ordering = Ordering.fromLessThan[TimestampNanosVal](_.compareTo(_) < 
0)
+      val stats = columnStats.collectedStatistics
+
+      assertResult(values.min(ordering), "Wrong lower bound")(stats(0))
+      assertResult(values.max(ordering), "Wrong upper bound")(stats(1))
+      assertResult(10, "Wrong null count")(stats(2))
+      assertResult(20, "Wrong row count")(stats(3))
+      assertResult(TimestampNanosVal.SIZE_IN_BYTES * 10 + 4 * 10, "Wrong size 
in bytes")(stats(4))
+    }
+  }
+
   def testStringColumnStats[T <: PhysicalDataType, U <: ColumnStats](
       initialStatistics: Array[Any]): Unit = {
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnTypeSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnTypeSuite.scala
index cb97066098f2..93f2ed85e53f 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnTypeSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnTypeSuite.scala
@@ -44,7 +44,7 @@ class ColumnTypeSuite extends SparkFunSuite {
       STRING(StringType) -> 8, STRING(StringType("UTF8_LCASE")) -> 8,
       STRING(StringType("UNICODE")) -> 8, STRING(StringType("UNICODE_CI")) -> 
8,
       BINARY -> 16, STRUCT_TYPE -> 20, ARRAY_TYPE -> 28, MAP_TYPE -> 68,
-      CALENDAR_INTERVAL -> 16)
+      CALENDAR_INTERVAL -> 16, TIMESTAMP_NTZ_NANOS -> 16, TIMESTAMP_LTZ_NANOS 
-> 16)
 
     checks.foreach { case (columnType, expectedSize) =>
       assertResult(expectedSize, s"Wrong defaultSize for $columnType") {
@@ -113,6 +113,8 @@ class ColumnTypeSuite extends SparkFunSuite {
   testColumnType(ARRAY_TYPE)
   testColumnType(MAP_TYPE)
   testColumnType(CALENDAR_INTERVAL)
+  testColumnType(TIMESTAMP_NTZ_NANOS)
+  testColumnType(TIMESTAMP_LTZ_NANOS)
 
   def testNativeColumnType[T <: PhysicalDataType](columnType: 
NativeColumnType[T]): Unit = {
     val typeName = columnType match {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarDataTypeUtils.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarDataTypeUtils.scala
index 018ce36eb783..639a7c99b3ab 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarDataTypeUtils.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarDataTypeUtils.scala
@@ -30,6 +30,8 @@ object ColumnarDataTypeUtils {
     case PhysicalShortType => ShortType
     case PhysicalBinaryType => BinaryType
     case PhysicalCalendarIntervalType => CalendarIntervalType
+    case PhysicalTimestampNTZNanosType => TimestampNTZNanosType()
+    case PhysicalTimestampLTZNanosType => TimestampLTZNanosType()
     case PhysicalFloatType => FloatType
     case PhysicalDoubleType => DoubleType
     case PhysicalStringType(collationId) => StringType(collationId)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarTestUtils.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarTestUtils.scala
index d08c34056f56..044277d46e93 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarTestUtils.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ColumnarTestUtils.scala
@@ -25,7 +25,7 @@ import 
org.apache.spark.sql.catalyst.expressions.GenericInternalRow
 import org.apache.spark.sql.catalyst.types.PhysicalDataType
 import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, GenericArrayData}
 import org.apache.spark.sql.types.Decimal
-import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String}
+import org.apache.spark.unsafe.types.{CalendarInterval, TimestampNanosVal, 
UTF8String}
 
 object ColumnarTestUtils {
   def makeNullRow(length: Int): GenericInternalRow = {
@@ -54,6 +54,10 @@ object ColumnarTestUtils {
       case BINARY => randomBytes(Random.nextInt(32))
       case CALENDAR_INTERVAL =>
         new CalendarInterval(Random.nextInt(), Random.nextInt(), 
Random.nextLong())
+      case _: TIMESTAMP_NANOS =>
+        // nanosWithinMicro must be in [0, 999]; epochMicros can be any long.
+        TimestampNanosVal.fromParts(
+          Random.nextLong(), 
Random.nextInt(TimestampNanosVal.MAX_NANOS_WITHIN_MICRO + 1).toShort)
       case COMPACT_DECIMAL(precision, scale) => Decimal(Random.nextLong() % 
100, precision, scale)
       case LARGE_DECIMAL(precision, scale) => Decimal(Random.nextLong(), 
precision, scale)
       case STRUCT(_) =>
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
index 57da12e87979..fcf7edfcf87b 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/InMemoryColumnarQuerySuite.scala
@@ -222,6 +222,36 @@ class InMemoryColumnarQuerySuite extends 
SharedSparkSession with AdaptiveSparkPl
     }
   }
 
+  test("cache nanosecond-precision timestamp types") {
+    // Nanosecond timestamps are non-primitive for the default cache 
(DefaultCachedBatchSerializer
+    // .supportsColumnarOutput is true only for primitive types), so they 
always read back through
+    // the row path -- the vectorized reader is not exercised, the same as for 
CalendarInterval,
+    // Variant, and Decimal.
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      Seq("TIMESTAMP_NTZ(9)", "TIMESTAMP_LTZ(9)").foreach { typeName =>
+        withTempView("nanos") {
+          // Include sub-microsecond precision and a null to exercise the full 
payload and null
+          // handling through the cache.
+          val df = sql(
+            s"""SELECT * FROM VALUES
+               |  (cast('2020-01-01 00:00:00.123456789' as $typeName)),
+               |  (cast('1999-12-31 23:59:59.987654321' as $typeName)),
+               |  (cast(null as $typeName))
+               |  as t(ts)""".stripMargin)
+          df.createOrReplaceTempView("nanos")
+          val expected = sql("SELECT ts FROM nanos").collect().toSeq
+
+          spark.catalog.cacheTable("nanos")
+          try {
+            checkAnswer(sql("SELECT ts FROM nanos"), expected)
+          } finally {
+            spark.catalog.uncacheTable("nanos")
+          }
+        }
+      }
+    }
+  }
+
   test("SPARK-3320 regression: batched column buffer building should work with 
empty partitions") {
     checkAnswer(
       sql("SELECT * FROM withEmptyParts"),
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/PartitionBatchPruningSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/PartitionBatchPruningSuite.scala
index 88ff51d0ff4c..c8ba722fd74c 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/PartitionBatchPruningSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/PartitionBatchPruningSuite.scala
@@ -212,4 +212,62 @@ class PartitionBatchPruningSuite extends 
SharedSparkSession with AdaptiveSparkPl
         s"Wrong number of read partitions: $queryExecution")
     }
   }
+
+  test("SPARK-57735: partition pruning on cached nanosecond-timestamp column") 
{
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      withTempView("nanosPruning") {
+        // 100 monotonically increasing nanosecond timestamps in one ordered 
partition; with batch
+        // size 10 (set in beforeAll) this is 10 batches whose min/max bounds 
are ordered and
+        // non-overlapping. makeRDD with a single slice preserves element 
order (no shuffle), so a
+        // range filter can prune to just the matching batches. The timestamps 
differ only in the
+        // sub-microsecond component, so this also exercises 
nanosecond-precision bounds.
+        val rows = (1 to 100).map { k =>
+          Tuple1(s"2020-01-01 00:00:00.${"%09d".format(k)}")
+        }
+        // Boundary chosen so only the last batch (values 91..100) qualifies.
+        val boundary = "cast('2020-01-01 00:00:00.000000090' as 
TIMESTAMP_NTZ(9))"
+
+        // Compute the expected result BEFORE caching, so it cannot hit the 
cache (the CacheManager
+        // matches by logical plan, not by DataFrame identity, so evaluating 
an equivalent query
+        // after caching could be served from the InMemoryRelation).
+        val expected = sparkContext.makeRDD(rows, 1).toDF("s")
+          .selectExpr("cast(s as TIMESTAMP_NTZ(9)) as ts")
+          .where(s"ts > $boundary").orderBy("ts").collect().toSeq
+        assert(expected.nonEmpty && expected.size < 100,
+          "test boundary should select a strict, non-empty subset")
+
+        sparkContext.makeRDD(rows, 1).toDF("s")
+          .selectExpr("cast(s as TIMESTAMP_NTZ(9)) as ts")
+          .createOrReplaceTempView("nanosPruning")
+        spark.catalog.cacheTable("nanosPruning")
+        try {
+          // Correctness: the cached + pruned read matches the pre-cache 
evaluation.
+          val cached = sql(s"SELECT ts FROM nanosPruning WHERE ts > $boundary 
ORDER BY ts")
+          assert(cached.collect().toSeq === expected,
+            "cached + pruned result must match the uncached evaluation")
+
+          // Pruning: the same range query reads fewer batches with in-memory 
partition pruning on
+          // than off. (With bounds-less stats the counts would be equal 
because no batch can be
+          // skipped.) Comparing pruning-on vs pruning-off for the identical 
query avoids depending
+          // on the absolute batch/partition count, and mirrors the suite's
+          // "disable IN_MEMORY_PARTITION_PRUNING" test.
+          def readBatchesWithPruning(enabled: Boolean): Long = {
+            withSQLConf(SQLConf.IN_MEMORY_PARTITION_PRUNING.key -> 
enabled.toString) {
+              val df = sql(s"SELECT ts FROM nanosPruning WHERE ts > $boundary")
+              df.collect()
+              collect(df.queryExecution.executedPlan) {
+                case in: InMemoryTableScanExec => in.readBatches.value
+              }.head
+            }
+          }
+          val withoutPruning = readBatchesWithPruning(enabled = false)
+          val withPruning = readBatchesWithPruning(enabled = true)
+          assert(withPruning < withoutPruning,
+            s"expected pruning to read fewer batches: $withPruning (on) vs 
$withoutPruning (off)")
+        } finally {
+          spark.catalog.uncacheTable("nanosPruning")
+        }
+      }
+    }
+  }
 }


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

Reply via email to