Skip to content

Commit 0baee14

Browse files
Chong Gaores-life
authored andcommitted
Fix non-UTC ORC timestamp reads
Match Apache ORC timezone, rounding, and historical offset semantics for physical timestamps and schema-evolution casts. Fixes NVIDIA#15449. Signed-off-by: Chong Gao <chongg@nvidia.com>
1 parent 9af44c6 commit 0baee14

3 files changed

Lines changed: 131 additions & 24 deletions

File tree

integration_tests/src/main/python/orc_cast_test.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2020-2025, NVIDIA CORPORATION.
1+
# Copyright (c) 2020-2026, NVIDIA CORPORATION.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -128,6 +128,39 @@ def test_casting_from_double_to_timestamp(spark_tmp_path, data_gen):
128128
)
129129

130130

131+
@pytest.mark.skipif(not is_not_utc(), reason="non-UTC ORC timestamp regression test")
132+
@allow_non_gpu(*non_utc_allow_orc_scan)
133+
def test_non_utc_timestamp_regressions(spark_tmp_path):
134+
integer_path = spark_tmp_path + '/orc_integer_timestamp_regression'
135+
double_path = spark_tmp_path + '/orc_double_timestamp_regression'
136+
physical_path = spark_tmp_path + '/orc_physical_timestamp_regression'
137+
138+
with_cpu_session(
139+
lambda spark: spark.createDataFrame([(514952012,)], "a long")
140+
.write.orc(integer_path)
141+
)
142+
with_cpu_session(
143+
lambda spark: spark.createDataFrame(
144+
[(0.0,), (-8589934591.999999,), (-7953731124.723491,)], "a double")
145+
.write.orc(double_path)
146+
)
147+
with_cpu_session(
148+
lambda spark: spark.range(1)
149+
.selectExpr("timestamp_micros(-2957649381472612L) AS a")
150+
.write.orc(physical_path)
151+
)
152+
153+
assert_gpu_and_cpu_are_equal_collect(
154+
lambda spark: spark.read.schema("a timestamp").orc(integer_path)
155+
)
156+
assert_gpu_and_cpu_are_equal_collect(
157+
lambda spark: spark.read.schema("a timestamp").orc(double_path)
158+
)
159+
assert_gpu_and_cpu_are_equal_collect(
160+
lambda spark: spark.read.orc(physical_path)
161+
)
162+
163+
131164
@allow_non_gpu(*non_utc_allow_for_test_casting_from_overflow_long)
132165
def test_casting_from_overflow_double_to_timestamp(spark_tmp_path):
133166
orc_path = spark_tmp_path + '/orc_casting_from_overflow_double_to_timestamp'

sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuOrcScan.scala

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,7 @@ object GpuOrcScan {
322322
case (DType.BOOL8 | DType.INT8 | DType.INT16 | DType.INT32 | DType.INT64,
323323
DType.TIMESTAMP_MICROSECONDS) =>
324324
withResource(OrcCastingShims.castIntegerToTimestamp(col, fromDt)) { timestamp =>
325-
GpuTimeZoneDB.fromTimestampToUtcTimestamp(
326-
timestamp, ZoneId.systemDefault().normalized())
325+
GpuTimeZoneDB.convertOrcFromUtc(timestamp, ZoneId.systemDefault().getId)
327326
}
328327

329328
// float to bool/integral
@@ -382,16 +381,19 @@ object GpuOrcScan {
382381
// Math.round half up can be implemented in terms of floor
383382
// Math.round(x) = n iff x is in [n-0.5, n+0.5) iff x+0.5 is in [n,n+1) iff floor(x+0.5) = n
384383
//
385-
val milliseconds = withResource(Scalar.fromDouble(DateTimeConstants.MILLIS_PER_SECOND)) {
386-
thousand =>
387-
// ORC assumes value is in seconds
388-
withResource(col.mul(thousand, DType.FLOAT64)) { doubleMillis =>
389-
withResource(Scalar.fromDouble(0.5)) { half =>
390-
withResource(doubleMillis.add(half)) { doubleMillisPlusHalf =>
391-
withResource(doubleMillisPlusHalf.floor()) { millis =>
392-
withResource(getOverflowFlags(doubleMillis, millis)) { overflowFlags =>
393-
withResource(Scalar.fromNull(millis.getType)) { nullVal =>
394-
overflowFlags.ifElse(millis, nullVal)
384+
val milliseconds = withResource(col.castTo(DType.FLOAT64)) { doubleSeconds =>
385+
withResource(convertOrcFloatingPointSeconds(doubleSeconds)) { convertedSeconds =>
386+
withResource(Scalar.fromDouble(DateTimeConstants.MILLIS_PER_SECOND)) { thousand =>
387+
// ORC applies timezone conversion while the value is still in seconds.
388+
withResource(convertedSeconds.mul(thousand, DType.FLOAT64)) { doubleMillis =>
389+
withResource(Scalar.fromDouble(0.5)) { half =>
390+
withResource(doubleMillis.add(half)) { doubleMillisPlusHalf =>
391+
withResource(doubleMillisPlusHalf.floor()) { millis =>
392+
withResource(getOverflowFlags(doubleMillis, millis)) { overflowFlags =>
393+
withResource(Scalar.fromNull(millis.getType)) { nullVal =>
394+
overflowFlags.ifElse(millis, nullVal)
395+
}
396+
}
395397
}
396398
}
397399
}
@@ -419,12 +421,11 @@ object GpuOrcScan {
419421
}
420422
withResource(Scalar.fromDouble(DateTimeConstants.MICROS_PER_MILLIS)) { thousand =>
421423
withResource(milliseconds.mul(thousand)) { microseconds =>
422-
withResource(microseconds.castTo(DType.INT64)) { longVec =>
423-
withResource(longVec.castTo(DType.TIMESTAMP_MICROSECONDS)) { timestamp =>
424-
GpuTimeZoneDB.fromTimestampToUtcTimestamp(
425-
timestamp, ZoneId.systemDefault().normalized())
426-
}
424+
withResource(microseconds.castTo(DType.INT64)) { longVec =>
425+
withResource(longVec.castTo(DType.TIMESTAMP_MICROSECONDS)) { timestamp =>
426+
timestamp.incRefCount()
427427
}
428+
}
428429
}
429430
}
430431
}
@@ -444,6 +445,35 @@ object GpuOrcScan {
444445
}
445446
}
446447

448+
/**
449+
* Apply Spark's java.time timezone rules before ORC's millisecond rounding and overflow check.
450+
* Looking up the offset with a microsecond timestamp preserves the original floating-point
451+
* value: only the integral timezone delta is taken from the converted timestamp.
452+
*/
453+
private def convertOrcFloatingPointSeconds(seconds: ColumnView): ColumnVector = {
454+
withResource(Scalar.fromDouble(DateTimeConstants.MICROS_PER_SECOND)) { microsPerSecond =>
455+
withResource(seconds.mul(microsPerSecond, DType.FLOAT64)) { doubleMicros =>
456+
withResource(doubleMicros.castTo(DType.INT64)) { localMicros =>
457+
withResource(localMicros.castTo(DType.TIMESTAMP_MICROSECONDS)) { localTimestamp =>
458+
withResource(GpuTimeZoneDB.fromTimestampToUtcTimestamp(
459+
localTimestamp, ZoneId.systemDefault().normalized())) { utcTimestamp =>
460+
withResource(utcTimestamp.castTo(DType.INT64)) { utcMicros =>
461+
withResource(utcMicros.sub(localMicros)) { offsetMicros =>
462+
withResource(offsetMicros.castTo(DType.FLOAT64)) { doubleOffsetMicros =>
463+
withResource(doubleOffsetMicros.div(microsPerSecond, DType.FLOAT64)) {
464+
offsetSeconds =>
465+
seconds.add(offsetSeconds, DType.FLOAT64)
466+
}
467+
}
468+
}
469+
}
470+
}
471+
}
472+
}
473+
}
474+
}
475+
}
476+
447477
/**
448478
* Whether the type casting is supported by GPU ORC reading.
449479
*

sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuOrcTimezoneUtils.scala

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import java.util.Optional
2121

2222
import scala.collection.mutable.ArrayBuffer
2323

24-
import ai.rapids.cudf.{ColumnView, DType, Table}
24+
import ai.rapids.cudf.{ColumnView, DType, Scalar, Table}
2525
import com.nvidia.spark.rapids.Arm.withResource
2626
import com.nvidia.spark.rapids.RapidsPluginImplicits.AutoCloseableProducingSeq
2727
import com.nvidia.spark.rapids.jni.GpuTimeZoneDB
@@ -77,16 +77,17 @@ object GpuOrcTimezoneUtils {
7777
*/
7878
private def rebaseWithWriterTimezone(
7979
input: Table, writerTz: String, readerTz: String): Table = {
80+
val readerZone = ZoneId.of(readerTz, ZoneId.SHORT_IDS)
8081
withResource(input) { _ =>
8182
withResource(GpuTimeZoneDB.buildOrcTimezoneContext(writerTz, readerTz)) { tzCtx =>
8283
val newColumns = (0 until input.getNumberOfColumns).safeMap { colIdx =>
8384
val col = input.getColumn(colIdx)
8485
val dType = col.getType
8586
if (dType.hasTimeResolution) {
86-
GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)
87+
convertOrcTimestamp(col, tzCtx, readerZone)
8788
} else if (dType == DType.LIST || dType == DType.STRUCT) {
8889
withResource(new ArrayBuffer[ColumnView]) { toClose =>
89-
val rebased = rebaseNestedWithWriterTimezone(col, tzCtx, toClose)
90+
val rebased = rebaseNestedWithWriterTimezone(col, tzCtx, readerZone, toClose)
9091
if (rebased eq col) {
9192
col.incRefCount()
9293
} else {
@@ -105,18 +106,61 @@ object GpuOrcTimezoneUtils {
105106
}
106107
}
107108

109+
/**
110+
* Match the full Spark ORC timestamp path. Apache ORC uses java.util.TimeZone while decoding,
111+
* but Spark materializes the resulting java.sql.Timestamp using java.time rules. Those rule
112+
* sets can differ for historical and projected timestamps.
113+
*/
114+
private def convertOrcTimestamp(
115+
col: ColumnView,
116+
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
117+
readerZone: ZoneId): ai.rapids.cudf.ColumnVector = {
118+
withResource(GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)) { orcTimestamp =>
119+
val firstTransitionUs = tzCtx.getReaderFirstTransitionUs
120+
if (firstTransitionUs == Long.MinValue) {
121+
orcTimestamp.incRefCount()
122+
} else {
123+
withResource(GpuTimeZoneDB.convertOrcFromUtc(orcTimestamp, tzCtx)) { utilUtc =>
124+
withResource(GpuTimeZoneDB.fromTimestampToUtcTimestamp(
125+
orcTimestamp, readerZone.normalized())) { zoneUtc =>
126+
withResource(orcTimestamp.castTo(DType.INT64)) { orcMicros =>
127+
withResource(utilUtc.castTo(DType.INT64)) { utilMicros =>
128+
withResource(zoneUtc.castTo(DType.INT64)) { zoneMicros =>
129+
withResource(zoneMicros.sub(utilMicros)) { ruleCorrection =>
130+
withResource(orcMicros.add(ruleCorrection)) { corrected =>
131+
withResource(corrected.castTo(DType.TIMESTAMP_MICROSECONDS)) {
132+
correctedTimestamp =>
133+
withResource(Scalar.timestampFromLong(
134+
DType.TIMESTAMP_MICROSECONDS, firstTransitionUs)) { firstTransition =>
135+
withResource(orcTimestamp.lessThan(firstTransition)) { needsCorrection =>
136+
needsCorrection.ifElse(correctedTimestamp, orcTimestamp)
137+
}
138+
}
139+
}
140+
}
141+
}
142+
}
143+
}
144+
}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
108151
private def rebaseNestedWithWriterTimezone(
109152
col: ColumnView,
110153
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
154+
readerZone: ZoneId,
111155
toClose: ArrayBuffer[ColumnView]): ColumnView = {
112156
val addToClose = (v: ColumnView) => { toClose += v; v }
113157
val dType = col.getType
114158

115159
if (dType.hasTimeResolution) {
116-
GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)
160+
convertOrcTimestamp(col, tzCtx, readerZone)
117161
} else if (dType == DType.LIST) {
118162
val child = addToClose(col.getChildColumnView(0))
119-
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, toClose)
163+
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, readerZone, toClose)
120164
if (newChild ne child) {
121165
col.replaceListChild(addToClose(newChild))
122166
} else {
@@ -125,7 +169,7 @@ object GpuOrcTimezoneUtils {
125169
} else if (dType == DType.STRUCT) {
126170
val newViews = (0 until col.getNumChildren).map { i =>
127171
val child = addToClose(col.getChildColumnView(i))
128-
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, toClose)
172+
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, readerZone, toClose)
129173
if (newChild ne child) addToClose(newChild)
130174
newChild
131175
}

0 commit comments

Comments
 (0)