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 686d1c4044ad [SPARK-57159][SQL] Add Arrow type mapping for
nanosecond-capable timestamp types
686d1c4044ad is described below
commit 686d1c4044ad0405dc6ae2c5cc1249aa47f26451
Author: Maxim Gekk <[email protected]>
AuthorDate: Thu Jun 25 11:34:51 2026 +0200
[SPARK-57159][SQL] Add Arrow type mapping for nanosecond-capable timestamp
types
### What changes were proposed in this pull request?
This PR teaches Spark's Arrow conversion about the nanosecond timestamp
types `TimestampNTZNanosType(p)` and `TimestampLTZNanosType(p)` (`p` in `[7,
9]`), so they can be carried over Arrow like the other timestamp types.
- **Type mapping**: NTZ maps to Arrow `Timestamp(NANOSECOND, null)` and LTZ
to `Timestamp(NANOSECOND, sessionTz)`, and back. Arrow timestamps have no
precision field, so the exact precision is preserved in field metadata
(`SPARK::timestampNanos::precision`) and falls back to `9` when it is missing.
- **Writer/reader**: new `ArrowWriter` field writers pack the value into
int64 epoch-nanoseconds (raising `DATETIME_OVERFLOW` when out of range), and
matching `ArrowColumnVector` accessors decode it back into `TimestampNanosVal`.
- **Reuse**: the epoch-nanos packing
(`DateTimeUtils.timestampNanosToEpochNanos`) and the overflow error are now
shared with the Parquet INT64 path (SPARK-57100) instead of duplicated; the
existing Parquet error message is unchanged.
### Why are the changes needed?
This is the shared Arrow prerequisite for Spark Connect (parent:
SPARK-56822) and also benefits the classic Arrow paths (Arrow result transfer,
`createDataFrame` from Arrow, `mapInArrow`). The Spark <-> Arrow mapping
(`ArrowUtils`) and the row-to-vector writers (`ArrowWriter`) had no support for
the nanosecond timestamp types, so any plan whose schema contained them failed
to serialize.
### Does this PR introduce _any_ user-facing change?
No. The types remain gated behind `spark.sql.timestampNanosTypes.enabled`.
### How was this patch tested?
- `ArrowUtilsSuite`: precision round-trip for `p` in `{7, 8, 9}` (NTZ and
LTZ across multiple session zones), null-tz LTZ error, fallback to `9` when the
precision metadata is absent or present-but-invalid (out of `[7, 9]` or
non-numeric), and that the precision key does not leak into the reconstructed
column `Metadata`.
- `ArrowWriterSuite`: value round-trip (sub-micro `0`/`999`, pre-epoch
instants, large boundaries, nulls) for `p = 9` and `p = 7`, and
`DATETIME_OVERFLOW` for out-of-range values, for both NTZ and LTZ.
- `DateTimeUtilsSuite`: direct unit test for the shared
`timestampNanosToEpochNanos` helper (sub-micro boundaries `0`/`999`, positive
and pre-epoch values, the `floorDiv`/`floorMod` inverse, the `Long.MaxValue`
boundary, and the overflow `ArithmeticException`).
- `ArrowConvertersSuite`: end-to-end Arrow IPC batch round-trip
(`toBatchIterator` -> `fromBatchIterator`) for NTZ and LTZ, including
sub-micro/pre-epoch values and a trailing null.
- `ParquetTimestampNanosSuite`: re-run to confirm the shared-helper
refactor preserves the existing Parquet behavior.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Cursor (Claude Opus 4.8)
Closes #56739 from MaxGekk/nanos-arrow.
Authored-by: Maxim Gekk <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
---
.../types/ops/TimestampNanosTypeApiOps.scala | 20 ++++++
.../spark/sql/catalyst/types/ops/TypeApiOps.scala | 9 +++
.../org/apache/spark/sql/util/ArrowUtils.scala | 55 +++++++++++++++++
.../spark/sql/vectorized/ArrowColumnVector.java | 60 ++++++++++++++++++
.../catalyst/types/ops/TimestampNanosTypeOps.scala | 9 +++
.../spark/sql/catalyst/util/DateTimeUtils.scala | 14 +++++
.../spark/sql/errors/QueryExecutionErrors.scala | 6 +-
.../spark/sql/execution/arrow/ArrowWriter.scala | 44 +++++++++++++-
.../sql/catalyst/util/DateTimeUtilsSuite.scala | 33 ++++++++++
.../apache/spark/sql/util/ArrowUtilsSuite.scala | 70 ++++++++++++++++++++-
.../datasources/parquet/ParquetWriteSupport.scala | 9 ++-
.../sql/execution/arrow/ArrowConvertersSuite.scala | 45 +++++++++++++-
.../sql/execution/arrow/ArrowWriterSuite.scala | 71 +++++++++++++++++++++-
13 files changed, 430 insertions(+), 15 deletions(-)
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeApiOps.scala
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeApiOps.scala
index 650575d006f1..76bbf0c6f4a8 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeApiOps.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeApiOps.scala
@@ -19,6 +19,10 @@ package org.apache.spark.sql.catalyst.types.ops
import java.time.{Instant, LocalDateTime, ZoneId, ZoneOffset}
+import org.apache.arrow.vector.types.TimeUnit
+import org.apache.arrow.vector.types.pojo.ArrowType
+
+import org.apache.spark.SparkException
import org.apache.spark.sql.catalyst.encoders.AgnosticEncoder
import
org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{InstantNanosEncoder,
LocalDateTimeNanosEncoder}
import org.apache.spark.sql.catalyst.util.TimestampFormatter
@@ -116,6 +120,12 @@ class TimestampNTZNanosTypeApiOps(val t:
TimestampNTZNanosType) extends Timestam
// Mirrors RowEncoder.encoderForDataTypeDefault for TimestampNTZNanosType
(SPARK-57033):
// maps to java.time.LocalDateTime with the column precision.
override protected def nanosEncoder: AgnosticEncoder[_] =
LocalDateTimeNanosEncoder(t.precision)
+
+ // NTZ is zone-less: like TimestampNTZType, the Arrow timestamp carries a
null time zone. The
+ // column precision is not expressible in the Arrow type itself and is
carried in the Arrow
+ // field metadata instead (see ArrowUtils).
+ override def toArrowType(timeZoneId: String): Option[ArrowType] =
+ Some(new ArrowType.Timestamp(TimeUnit.NANOSECOND, null))
}
/**
@@ -154,4 +164,14 @@ class TimestampLTZNanosTypeApiOps(val t:
TimestampLTZNanosType, zoneId: => ZoneI
// Mirrors RowEncoder.encoderForDataTypeDefault for TimestampLTZNanosType
(SPARK-57033):
// maps to java.time.Instant with the column precision.
override protected def nanosEncoder: AgnosticEncoder[_] =
InstantNanosEncoder(t.precision)
+
+ // LTZ is zone-aware: like TimestampType, the Arrow timestamp carries the
session time zone, so
+ // a non-null timeZoneId is mandatory (mirrors ArrowUtils.toArrowTypeDefault
for TimestampType).
+ // The column precision is carried in the Arrow field metadata instead (see
ArrowUtils).
+ override def toArrowType(timeZoneId: String): Option[ArrowType] = {
+ if (timeZoneId == null) {
+ throw SparkException.internalError("Missing timezoneId where it is
mandatory.")
+ }
+ Some(new ArrowType.Timestamp(TimeUnit.NANOSECOND, timeZoneId))
+ }
}
diff --git
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeApiOps.scala
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeApiOps.scala
index 728c6ae40cd4..e94e063f3d09 100644
---
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeApiOps.scala
+++
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeApiOps.scala
@@ -202,6 +202,15 @@ object TypeApiOps {
at match {
case t: ArrowType.Time if t.getUnit == TimeUnit.NANOSECOND &&
t.getBitWidth == 8 * 8 =>
Some(TimeType(TimeType.MICROS_PRECISION))
+ // Nanosecond Arrow timestamps map to the nanosecond-capable Spark
timestamp types. The Arrow
+ // type carries no fractional-second precision, so this precision-less
reverse lookup uses the
+ // canonical maximum precision; the exact precision is recovered from
the Arrow field metadata
+ // by ArrowUtils.fromArrowField when present.
+ case ts: ArrowType.Timestamp
+ if ts.getUnit == TimeUnit.NANOSECOND && ts.getTimezone == null =>
+ Some(TimestampNTZNanosType(TimestampNTZNanosType.MAX_PRECISION))
+ case ts: ArrowType.Timestamp if ts.getUnit == TimeUnit.NANOSECOND =>
+ Some(TimestampLTZNanosType(TimestampLTZNanosType.MAX_PRECISION))
// Add new framework types here
case _ => None
}
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/util/ArrowUtils.scala
b/sql/api/src/main/scala/org/apache/spark/sql/util/ArrowUtils.scala
index b84336cc0f54..15cf5b23e4ac 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/util/ArrowUtils.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/util/ArrowUtils.scala
@@ -110,6 +110,11 @@ private[sql] object ArrowUtils {
}
private val metadataKey = "SPARK::metadata::json"
+ // Arrow's Timestamp type carries only (unit, timezone) and has no
fractional-second precision
+ // field, so the precision of the nanosecond timestamp types is stored in
the Arrow field
+ // metadata under this dedicated key (namespaced like `metadataKey`,
separate from the user
+ // metadata blob so user metadata is untouched) and recovered on read in
`fromArrowField`.
+ private val timestampNanosPrecisionKey = "SPARK::timestampNanos::precision"
private def toArrowMetaData(metadata: Metadata) = {
if (metadata != null && !metadata.isEmpty) {
Map(metadataKey -> metadata.json).asJava
@@ -125,6 +130,24 @@ private[sql] object ArrowUtils {
}
}
+ /**
+ * Builds an Arrow field for a nanosecond timestamp type, stashing the
column precision in the
+ * field metadata (alongside the user metadata) so it can be recovered in
`fromArrowField`.
+ */
+ private def toTimestampNanosArrowField(
+ name: String,
+ dt: DataType,
+ precision: Int,
+ nullable: Boolean,
+ timeZoneId: String,
+ largeVarTypes: Boolean,
+ metadata: Metadata): Field = {
+ val base =
Option(toArrowMetaData(metadata)).map(_.asScala.toMap).getOrElse(Map.empty)
+ val md = (base + (timestampNanosPrecisionKey -> precision.toString)).asJava
+ val fieldType = new FieldType(nullable, toArrowType(dt, timeZoneId,
largeVarTypes), null, md)
+ new Field(name, fieldType, Seq.empty[Field].asJava)
+ }
+
/** Maps field from Spark to Arrow. NOTE: timeZoneId required for
TimestampType */
def toArrowField(
name: String,
@@ -231,6 +254,24 @@ private[sql] object ArrowUtils {
Seq(
toArrowField("value", BinaryType, false, timeZoneId,
largeVarTypes),
new Field("metadata", metadataFieldType,
Seq.empty[Field].asJava)).asJava)
+ case t: TimestampNTZNanosType =>
+ toTimestampNanosArrowField(
+ name,
+ t,
+ t.precision,
+ nullable,
+ timeZoneId,
+ largeVarTypes,
+ metadata)
+ case t: TimestampLTZNanosType =>
+ toTimestampNanosArrowField(
+ name,
+ t,
+ t.precision,
+ nullable,
+ timeZoneId,
+ largeVarTypes,
+ metadata)
case dataType =>
val fieldType = new FieldType(
nullable,
@@ -310,6 +351,20 @@ private[sql] object ArrowUtils {
StructField(child.getName, dt, child.isNullable,
fromArrowMetaData(child.getMetadata))
}
StructType(fields.toArray)
+ // Recover the exact precision of nanosecond timestamps from the field
metadata written by
+ // `toTimestampNanosArrowField`. Foreign Arrow data (or an out-of-range
value) has no usable
+ // key, so fall back to the canonical maximum precision via
`fromArrowType`.
+ case ts: ArrowType.Timestamp if ts.getUnit == TimeUnit.NANOSECOND =>
+ val precision =
Option(field.getMetadata.get(timestampNanosPrecisionKey))
+ .flatMap(s => scala.util.Try(s.toInt).toOption)
+ .filter { p =>
+ p >= TimestampNTZNanosType.MIN_PRECISION && p <=
TimestampNTZNanosType.MAX_PRECISION
+ }
+ precision match {
+ case Some(p) if ts.getTimezone == null => TimestampNTZNanosType(p)
+ case Some(p) => TimestampLTZNanosType(p)
+ case None => fromArrowType(ts)
+ }
case arrowType => fromArrowType(arrowType)
}
}
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/vectorized/ArrowColumnVector.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/vectorized/ArrowColumnVector.java
index 8a47e93724d9..3267daea6bcc 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/vectorized/ArrowColumnVector.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/vectorized/ArrowColumnVector.java
@@ -25,11 +25,13 @@ import
org.apache.arrow.vector.holders.NullableVarCharHolder;
import org.apache.spark.SparkUnsupportedOperationException;
import org.apache.spark.annotation.DeveloperApi;
+import org.apache.spark.sql.catalyst.util.DateTimeConstants;
import org.apache.spark.sql.catalyst.util.STUtils;
import org.apache.spark.sql.util.ArrowUtils;
import org.apache.spark.sql.types.*;
import org.apache.spark.unsafe.types.BinaryView;
import org.apache.spark.unsafe.types.CalendarInterval;
+import org.apache.spark.unsafe.types.TimestampNanosVal;
import org.apache.spark.unsafe.types.UTF8String;
/**
@@ -123,6 +125,18 @@ public class ArrowColumnVector extends ColumnVector {
return accessor.getInterval(rowId);
}
+ @Override
+ public TimestampNanosVal getTimestampNTZNanos(int rowId) {
+ if (isNullAt(rowId)) return null;
+ return accessor.getTimestampNanos(rowId);
+ }
+
+ @Override
+ public TimestampNanosVal getTimestampLTZNanos(int rowId) {
+ if (isNullAt(rowId)) return null;
+ return accessor.getTimestampNanos(rowId);
+ }
+
@Override
public byte[] getBinary(int rowId) {
if (isNullAt(rowId)) return null;
@@ -204,6 +218,10 @@ public class ArrowColumnVector extends ColumnVector {
accessor = new TimestampNTZAccessor(timeStampMicroVector);
} else if (vector instanceof TimeNanoVector timeNanoVector) {
accessor = new TimeNanoAccessor(timeNanoVector);
+ } else if (vector instanceof TimeStampNanoTZVector timeStampNanoTZVector) {
+ accessor = new TimestampLTZNanosAccessor(timeStampNanoTZVector);
+ } else if (vector instanceof TimeStampNanoVector timeStampNanoVector) {
+ accessor = new TimestampNTZNanosAccessor(timeStampNanoVector);
} else if (vector instanceof MapVector mapVector) {
accessor = new MapAccessor(mapVector);
} else if (vector instanceof ListVector listVector) {
@@ -280,6 +298,10 @@ public class ArrowColumnVector extends ColumnVector {
throw SparkUnsupportedOperationException.apply();
}
+ TimestampNanosVal getTimestampNanos(int rowId) {
+ throw SparkUnsupportedOperationException.apply();
+ }
+
Decimal getDecimal(int rowId, int precision, int scale) {
throw SparkUnsupportedOperationException.apply();
}
@@ -559,6 +581,44 @@ public class ArrowColumnVector extends ColumnVector {
}
}
+ // Decodes a single int64 of epoch-nanoseconds back into the (epochMicros,
nanosWithinMicro)
+ // pair. floorDiv/floorMod keep nanosWithinMicro in [0, 999] for pre-epoch
(negative) values too.
+ private static TimestampNanosVal decodeEpochNanos(long nanos) {
+ return TimestampNanosVal.fromTrustedRowBytes(
+ Math.floorDiv(nanos, DateTimeConstants.NANOS_PER_MICROS),
+ (short) Math.floorMod(nanos, DateTimeConstants.NANOS_PER_MICROS));
+ }
+
+ static class TimestampNTZNanosAccessor extends ArrowVectorAccessor {
+
+ private final TimeStampNanoVector accessor;
+
+ TimestampNTZNanosAccessor(TimeStampNanoVector vector) {
+ super(vector);
+ this.accessor = vector;
+ }
+
+ @Override
+ final TimestampNanosVal getTimestampNanos(int rowId) {
+ return decodeEpochNanos(accessor.get(rowId));
+ }
+ }
+
+ static class TimestampLTZNanosAccessor extends ArrowVectorAccessor {
+
+ private final TimeStampNanoTZVector accessor;
+
+ TimestampLTZNanosAccessor(TimeStampNanoTZVector vector) {
+ super(vector);
+ this.accessor = vector;
+ }
+
+ @Override
+ final TimestampNanosVal getTimestampNanos(int rowId) {
+ return decodeEpochNanos(accessor.get(rowId));
+ }
+ }
+
static class ArrayAccessor extends ArrowVectorAccessor {
private final ListVector accessor;
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
index 48628619cdb4..6ecebf9a3fe0 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
@@ -19,12 +19,15 @@ package org.apache.spark.sql.catalyst.types.ops
import java.time.{Instant, LocalDateTime}
+import org.apache.arrow.vector.{TimeStampNanoTZVector, TimeStampNanoVector,
ValueVector}
+
import org.apache.spark.SparkIllegalArgumentException
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.{Expression, Literal,
MutableTimestampNanos, MutableValue}
import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke
import org.apache.spark.sql.catalyst.types.{PhysicalDataType,
PhysicalTimestampLTZNanosType, PhysicalTimestampNTZNanosType}
import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import org.apache.spark.sql.execution.arrow.{ArrowFieldWriter,
TimestampLTZNanosWriter, TimestampNTZNanosWriter}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{ObjectType, TimestampLTZNanosType,
TimestampNTZNanosType}
import org.apache.spark.unsafe.types.TimestampNanosVal
@@ -121,6 +124,9 @@ case class TimestampNTZNanosTypeOps(override val t:
TimestampNTZNanosType)
"timestampNanosToLocalDateTime",
path :: Nil,
returnNullable = false))
+
+ override def createArrowFieldWriter(vector: ValueVector):
Option[ArrowFieldWriter] =
+ Some(new TimestampNTZNanosWriter(vector.asInstanceOf[TimeStampNanoVector]))
}
/**
@@ -171,4 +177,7 @@ case class TimestampLTZNanosTypeOps(override val t:
TimestampLTZNanosType)
"timestampNanosToInstant",
path :: Nil,
returnNullable = false))
+
+ override def createArrowFieldWriter(vector: ValueVector):
Option[ArrowFieldWriter] =
+ Some(new
TimestampLTZNanosWriter(vector.asInstanceOf[TimeStampNanoTZVector]))
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
index 481f99709858..86cd01a0f3b5 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
@@ -317,6 +317,20 @@ object DateTimeUtils extends SparkDateTimeUtils {
TimestampNanosVal.fromParts(epochMicros, start.nanosWithinMicro)
}
+ /**
+ * Packs a [[TimestampNanosVal]] (epoch micros + nanos within the micro)
into a single int64 of
+ * nanoseconds since the epoch, the representation used by the Arrow
nanosecond timestamp vectors
+ * and the Parquet INT64 epoch-nanoseconds physical type. Throws
[[ArithmeticException]] when the
+ * value falls outside the int64 epoch-nanosecond range; callers translate
that into a
+ * `DATETIME_OVERFLOW` error naming their sink (see
+ *
[[org.apache.spark.sql.errors.QueryExecutionErrors.timestampNanosEpochNanosOverflowError]]).
+ */
+ def timestampNanosToEpochNanos(value: TimestampNanosVal): Long = {
+ Math.addExact(
+ Math.multiplyExact(value.epochMicros, NANOS_PER_MICROS),
+ value.nanosWithinMicro.toLong)
+ }
+
/**
* Adds a full interval (months, days, microseconds) to a timestamp
represented as the number of
* microseconds since 1970-01-01 00:00:00Z.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
index c46d88aad3f8..5eb117465175 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
@@ -2627,8 +2627,8 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
summary = "")
}
- def parquetTimestampNanosOverflowError(
- value: TimestampNanosVal, isNtz: Boolean): SparkArithmeticException = {
+ def timestampNanosEpochNanosOverflowError(
+ value: TimestampNanosVal, isNtz: Boolean, sink: String):
SparkArithmeticException = {
// Render TIMESTAMP_NTZ values without a zone (LocalDateTime, no trailing
`Z`); TIMESTAMP_LTZ
// values are absolute instants and render as UTC with a trailing `Z`.
val rendered =
@@ -2637,7 +2637,7 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
new SparkArithmeticException(
errorClass = "DATETIME_OVERFLOW",
messageParameters = Map(
- "operation" -> (s"write the timestamp value $rendered as Parquet INT64
" +
+ "operation" -> (s"write the timestamp value $rendered as $sink " +
"epoch-nanoseconds " +
"(supported range: 1677-09-21T00:12:43.145224192Z to
2262-04-11T23:47:16.854775807Z)")),
context = Array.empty,
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowWriter.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowWriter.scala
index 1e2ae058ac63..6030eee94a6c 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowWriter.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowWriter.scala
@@ -25,8 +25,8 @@ import org.apache.arrow.vector.complex._
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
import org.apache.spark.sql.catalyst.types.ops.TypeOps
-import org.apache.spark.sql.catalyst.util.STUtils
-import org.apache.spark.sql.errors.ExecutionErrors
+import org.apache.spark.sql.catalyst.util.{DateTimeUtils, STUtils}
+import org.apache.spark.sql.errors.{ExecutionErrors, QueryExecutionErrors}
import org.apache.spark.sql.types._
import org.apache.spark.sql.util.ArrowUtils
@@ -390,6 +390,46 @@ private[sql] class TimeWriter(
}
}
+private[sql] class TimestampNTZNanosWriter(
+ val valueVector: TimeStampNanoVector) extends ArrowFieldWriter {
+
+ override def setNull(): Unit = {
+ valueVector.setNull(count)
+ }
+
+ override def setValue(input: SpecializedGetters, ordinal: Int): Unit = {
+ val v = input.getTimestampNTZNanos(ordinal)
+ val nanos = try {
+ DateTimeUtils.timestampNanosToEpochNanos(v)
+ } catch {
+ case _: ArithmeticException =>
+ throw QueryExecutionErrors.timestampNanosEpochNanosOverflowError(
+ v, isNtz = true, sink = "Arrow INT64")
+ }
+ valueVector.setSafe(count, nanos)
+ }
+}
+
+private[sql] class TimestampLTZNanosWriter(
+ val valueVector: TimeStampNanoTZVector) extends ArrowFieldWriter {
+
+ override def setNull(): Unit = {
+ valueVector.setNull(count)
+ }
+
+ override def setValue(input: SpecializedGetters, ordinal: Int): Unit = {
+ val v = input.getTimestampLTZNanos(ordinal)
+ val nanos = try {
+ DateTimeUtils.timestampNanosToEpochNanos(v)
+ } catch {
+ case _: ArithmeticException =>
+ throw QueryExecutionErrors.timestampNanosEpochNanosOverflowError(
+ v, isNtz = false, sink = "Arrow INT64")
+ }
+ valueVector.setSafe(count, nanos)
+ }
+}
+
private[arrow] class ArrayWriter(
val valueVector: ListVector,
val elementWriter: ArrowFieldWriter) extends ArrowFieldWriter {
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
index 6db96d8d366a..34fc5286722c 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
@@ -1199,6 +1199,39 @@ class DateTimeUtilsSuite extends SparkFunSuite with
Matchers with SQLHelper {
}
}
+ test("SPARK-57159: timestampNanosToEpochNanos packs into int64
epoch-nanoseconds") {
+ def nanos(epochMicros: Long, nanosWithinMicro: Int): TimestampNanosVal =
+ TimestampNanosVal.fromParts(epochMicros, nanosWithinMicro.toShort)
+
+ // Packs (epochMicros, nanosWithinMicro) as epochMicros * 1000 +
nanosWithinMicro, including the
+ // sub-microsecond remainder boundaries 0 and 999.
+ assert(timestampNanosToEpochNanos(nanos(0L, 0)) === 0L)
+ assert(timestampNanosToEpochNanos(nanos(0L, 999)) === 999L)
+ assert(timestampNanosToEpochNanos(nanos(1L, 0)) === NANOS_PER_MICROS)
+ assert(timestampNanosToEpochNanos(nanos(1234567L, 7)) === 1234567L *
NANOS_PER_MICROS + 7L)
+ // Pre-epoch (negative epochMicros) values pack linearly too.
+ assert(timestampNanosToEpochNanos(nanos(-1L, 0)) === -NANOS_PER_MICROS)
+ assert(timestampNanosToEpochNanos(nanos(-1234567L, 13)) === -1234567L *
NANOS_PER_MICROS + 13L)
+
+ // Round-trips with the documented inverse (floorDiv/floorMod) that the
Arrow reader uses to
+ // reconstruct the pair, for positive and pre-epoch values alike.
+ Seq(nanos(0L, 0), nanos(0L, 999), nanos(1234567L, 7), nanos(-1234567L,
13)).foreach { v =>
+ val packed = timestampNanosToEpochNanos(v)
+ assert(Math.floorDiv(packed, NANOS_PER_MICROS) === v.epochMicros)
+ assert(Math.floorMod(packed, NANOS_PER_MICROS) ===
v.nanosWithinMicro.toLong)
+ }
+
+ // The maximum representable instant packs exactly to Long.MaxValue.
+ assert(timestampNanosToEpochNanos(
+ nanos(Long.MaxValue / NANOS_PER_MICROS, (Long.MaxValue %
NANOS_PER_MICROS).toInt)) ===
+ Long.MaxValue)
+
+ // Out-of-range values raise ArithmeticException (callers translate it
into DATETIME_OVERFLOW).
+ intercept[ArithmeticException] {
+ timestampNanosToEpochNanos(nanos(Long.MaxValue / NANOS_PER_MICROS + 1,
0))
+ }
+ }
+
test("SPARK-34903: subtract timestamps") {
DateTimeTestUtils.outstandingZoneIds.foreach { zid =>
Seq(
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/util/ArrowUtilsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/util/ArrowUtilsSuite.scala
index 8610a8018b27..16682f981633 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/util/ArrowUtilsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/util/ArrowUtilsSuite.scala
@@ -19,7 +19,8 @@ package org.apache.spark.sql.util
import java.time.ZoneId
-import org.apache.arrow.vector.types.pojo.ArrowType
+import org.apache.arrow.vector.types.TimeUnit
+import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType}
import org.apache.spark.{SparkException, SparkFunSuite,
SparkUnsupportedOperationException}
import org.apache.spark.sql.catalyst.util.DateTimeTestUtils.LA
@@ -86,6 +87,73 @@ class ArrowUtilsSuite extends SparkFunSuite {
roundtripWithTz(LA.getId)
}
+ test("timestamp nanos") {
+ // NTZ is zone-independent (null Arrow timezone); precision preserved via
field metadata.
+ Seq(7, 8, 9).foreach { p =>
+ val schema = new StructType().add("value", TimestampNTZNanosType(p))
+ val arrowSchema = ArrowUtils.toArrowSchema(schema, null, true, false)
+ val fieldType =
arrowSchema.findField("value").getType.asInstanceOf[ArrowType.Timestamp]
+ assert(fieldType.getUnit === TimeUnit.NANOSECOND)
+ assert(fieldType.getTimezone === null)
+ assert(ArrowUtils.fromArrowSchema(arrowSchema) === schema)
+ }
+
+ // LTZ is zone-aware: it requires a non-null session time zone; precision
preserved.
+ def roundtripLtz(timeZoneId: String): Unit = {
+ Seq(7, 8, 9).foreach { p =>
+ val schema = new StructType().add("value", TimestampLTZNanosType(p))
+ val arrowSchema = ArrowUtils.toArrowSchema(schema, timeZoneId, true,
false)
+ val fieldType =
arrowSchema.findField("value").getType.asInstanceOf[ArrowType.Timestamp]
+ assert(fieldType.getUnit === TimeUnit.NANOSECOND)
+ assert(fieldType.getTimezone === timeZoneId)
+ assert(ArrowUtils.fromArrowSchema(arrowSchema) === schema)
+ }
+ }
+ roundtripLtz(ZoneId.systemDefault().getId)
+ roundtripLtz("Asia/Tokyo")
+ roundtripLtz("UTC")
+ roundtripLtz(LA.getId)
+
+ // LTZ without a time zone is an error, mirroring TimestampType.
+ checkError(
+ exception = intercept[SparkException] {
+ ArrowUtils.toArrowSchema(
+ new StructType().add("value", TimestampLTZNanosType(9)), null, true,
false)
+ },
+ condition = "INTERNAL_ERROR",
+ parameters = Map("message" -> "Missing timezoneId where it is
mandatory."))
+
+ // Fallback: a nanosecond Arrow timestamp without precision metadata maps
to canonical p=9.
+ def nanosField(timeZoneId: String): Field = new Field(
+ "value",
+ new FieldType(true, new ArrowType.Timestamp(TimeUnit.NANOSECOND,
timeZoneId), null, null),
+ java.util.Collections.emptyList[Field]())
+ assert(ArrowUtils.fromArrowField(nanosField(null)) ===
TimestampNTZNanosType(9))
+ assert(ArrowUtils.fromArrowField(nanosField("UTC")) ===
TimestampLTZNanosType(9))
+
+ // Fallback also covers a present-but-invalid precision key (out of [7, 9]
or non-numeric):
+ // the value is unusable, so the type maps to the canonical p=9 just like
the no-metadata case.
+ def nanosFieldWithPrecision(timeZoneId: String, precision: String): Field
= new Field(
+ "value",
+ new FieldType(
+ true,
+ new ArrowType.Timestamp(TimeUnit.NANOSECOND, timeZoneId),
+ null,
+ java.util.Collections.singletonMap("SPARK::timestampNanos::precision",
precision)),
+ java.util.Collections.emptyList[Field]())
+ assert(
+ ArrowUtils.fromArrowField(nanosFieldWithPrecision(null, "5")) ===
TimestampNTZNanosType(9))
+ assert(
+ ArrowUtils.fromArrowField(nanosFieldWithPrecision("UTC", "x")) ===
TimestampLTZNanosType(9))
+
+ // The precision metadata key does not leak into the reconstructed column
Metadata.
+ val md = new MetadataBuilder().putString("city", "beijing").build()
+ val schemaWithMeta =
+ new StructType().add("value", TimestampNTZNanosType(7), nullable = true,
md)
+ assert(ArrowUtils.fromArrowSchema(
+ ArrowUtils.toArrowSchema(schemaWithMeta, null, true, false)) ===
schemaWithMeta)
+ }
+
test("array") {
roundtrip(ArrayType(IntegerType, containsNull = true))
roundtrip(ArrayType(IntegerType, containsNull = false))
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
index 48e57f1d6bd3..641a563cd7c1 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
@@ -34,7 +34,7 @@ import org.apache.spark.internal.Logging
import org.apache.spark.sql.{SPARK_LEGACY_DATETIME_METADATA_KEY,
SPARK_LEGACY_INT96_METADATA_KEY, SPARK_TIMEZONE_METADATA_KEY,
SPARK_VERSION_METADATA_KEY}
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
-import org.apache.spark.sql.catalyst.util.{DateTimeConstants, DateTimeUtils,
STUtils}
+import org.apache.spark.sql.catalyst.util.{DateTimeUtils, STUtils}
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.execution.datasources.DataSourceUtils
import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
@@ -193,12 +193,11 @@ class ParquetWriteSupport extends
WriteSupport[InternalRow] with Logging {
private def timestampNanosToEpochNanos(value: TimestampNanosVal, isNtz:
Boolean): Long = {
try {
- Math.addExact(
- Math.multiplyExact(value.epochMicros,
DateTimeConstants.NANOS_PER_MICROS),
- value.nanosWithinMicro.toLong)
+ DateTimeUtils.timestampNanosToEpochNanos(value)
} catch {
case _: ArithmeticException =>
- throw QueryExecutionErrors.parquetTimestampNanosOverflowError(value,
isNtz)
+ throw QueryExecutionErrors.timestampNanosEpochNanosOverflowError(
+ value, isNtz, sink = "Parquet INT64")
}
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowConvertersSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowConvertersSuite.scala
index 0d7d15b8fcd0..e3d5e2e4c5a2 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowConvertersSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowConvertersSuite.scala
@@ -36,9 +36,9 @@ import org.apache.spark.sql.catalyst.util.DateTimeUtils
import org.apache.spark.sql.classic.DataFrame
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.sql.types.{ArrayType, BinaryType, Decimal,
IntegerType, NullType, StringType, StructField, StructType}
+import org.apache.spark.sql.types.{ArrayType, BinaryType, DataType, Decimal,
IntegerType, NullType, StringType, StructField, StructType,
TimestampLTZNanosType, TimestampNTZNanosType}
import org.apache.spark.sql.util.ArrowUtils
-import org.apache.spark.unsafe.types.UTF8String
+import org.apache.spark.unsafe.types.{TimestampNanosVal, UTF8String}
import org.apache.spark.util.Utils
@@ -1431,6 +1431,47 @@ class ArrowConvertersSuite extends SharedSparkSession {
assert(count == inputRows.length)
}
+ test("SPARK-57159: roundtrip arrow batches with nanosecond timestamps") {
+ withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+ Seq[(DataType, String)](
+ (TimestampNTZNanosType(9), null),
+ (TimestampLTZNanosType(9), "UTC")).foreach { case (dt, timeZoneId) =>
+ val values = Seq(
+ TimestampNanosVal.fromParts(0L, 0.toShort),
+ TimestampNanosVal.fromParts(0L, 999.toShort),
+ TimestampNanosVal.fromParts(1234567L, 7.toShort),
+ // pre-epoch instant with a sub-microsecond remainder
+ TimestampNanosVal.fromParts(-1234567L, 13.toShort))
+ // A trailing null exercises the null path.
+ val inputRows = values.map(v => InternalRow(v)) :+ InternalRow(null)
+ val schema = StructType(Seq(StructField("value", dt, nullable = true)))
+
+ val ctx = TaskContext.empty()
+ val batchIter = ArrowConverters.toBatchIterator(
+ inputRows.iterator, schema, 5, timeZoneId, true, false, ctx)
+ // The output iterator reuses a mutable row, so read each row before
advancing.
+ val outputRowIter = ArrowConverters.fromBatchIterator(
+ batchIter, schema, timeZoneId, true, false, ctx)
+
+ var count = 0
+ outputRowIter.zipWithIndex.foreach { case (row, i) =>
+ if (i < values.length) {
+ val got = dt match {
+ case _: TimestampNTZNanosType => row.getTimestampNTZNanos(0)
+ case _: TimestampLTZNanosType => row.getTimestampLTZNanos(0)
+ }
+ assert(got.epochMicros === values(i).epochMicros)
+ assert(got.nanosWithinMicro === values(i).nanosWithinMicro)
+ } else {
+ assert(row.isNullAt(0))
+ }
+ count += 1
+ }
+ assert(count === inputRows.length)
+ }
+ }
+ }
+
test("ArrowBatchStreamWriter roundtrip") {
val inputRows = (0 until 9).map(InternalRow(_)) :+ InternalRow(null)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowWriterSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowWriterSuite.scala
index eb826a9e2357..e4a22ff18846 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowWriterSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowWriterSuite.scala
@@ -21,7 +21,7 @@ import scala.jdk.CollectionConverters._
import org.apache.arrow.vector.VectorSchemaRoot
-import org.apache.spark.SparkFunSuite
+import org.apache.spark.{SparkArithmeticException, SparkFunSuite}
import org.apache.spark.sql.Row
import org.apache.spark.sql.YearUDT
import org.apache.spark.sql.catalyst.InternalRow
@@ -32,7 +32,7 @@ import org.apache.spark.sql.catalyst.util.{Geography =>
InternalGeography, Geome
import org.apache.spark.sql.types._
import org.apache.spark.sql.util.ArrowUtils
import org.apache.spark.sql.vectorized._
-import org.apache.spark.unsafe.types.{BinaryView, CalendarInterval, UTF8String}
+import org.apache.spark.unsafe.types.{BinaryView, CalendarInterval,
TimestampNanosVal, UTF8String}
import org.apache.spark.util.MaybeNull
class ArrowWriterSuite extends SparkFunSuite {
@@ -152,6 +152,73 @@ class ArrowWriterSuite extends SparkFunSuite {
check(new YearUDT, Seq(2020, 2021, null, 2022))
}
+ test("timestamp nanos round-trip") {
+ // Decompose an int64 epoch-nanoseconds value into the (epochMicros,
nanosWithinMicro) pair,
+ // matching how the Arrow reader reconstructs it.
+ def fromEpochNanos(nanos: Long): TimestampNanosVal =
+ TimestampNanosVal.fromParts(Math.floorDiv(nanos, 1000L),
Math.floorMod(nanos, 1000L).toShort)
+
+ val values = Seq(
+ TimestampNanosVal.fromParts(0L, 0.toShort),
+ TimestampNanosVal.fromParts(0L, 999.toShort),
+ TimestampNanosVal.fromParts(1234567L, 7.toShort),
+ // pre-epoch instant with a sub-microsecond remainder
+ TimestampNanosVal.fromParts(-1234567L, 13.toShort),
+ // large positive/negative epoch-nanoseconds within the representable
range
+ fromEpochNanos(9000000000000000000L),
+ fromEpochNanos(-9000000000000000000L))
+
+ def check(dt: DataType, timeZoneId: String): Unit = {
+ val schema = new StructType().add("value", dt, nullable = true)
+ val writer = ArrowWriter.create(schema, timeZoneId)
+ assert(writer.schema === schema)
+ // Append a trailing null to exercise the null path.
+ (values.map(Option(_)) :+ None).foreach { v =>
+ writer.write(InternalRow(v.orNull))
+ }
+ writer.finish()
+
+ val reader = new ArrowColumnVector(writer.root.getFieldVectors().get(0))
+ values.zipWithIndex.foreach { case (v, rowId) =>
+ val got = dt match {
+ case _: TimestampNTZNanosType => reader.getTimestampNTZNanos(rowId)
+ case _: TimestampLTZNanosType => reader.getTimestampLTZNanos(rowId)
+ }
+ assert(got.epochMicros === v.epochMicros)
+ assert(got.nanosWithinMicro === v.nanosWithinMicro)
+ }
+ assert(reader.isNullAt(values.length))
+ writer.root.close()
+ }
+
+ check(TimestampNTZNanosType(9), null)
+ check(TimestampLTZNanosType(9), "UTC")
+ // The value path packs the full nanosecond value regardless of the column
precision (precision
+ // is carried in the Arrow field metadata, not the value), so p=7
round-trips identically to
+ // p=9; exercising it guards the value path against a future
precision-enforcing change.
+ check(TimestampNTZNanosType(7), null)
+ check(TimestampLTZNanosType(7), "UTC")
+ }
+
+ test("timestamp nanos out of range raises DATETIME_OVERFLOW") {
+ def check(dt: DataType, timeZoneId: String): Unit = {
+ val schema = new StructType().add("value", dt, nullable = true)
+ val writer = ArrowWriter.create(schema, timeZoneId)
+ // epochMicros past the int64 epoch-nanosecond range overflows when
packed, but still
+ // renders as a valid Instant/LocalDateTime in the error message.
+ val tooLarge = TimestampNanosVal.fromParts(Long.MaxValue / 1000L + 1L,
0.toShort)
+ val e = intercept[SparkArithmeticException] {
+ writer.write(InternalRow(tooLarge))
+ }
+ assert(e.getCondition === "DATETIME_OVERFLOW")
+ assert(e.getMessage.contains("Arrow INT64"))
+ writer.root.close()
+ }
+
+ check(TimestampNTZNanosType(9), null)
+ check(TimestampLTZNanosType(9), "UTC")
+ }
+
test("nested geographies") {
def check(
dt: StructType,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]