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

JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-spark-connector.git


The following commit(s) were added to refs/heads/master by this push:
     new 3a7c98b  [Fix] Recover DATETIME wall clock from Arrow timezone (#377)
3a7c98b is described below

commit 3a7c98b416c5ca7982421405c42f49a602d9b662
Author: Zack <[email protected]>
AuthorDate: Wed Sep 2 22:24:17 2026 -0400

    [Fix] Recover DATETIME wall clock from Arrow timezone (#377)
    
    Closes #376.
    
    Doris DATETIME and DATETIMEV2 store wall-clock values without timezone 
semantics. At times, Doris BE nodes can return them through timezone-aware 
Arrow vectors.
    
    RowBatch treated these values like TIMESTAMPTZ instants. When the Arrow 
timezone differed from the Spark JVM timezone, the displayed clock shifted.
    
    For example, Doris 2026-08-24 10:00:09 could become 2026-08-24 02:00:09 
when Spark ran in UTC but the BE configured tz was Shanghai/UTC+8.
    
    #366 supports Arrow timestamps without timezone metadata. It does not 
recover wall-clock values when older backends include this metadata.
---
 .../apache/doris/spark/client/read/RowBatch.java   |  93 ++++++++++-----
 .../doris/spark/client/read/RowBatchTest.java      | 128 +++++++++++++++++++++
 .../doris/spark/sql/Doris2DorisE2ECase.scala       |   8 +-
 .../apache/doris/spark/sql/DorisReaderITCase.scala |  18 +--
 4 files changed, 200 insertions(+), 47 deletions(-)

diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/RowBatch.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/RowBatch.java
index a9d30a5..3d56054 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/RowBatch.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/RowBatch.java
@@ -71,11 +71,13 @@ import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
 import java.time.temporal.ChronoField;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.TreeMap;
 
 /**
  * row batch data container.
@@ -83,6 +85,9 @@ import java.util.Objects;
 public class RowBatch implements Serializable {
     private static final Logger logger = 
LoggerFactory.getLogger(RowBatch.class);
     private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
+    private static final String DORIS_DEFAULT_TIME_ZONE = "Asia/Shanghai";
+    private static final String UTC_TIME_ZONE = "UTC";
+    private static final Map<String, String> DORIS_TIME_ZONE_ALIASES = 
buildDorisTimeZoneAliases();
 
     private static final DateTimeFormatter DATE_TIME_FORMATTER = new 
DateTimeFormatterBuilder()
             .appendPattern("yyyy-MM-dd HH:mm:ss")
@@ -424,42 +429,30 @@ public class RowBatch implements Serializable {
                         break;
                     case "DATETIME":
                     case "DATETIMEV2":
-                    case "TIMESTAMPTZ":
-
                         if (mt.equals(MinorType.VARCHAR)) {
-                            VarCharVector varCharVector = (VarCharVector) 
curFieldVector;
+                            convertVarcharDateTime((VarCharVector) 
curFieldVector);
+                        } else if (curFieldVector instanceof TimeStampVector) {
+                            TimeStampVector timeStampVector = 
(TimeStampVector) curFieldVector;
                             for (int rowIndex = 0; rowIndex < 
rowCountInOneBatch; rowIndex++) {
-                                if (varCharVector.isNull(rowIndex)) {
-                                    addValueToRow(rowIndex, null);
-                                    continue;
-                                }
-                                String stringValue = completeMilliseconds(new 
String(varCharVector.get(rowIndex),
-                                        StandardCharsets.UTF_8));
-                                LocalDateTime dateTime = 
LocalDateTime.parse(stringValue, dateTimeV2Formatter);
-                                if (datetimeJava8ApiEnabled) {
-                                    Instant instant = 
dateTime.atZone(DEFAULT_ZONE_ID).toInstant();
-                                    addValueToRow(rowIndex, instant);
-                                } else {
-                                    addValueToRow(rowIndex, 
Timestamp.valueOf(dateTime));
-                                }
+                                addDateTimeValue(rowIndex, 
getDateTime(rowIndex, timeStampVector));
                             }
+                        } else {
+                            String errMsg = String.format("Unsupported type 
for DATETIMEV2, minorType %s, class is %s",
+                                    mt.name(), curFieldVector.getClass());
+                            throw new 
java.lang.IllegalArgumentException(errMsg);
+                        }
+                        break;
+                    case "TIMESTAMPTZ":
+                        if (mt.equals(MinorType.VARCHAR)) {
+                            convertVarcharDateTime((VarCharVector) 
curFieldVector);
                         } else if (curFieldVector instanceof TimeStampVector) {
                             TimeStampVector timeStampVector = 
(TimeStampVector) curFieldVector;
                             for (int rowIndex = 0; rowIndex < 
rowCountInOneBatch; rowIndex++) {
-                                if (timeStampVector.isNull(rowIndex)) {
-                                    addValueToRow(rowIndex, null);
-                                    continue;
-                                }
-                                LocalDateTime dateTime = getDateTime(rowIndex, 
timeStampVector);
-                                if (datetimeJava8ApiEnabled) {
-                                    Instant instant = 
dateTime.atZone(DEFAULT_ZONE_ID).toInstant();
-                                    addValueToRow(rowIndex, instant);
-                                } else {
-                                    addValueToRow(rowIndex, 
Timestamp.valueOf(dateTime));
-                                }
+                                addDateTimeValue(rowIndex,
+                                        getDateTime(rowIndex, timeStampVector, 
DEFAULT_ZONE_ID));
                             }
                         } else {
-                            String errMsg = String.format("Unsupported type 
for DATETIMEV2, minorType %s, class is %s",
+                            String errMsg = String.format("Unsupported type 
for TIMESTAMPTZ, minorType %s, class is %s",
                                     mt.name(), curFieldVector.getClass());
                             throw new 
java.lang.IllegalArgumentException(errMsg);
                         }
@@ -597,7 +590,35 @@ public class RowBatch implements Serializable {
         }
     }
 
+    private void convertVarcharDateTime(VarCharVector varCharVector) {
+        for (int rowIndex = 0; rowIndex < rowCountInOneBatch; rowIndex++) {
+            if (varCharVector.isNull(rowIndex)) {
+                addDateTimeValue(rowIndex, null);
+                continue;
+            }
+            String stringValue = completeMilliseconds(new 
String(varCharVector.get(rowIndex),
+                    StandardCharsets.UTF_8));
+            addDateTimeValue(rowIndex, LocalDateTime.parse(stringValue, 
dateTimeV2Formatter));
+        }
+    }
+
+    private void addDateTimeValue(int rowIndex, LocalDateTime dateTime) {
+        if (dateTime == null) {
+            addValueToRow(rowIndex, null);
+            return;
+        }
+        if (datetimeJava8ApiEnabled) {
+            addValueToRow(rowIndex, 
dateTime.atZone(DEFAULT_ZONE_ID).toInstant());
+        } else {
+            addValueToRow(rowIndex, Timestamp.valueOf(dateTime));
+        }
+    }
+
     public LocalDateTime getDateTime(int rowIndex, FieldVector fieldVector) {
+        return getDateTime(rowIndex, fieldVector, null);
+    }
+
+    public LocalDateTime getDateTime(int rowIndex, FieldVector fieldVector, 
ZoneId zoneIdOverride) {
         TimeStampVector vector = (TimeStampVector) fieldVector;
         if (vector.isNull(rowIndex)) {
             return null;
@@ -606,7 +627,21 @@ public class RowBatch implements Serializable {
         if (timestampType.getTimezone() == null) {
             return (LocalDateTime) vector.getObject(rowIndex);
         }
-        return longToLocalDateTime(vector.get(rowIndex), 
timestampType.getUnit(), DEFAULT_ZONE_ID);
+        ZoneId zoneId = zoneIdOverride == null
+                ? ZoneId.of(timestampType.getTimezone(), 
DORIS_TIME_ZONE_ALIASES)
+                : zoneIdOverride;
+        return longToLocalDateTime(vector.get(rowIndex), 
timestampType.getUnit(), zoneId);
+    }
+
+    private static Map<String, String> buildDorisTimeZoneAliases() {
+        Map<String, String> aliases = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        aliases.putAll(ZoneId.SHORT_IDS);
+        // Keep aliases consistent with Doris TimeUtils.timeZoneAliasMap.
+        aliases.put("CST", DORIS_DEFAULT_TIME_ZONE);
+        aliases.put("PRC", DORIS_DEFAULT_TIME_ZONE);
+        aliases.put(UTC_TIME_ZONE, UTC_TIME_ZONE);
+        aliases.put("GMT", UTC_TIME_ZONE);
+        return Collections.unmodifiableMap(aliases);
     }
 
     public static String completeMilliseconds(String stringValue) {
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/RowBatchTest.java
 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/RowBatchTest.java
index 1d2b5bb..b115206 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/RowBatchTest.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/RowBatchTest.java
@@ -1328,6 +1328,134 @@ public class RowBatchTest {
         Assert.assertEquals(secInstant, instantRow.get(2));
     }
 
+    @Test
+    public void testDatetimeTzVector() throws IOException, DorisException {
+        ImmutableList<Field> fields = ImmutableList.of(
+                new Field("k0", FieldType.nullable(
+                        new ArrowType.Timestamp(TimeUnit.MICROSECOND, 
"+08:00")), null),
+                new Field("k1", FieldType.nullable(
+                        new ArrowType.Timestamp(TimeUnit.MILLISECOND, "CST")), 
null),
+                new Field("k2", FieldType.nullable(
+                        new ArrowType.Timestamp(TimeUnit.SECOND, "EST")), 
null));
+        VectorSchemaRoot root = VectorSchemaRoot.create(
+                new org.apache.arrow.vector.types.pojo.Schema(fields, null),
+                new RootAllocator(Integer.MAX_VALUE));
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        ArrowStreamWriter writer = new ArrowStreamWriter(
+                root, new DictionaryProvider.MapDictionaryProvider(), 
outputStream);
+
+        writer.start();
+        root.setRowCount(1);
+        TimeStampMicroTZVector microVector = (TimeStampMicroTZVector) 
root.getVector("k0");
+        microVector.allocateNew(1);
+        microVector.setSafe(0, 1721892143586123L);
+        microVector.setValueCount(1);
+        TimeStampMilliTZVector milliVector = (TimeStampMilliTZVector) 
root.getVector("k1");
+        milliVector.allocateNew(1);
+        milliVector.setSafe(0, 1721892143586L);
+        milliVector.setValueCount(1);
+        TimeStampSecTZVector secVector = (TimeStampSecTZVector) 
root.getVector("k2");
+        secVector.allocateNew(1);
+        secVector.setSafe(0, 1721892143L);
+        secVector.setValueCount(1);
+        writer.writeBatch();
+        writer.end();
+        writer.close();
+
+        TStatus status = new TStatus();
+        status.setStatusCode(TStatusCode.OK);
+        TScanBatchResult result = new TScanBatchResult();
+        result.setStatus(status);
+        result.setEos(false);
+        result.setRows(outputStream.toByteArray());
+        Schema schema = MAPPER.readValue(
+                "{\"properties\":["
+                        + 
"{\"type\":\"DATETIME\",\"name\":\"k0\",\"comment\":\"\"},"
+                        + 
"{\"type\":\"DATETIMEV2\",\"name\":\"k1\",\"comment\":\"\"},"
+                        + 
"{\"type\":\"DATETIMEV2\",\"name\":\"k2\",\"comment\":\"\"}],\"status\":200}",
+                Schema.class);
+        Instant microInstant = Instant.ofEpochSecond(1721892143L, 586123000L);
+        Instant milliInstant = Instant.ofEpochSecond(1721892143L, 586000000L);
+        Instant secInstant = Instant.ofEpochSecond(1721892143L);
+        ZoneId arrowTz = ZoneId.of("+08:00");
+        Timestamp microTimestamp = 
Timestamp.valueOf(LocalDateTime.ofInstant(microInstant, arrowTz));
+        Timestamp milliTimestamp = Timestamp.valueOf(
+                LocalDateTime.ofInstant(milliInstant, 
ZoneId.of("Asia/Shanghai")));
+        Timestamp secTimestamp = Timestamp.valueOf(
+                LocalDateTime.ofInstant(secInstant, ZoneId.of("EST", 
ZoneId.SHORT_IDS)));
+
+        List<Object> timestampRow = new RowBatch(result, schema, false).next();
+        Assert.assertEquals(microTimestamp, timestampRow.get(0));
+        Assert.assertEquals(milliTimestamp, timestampRow.get(1));
+        Assert.assertEquals(secTimestamp, timestampRow.get(2));
+
+        List<Object> instantRow = new RowBatch(result, schema, true).next();
+        
Assert.assertEquals(microTimestamp.toLocalDateTime().atZone(ZoneId.systemDefault()).toInstant(),
+                instantRow.get(0));
+        
Assert.assertEquals(milliTimestamp.toLocalDateTime().atZone(ZoneId.systemDefault()).toInstant(),
+                instantRow.get(1));
+        
Assert.assertEquals(secTimestamp.toLocalDateTime().atZone(ZoneId.systemDefault()).toInstant(),
+                instantRow.get(2));
+    }
+
+    @Test
+    public void testTimestampTzAndDatetimeSameArrowBytes() throws IOException, 
DorisException {
+        Instant milliInstant = Instant.ofEpochMilli(1721892143586L);
+        ZoneOffset systemOffset = 
ZoneId.systemDefault().getRules().getOffset(milliInstant);
+        String arrowTimezone = ZoneOffset.ofHours(8).equals(systemOffset) ? 
"+00:00" : "+08:00";
+        ZoneId arrowTz = ZoneId.of(arrowTimezone);
+        ImmutableList<Field> fields = ImmutableList.of(
+                new Field("k0", FieldType.nullable(
+                        new ArrowType.Timestamp(TimeUnit.MILLISECOND, 
arrowTimezone)), null),
+                new Field("k1", FieldType.nullable(
+                        new ArrowType.Timestamp(TimeUnit.MILLISECOND, 
arrowTimezone)), null));
+        VectorSchemaRoot root = VectorSchemaRoot.create(
+                new org.apache.arrow.vector.types.pojo.Schema(fields, null),
+                new RootAllocator(Integer.MAX_VALUE));
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        ArrowStreamWriter writer = new ArrowStreamWriter(
+                root, new DictionaryProvider.MapDictionaryProvider(), 
outputStream);
+
+        writer.start();
+        root.setRowCount(1);
+        TimeStampMilliTZVector timestamptzVector = (TimeStampMilliTZVector) 
root.getVector("k0");
+        timestamptzVector.allocateNew(1);
+        timestamptzVector.setSafe(0, 1721892143586L);
+        timestamptzVector.setValueCount(1);
+        TimeStampMilliTZVector datetimeVector = (TimeStampMilliTZVector) 
root.getVector("k1");
+        datetimeVector.allocateNew(1);
+        datetimeVector.setSafe(0, 1721892143586L);
+        datetimeVector.setValueCount(1);
+        writer.writeBatch();
+        writer.end();
+        writer.close();
+
+        TStatus status = new TStatus();
+        status.setStatusCode(TStatusCode.OK);
+        TScanBatchResult result = new TScanBatchResult();
+        result.setStatus(status);
+        result.setEos(false);
+        result.setRows(outputStream.toByteArray());
+        Schema schema = MAPPER.readValue(
+                "{\"properties\":["
+                        + 
"{\"type\":\"TIMESTAMPTZ\",\"name\":\"k0\",\"comment\":\"\"},"
+                        + 
"{\"type\":\"DATETIME\",\"name\":\"k1\",\"comment\":\"\"}],\"status\":200}",
+                Schema.class);
+        Timestamp datetimeTimestamp = Timestamp.valueOf(
+                LocalDateTime.ofInstant(milliInstant, arrowTz));
+
+        List<Object> timestampRow = new RowBatch(result, schema, false).next();
+        Assert.assertNotEquals(systemOffset, 
arrowTz.getRules().getOffset(milliInstant));
+        Assert.assertEquals(Timestamp.from(milliInstant), timestampRow.get(0));
+        Assert.assertEquals(datetimeTimestamp, timestampRow.get(1));
+        Assert.assertNotEquals(timestampRow.get(0), timestampRow.get(1));
+
+        List<Object> instantRow = new RowBatch(result, schema, true).next();
+        Assert.assertEquals(milliInstant, instantRow.get(0));
+        
Assert.assertEquals(datetimeTimestamp.toLocalDateTime().atZone(ZoneId.systemDefault()).toInstant(),
+                instantRow.get(1));
+    }
+
     @Test
     public void testLongToLocalDateTimeUsesDeclaredUnit() {
         ZoneId utc = ZoneId.of("UTC");
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/Doris2DorisE2ECase.scala
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/Doris2DorisE2ECase.scala
index 76ecccf..26c9941 100644
--- 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/Doris2DorisE2ECase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/Doris2DorisE2ECase.scala
@@ -97,11 +97,9 @@ class Doris2DorisE2ECase(readMode: String, flightSqlPort: 
Int) extends AbstractC
         |""".stripMargin)
     session.stop()
 
-    // TODO: Remove the legacy Scanner expectations after Doris returns 
DATETIME as a
-    // timezone-naive Arrow timestamp.
-    val datetime1 = if (readMode == "arrow") "2025-03-11T12:34:56" else 
"2025-03-11T04:34:56"
-    val datetime2 = if (readMode == "arrow") "2024-12-25T23:59:59" else 
"2024-12-25T15:59:59"
-    val datetime3 = if (readMode == "arrow") "2023-06-15T08:00" else 
"2023-06-15T00:00"
+    val datetime1 = "2025-03-11T12:34:56"
+    val datetime2 = "2024-12-25T23:59:59"
+    val datetime3 = "2023-06-15T08:00"
     val excepted =
       util.Arrays.asList(
         
"1,true,127,32767,2147483647,9223372036854775807,170141183460469231731687303715884105727,3.14,2.71828,12345.6789,2025-03-11,"
 + datetime1 + ",A,Hello, Doris!,This is a string,[\"Alice\", 
\"Bob\"],{\"key1\":\"value1\", \"key2\":\"value2\"},{\"name\":\"Tom\", 
\"age\":30},{\"key\":\"value\"},{\"data\":123,\"type\":\"variant\"}",
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisReaderITCase.scala
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisReaderITCase.scala
index 5653dfe..86bceae 100644
--- 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisReaderITCase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisReaderITCase.scala
@@ -235,9 +235,9 @@ class DorisReaderITCase(readMode: String, flightSqlPort: 
Int) extends AbstractCo
           |select * from test_source order by id
           |""".stripMargin).collect()
 
-      val expectedTimestamp1 = if (readMode == "thrift") "2025-03-11 04:34:56" 
else "2025-03-11 12:34:56"
-      val expectedTimestamp2 = if (readMode == "thrift") "2024-12-25 15:59:59" 
else "2024-12-25 23:59:59"
-      val expectedTimestamp3 = if (readMode == "thrift") "2023-06-15 00:00:00" 
else "2023-06-15 08:00:00"
+      val expectedTimestamp1 = "2025-03-11 12:34:56"
+      val expectedTimestamp2 = "2024-12-25 23:59:59"
+      val expectedTimestamp3 = "2023-06-15 08:00:00"
 
       val expectedData = Array(
         Row(1, true, 127, 32767, 2147483647, 9223372036854775807L, 
"170141183460469231731687303715884105727",
@@ -416,11 +416,7 @@ class DorisReaderITCase(readMode: String, flightSqlPort: 
Int) extends AbstractCo
           |select id,c10,c11 from test_source where c10 = '2025-03-11' and c13 
like 'Hello%'
           |""".stripMargin).collect()
 
-      val expectedDateFilter = if (readMode == "thrift") {
-        "List([1,2025-03-11,2025-03-11 04:34:56.0])"
-      } else {
-        "List([1,2025-03-11,2025-03-11 12:34:56.0])"
-      }
+      val expectedDateFilter = "List([1,2025-03-11,2025-03-11 12:34:56.0])"
       assert(expectedDateFilter.equals(dateFilter.toList.toString()))
 
       val datetimeFilter = session.sql(
@@ -428,11 +424,7 @@ class DorisReaderITCase(readMode: String, flightSqlPort: 
Int) extends AbstractCo
           |select id,c11,c12 from test_source where c10 < '2025-03-11' and c11 
= '2024-12-25 23:59:59'
           |""".stripMargin).collect()
 
-      val expectedDatetimeFilter = if (readMode == "thrift") {
-        "List([2,2024-12-25 15:59:59.0,B])"
-      } else {
-        "List([2,2024-12-25 23:59:59.0,B])"
-      }
+      val expectedDatetimeFilter = "List([2,2024-12-25 23:59:59.0,B])"
       assert(expectedDatetimeFilter.equals(datetimeFilter.toList.toString()))
 
       val stringFilter = session.sql(


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

Reply via email to