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

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


The following commit(s) were added to refs/heads/master by this push:
     new e4c2dc261a7 [fix](be) Reject Arrow timestamps outside the DATETIMEV2 
and TIMESTAMPTZ ranges (#69697)
e4c2dc261a7 is described below

commit e4c2dc261a7a8f1dca9286f2509a4e4fca062ce7
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Nov 3 10:21:39 2026 +0000

    [fix](be) Reject Arrow timestamps outside the DATETIMEV2 and TIMESTAMPTZ 
ranges (#69697)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69581 #68516
    
    Problem Summary:
    
    **In short.** An Arrow timestamp whose civil year is outside 0000..9999
    is read into DATETIMEV2 or TIMESTAMPTZ as a different, valid-looking
    value: timestamp(us) 2005980768000000000, which is 65537-01-01, reads as
    a non-null 0001-01-01. This PR checks the year before the value is
    narrowed into Doris' 16-bit year field, as the ORC path already does,
    and fails the read otherwise. The review of #69581 found it on the Lance
    path; the code is shared by every Arrow reader.
    
    **Background.** `DataTypeDateTimeV2SerDe::read_column_from_arrow` and
    `DataTypeTimeStampTzSerDe::read_column_from_arrow` decode Arrow
    timestamps for ADBC, Arrow stream load, paimon-rust, remote Doris and,
    once #69581 lands, Lance, nested leaves included. Both convert epoch
    seconds with `DateV2Value::from_unixtime()`, which passes cctz's 64-bit
    year to `unchecked_set_time(uint16_t year, ...)` ("there's no overflow
    check since it's hot path"). #68516 added a check before this narrowing
    to the ORC path (`orc_timestamp_to_datetime()`).
    
    **The problem, and what it cost.**
    
    | Arrow timestamp(us) | DATETIMEV2 before | TIMESTAMPTZ before |
    |---|---|---|
    | 253402300800000000 (10000-01-01) | stored as year 10000, outside the
    type's range | error |
    | 2005980768000000000 (65537-01-01) | 0001-01-01 00:00:00 | 0001-01-01
    00:00:00+00:00 |
    | a year before 0000 | wraps to 655xx or back into 0000..9999 | error,
    or a wrapped valid year |
    
    In the wrapped cases the read succeeds with a wrong value, and nothing
    in the result tells the user.
    
    **How this PR fixes it.**
    
    - DATETIMEV2: convert to civil time in the value's time zone (UTC for a
    timezone-naive Arrow timestamp, the session time zone for a zoned one)
    and reject a year outside 0000..9999 before packing, like
    `orc_timestamp_to_datetime()`. After packing, `is_valid_date()` is
    checked too: cctz counts year 0 as a leap year and Doris does not
    (`is_leap(0)` is false), so the civil date 0000-02-29 passes the year
    check, and before this PR it was stored as an invalid date.
    - TIMESTAMPTZ is stored in UTC, so
    `append_timestamptz_from_utc_epoch_micros` checks the instant against
    0000-01-01 00:00:00 UTC .. 9999-12-31 23:59:59.999999 UTC before
    converting. Its runtime `is_valid_date()` check after packing stays, for
    the same leap day: timestamp(us) -62162121600000000 (0000-02-29 UTC) is
    inside the range but packs into an invalid value. The range message now
    says 0000-9999, the range `is_valid_date()` accepts.
    - Both fail the read with DataQualityError, as the other range errors of
    these readers do. NULL slots are still skipped before conversion.
    - Not changed: the Parquet INT64 / INT96 timestamp paths validate the
    UTC range before packing (`parquet_timestamp_micros()`,
    `parquet_int96_timestamp_micros()`), so they could not wrap. Arrow reads
    into the deprecated DATE / DATETIME (v1) types go through
    `VecDateTimeValue::from_unixtime()` with the same unchecked narrowing;
    they are left as they are. `orc_timestamp_to_datetime()` checks only the
    year, so an ORC timestamp on 0000-02-29 is still stored as an invalid
    date; it is not changed here.
    
    **Merge order: after #69581.** A NULL STRUCT, LIST or MAP row may hide
    any child value, an out-of-range timestamp included, because Arrow
    leaves the payload under a NULL parent unspecified. #69581 masks those
    child slots in the shared nested SerDes
    (`mask_arrow_children_hidden_by_null_parents`, df95b41a331) before any
    leaf SerDe reads them. Without it, the checks here fail a batch whose
    visible values are valid: a STRUCT with rows `[NULL, {ts: 0}]` whose
    NULL row carries 2005980768000000000 reads with DataQualityError on this
    PR alone, and reads both rows on a tree with both PRs.
    
    **Results.**
    
    | Arrow timestamp(us) | DATETIMEV2 after | TIMESTAMPTZ after |
    |---|---|---|
    | 253402300799999999 (9999-12-31 23:59:59.999999) | read | read |
    | -62167219200000000 (0000-01-01) | read | read |
    | -62162121600000000 (0000-02-29, not a Doris date) | DataQualityError |
    DataQualityError |
    | 10000-01-01, 65537-01-01, -62167219200000001 (year -1) |
    DataQualityError | DataQualityError |
    | zoned, at +08:00: 253402271999999999 / 253402272000000000 | 9999-12-31
    23:59:59.999999 / DataQualityError | |
---
 .../data_type_serde/data_type_datetimev2_serde.cpp | 24 ++++++-
 .../data_type_timestamptz_serde.cpp                | 21 +++++-
 .../data_type_serde_datetime_v2_test.cpp           | 77 ++++++++++++++++++++++
 .../data_type_serde_timestamptz_test.cpp           | 39 +++++++++++
 4 files changed, 157 insertions(+), 4 deletions(-)

diff --git a/be/src/core/data_type_serde/data_type_datetimev2_serde.cpp 
b/be/src/core/data_type_serde/data_type_datetimev2_serde.cpp
index 36fc722e60d..9df0a8e060a 100644
--- a/be/src/core/data_type_serde/data_type_datetimev2_serde.cpp
+++ b/be/src/core/data_type_serde/data_type_datetimev2_serde.cpp
@@ -723,7 +723,29 @@ Status 
DataTypeDateTimeV2SerDe::read_column_from_arrow(IColumn& column,
                 --seconds;
                 remainder += divisor;
             }
-            v.from_unixtime(seconds, real_ctz);
+            // from_unixtime() narrows the cctz year to uint16_t without a 
check, so an instant
+            // whose civil year in real_ctz is outside 0000..9999 would read 
as another value:
+            // timestamp(us) 2005980768000000000 (65537-01-01) as 0001-01-01, 
and the years
+            // 10000..65535 as dates DATETIMEV2 cannot hold. Check the civil 
year before packing,
+            // as orc_timestamp_to_datetime() does for ORC.
+            const auto civil = cctz::convert(
+                    cctz::time_point<cctz::seconds>(cctz::seconds(seconds)), 
real_ctz);
+            if (civil.year() < 0 || civil.year() > 9999) {
+                return Status::DataQualityError(
+                        "Arrow timestamp {} of type {} is outside the 
DATETIMEV2 range", date_value,
+                        type->ToString());
+            }
+            v.unchecked_set_time(
+                    static_cast<uint16_t>(civil.year()), 
static_cast<uint8_t>(civil.month()),
+                    static_cast<uint8_t>(civil.day()), 
static_cast<uint8_t>(civil.hour()),
+                    static_cast<uint8_t>(civil.minute()), 
static_cast<uint16_t>(civil.second()), 0);
+            // cctz counts year 0 as a leap year and Doris does not 
(is_leap(0) is false), so the
+            // civil date 0000-02-29 passes the year check but is not a 
DATETIMEV2 value.
+            if (!v.is_valid_date()) {
+                return Status::DataQualityError(
+                        "Arrow timestamp {} of type {} is not a valid 
DATETIMEV2 value", date_value,
+                        type->ToString());
+            }
             // Get the fractional part.
             // add 0 on the right to make it 6 digits. DateTimeV2Value 
microsecond is 6 digits,
             // the scale decides to keep the first few digits, so the valid 
digits should be kept at the front.
diff --git a/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp 
b/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
index 52254438d0b..a1cd4ec292b 100644
--- a/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
+++ b/be/src/core/data_type_serde/data_type_timestamptz_serde.cpp
@@ -78,8 +78,21 @@ Status decode_timestamp_tz_orc_values(IColumn& nested_column,
 Status append_timestamptz_from_utc_epoch_micros(ColumnTimeStampTz::Container& 
data,
                                                 int64_t timestamp_micros) {
     static constexpr int64_t MICROS_PER_SECOND = 1000000;
+    // 0000-01-01 00:00:00 UTC and 9999-12-31 23:59:59.999999 UTC, the 
TIMESTAMPTZ range.
+    static constexpr int64_t MIN_TIMESTAMPTZ_MICROS = -62167219200000000LL;
+    static constexpr int64_t MAX_TIMESTAMPTZ_MICROS = 253402300799999999LL;
     static const auto UTC = cctz::utc_time_zone();
 
+    // from_unixtime() narrows the cctz year to uint16_t without a check, so 
an instant past year
+    // 65535 wraps back into 0000..9999 and would still pass is_valid_date() 
after packing:
+    // timestamp(us) 2005980768000000000 (65537-01-01) read as 0001-01-01. The 
Arrow reader passes
+    // any int64 here, so check the range before converting; Parquet values 
arrive inside it.
+    if (timestamp_micros < MIN_TIMESTAMPTZ_MICROS || timestamp_micros > 
MAX_TIMESTAMPTZ_MICROS) {
+        return Status::DataQualityError(
+                "Decoded TIMESTAMPTZ is outside the Doris 0000-9999 range: 
micros={}",
+                timestamp_micros);
+    }
+
     int64_t epoch_seconds = timestamp_micros / MICROS_PER_SECOND;
     int64_t micros_of_second = timestamp_micros % MICROS_PER_SECOND;
     if (micros_of_second < 0) {
@@ -90,10 +103,12 @@ Status 
append_timestamptz_from_utc_epoch_micros(ColumnTimeStampTz::Container& da
     TimestampTzValue timestamp_tz;
     timestamp_tz.from_unixtime(epoch_seconds, UTC);
     timestamp_tz.set_microsecond(static_cast<uint32_t>(micros_of_second));
+    // The range check does not make every instant inside it a Doris date: 
cctz counts year 0 as a
+    // leap year and Doris does not (is_leap(0) is false), so 0000-02-29 UTC, 
timestamp(us)
+    // -62162121600000000, passes the range and packs into an invalid value.
     if (!timestamp_tz.is_valid_date()) {
-        return Status::DataQualityError(
-                "Decoded TIMESTAMPTZ is outside the Doris 0001-9999 range: 
micros={}",
-                timestamp_micros);
+        return Status::DataQualityError("Decoded TIMESTAMPTZ is not a valid 
Doris date: micros={}",
+                                        timestamp_micros);
     }
     data.push_back(timestamp_tz);
     return Status::OK();
diff --git a/be/test/core/data_type_serde/data_type_serde_datetime_v2_test.cpp 
b/be/test/core/data_type_serde/data_type_serde_datetime_v2_test.cpp
index 51dec6fc83b..6f9d0c58151 100644
--- a/be/test/core/data_type_serde/data_type_serde_datetime_v2_test.cpp
+++ b/be/test/core/data_type_serde/data_type_serde_datetime_v2_test.cpp
@@ -31,10 +31,12 @@
 
 #include "core/assert_cast.h"
 #include "core/column/column.h"
+#include "core/column/column_array.h"
 #include "core/column/column_nullable.h"
 #include "core/data_type/common_data_type_serder_test.h"
 #include "core/data_type/common_data_type_test.h"
 #include "core/data_type/data_type.h"
+#include "core/data_type_serde/data_type_array_serde.h"
 #include "core/data_type_serde/data_type_datetimev2_serde.h"
 #include "core/data_type_serde/data_type_datev2_serde.h"
 #include "core/data_type_serde/data_type_nullable_serde.h"
@@ -589,4 +591,79 @@ TEST_F(DataTypeDateTimeV2SerDeTest, 
ArrowTimezoneNaiveTimestampIgnoresSessionTim
     EXPECT_EQ("1969-12-31 23:59:59.123456", 
column->get_data()[2].to_string(6));
 }
 
+// The civil year of an Arrow timestamp must be checked before it is narrowed 
into DATETIMEV2:
+// unchecked, 65537-01-01 read as 0001-01-01 and 10000-01-01 as a date 
DATETIMEV2 cannot hold. A
+// zoned timestamp is checked in the session time zone, after the shift.
+TEST_F(DataTypeDateTimeV2SerDeTest, 
RejectsArrowTimestampsOutsideDateTimeV2Range) {
+    const auto read = [](const std::shared_ptr<arrow::DataType>& type, int64_t 
value,
+                         const cctz::time_zone& timezone, std::string* result) 
{
+        arrow::TimestampBuilder builder(type, arrow::default_memory_pool());
+        EXPECT_TRUE(builder.Append(value).ok());
+        std::shared_ptr<arrow::Array> array;
+        EXPECT_TRUE(builder.Finish(&array).ok());
+        auto column = ColumnDateTimeV2::create();
+        auto status =
+                serde_datetime_v2_6->read_column_from_arrow(*column, 
array.get(), 0, 1, timezone);
+        if (status.ok()) {
+            *result = column->get_data()[0].to_string(6);
+        }
+        return status;
+    };
+    const auto naive = arrow::timestamp(arrow::TimeUnit::MICRO);
+    const auto utc = cctz::utc_time_zone();
+    std::string result;
+    ASSERT_TRUE(read(naive, 253402300799999999, utc, &result).ok());
+    EXPECT_EQ("9999-12-31 23:59:59.999999", result);
+    ASSERT_TRUE(read(naive, -62167219200000000, utc, &result).ok());
+    EXPECT_EQ("0000-01-01 00:00:00.000000", result);
+    // 10000-01-01, 65537-01-01 (read as 0001-01-01 before the check) and the 
last microsecond of
+    // year -1.
+    for (const int64_t value : {int64_t {253402300800000000}, int64_t 
{2005980768000000000},
+                                int64_t {-62167219200000001}}) {
+        SCOPED_TRACE(value);
+        const auto status = read(naive, value, utc, &result);
+        EXPECT_FALSE(status.ok());
+        EXPECT_NE(std::string::npos, status.to_string().find("is outside the 
DATETIMEV2 range"))
+                << status.to_string();
+    }
+    // 0000-02-29 is inside the year range, but year 0 is not a leap year in 
Doris.
+    const auto leap_day = read(naive, -62162121600000000, utc, &result);
+    EXPECT_FALSE(leap_day.ok());
+    EXPECT_NE(std::string::npos, leap_day.to_string().find("is not a valid 
DATETIMEV2 value"))
+            << leap_day.to_string();
+
+    // 9999-12-31 15:59:59.999999 UTC is 9999-12-31 23:59:59.999999 at +08:00, 
and one microsecond
+    // later is 10000-01-01 there.
+    cctz::time_zone plus_eight;
+    ASSERT_TRUE(TimezoneUtils::find_cctz_time_zone("+08:00", plus_eight));
+    const auto zoned = arrow::timestamp(arrow::TimeUnit::MICRO, "UTC");
+    ASSERT_TRUE(read(zoned, 253402271999999999, plus_eight, &result).ok());
+    EXPECT_EQ("9999-12-31 23:59:59.999999", result);
+    EXPECT_FALSE(read(zoned, 253402272000000000, plus_eight, &result).ok());
+}
+
+// A timestamp leaf of ARRAY reaches the same check through the element SerDe.
+TEST_F(DataTypeDateTimeV2SerDeTest, 
RejectsNestedArrowTimestampOutsideDateTimeV2Range) {
+    arrow::TimestampBuilder 
values_builder(arrow::timestamp(arrow::TimeUnit::MICRO),
+                                           arrow::default_memory_pool());
+    ASSERT_TRUE(values_builder.Append(2005980768000000000).ok());
+    std::shared_ptr<arrow::Array> values;
+    ASSERT_TRUE(values_builder.Finish(&values).ok());
+    arrow::Int32Builder offsets_builder;
+    ASSERT_TRUE(offsets_builder.AppendValues({0, 1}).ok());
+    std::shared_ptr<arrow::Array> offsets;
+    ASSERT_TRUE(offsets_builder.Finish(&offsets).ok());
+    auto list = arrow::ListArray::FromArrays(*offsets, *values);
+    ASSERT_TRUE(list.ok()) << list.status().ToString();
+
+    DataTypeArraySerDe 
serde(std::make_shared<DataTypeNullableSerDe>(serde_datetime_v2_6));
+    auto column = ColumnArray::create(
+            ColumnNullable::create(ColumnDateTimeV2::create(), 
ColumnUInt8::create()));
+    const auto status = serde.read_column_from_arrow(*column, 
list.ValueUnsafe().get(), 0, 1,
+                                                     cctz::utc_time_zone());
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(std::string::npos, status.to_string().find("is outside the 
DATETIMEV2 range"))
+            << status.to_string();
+}
+
 } // namespace doris
diff --git a/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp 
b/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
index 742715ef62e..9de6e14d898 100644
--- a/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
+++ b/be/test/core/data_type_serde/data_type_serde_timestamptz_test.cpp
@@ -395,4 +395,43 @@ TEST_F(DataTypeTimeStampTzSerDeTest, 
ArrowTimestampToTimestampTz) {
     read_timestamp(arrow::TimeUnit::NANO, -876543211, "Asia/Shanghai",
                    "1969-12-31 23:59:59.123456+00:00", "1970-01-01 
07:59:59.123456+08:00");
 }
+
+// An Arrow instant must be range-checked before it is narrowed into 
TIMESTAMPTZ: unchecked,
+// 65537-01-01 UTC wrapped back to 0001-01-01 and passed the validity check 
after packing.
+TEST_F(DataTypeTimeStampTzSerDeTest, 
RejectsArrowTimestampsOutsideTimestampTzRange) {
+    const auto read = [](int64_t micros, std::string* result) {
+        arrow::TimestampBuilder 
builder(arrow::timestamp(arrow::TimeUnit::MICRO, "UTC"),
+                                        arrow::default_memory_pool());
+        EXPECT_TRUE(builder.Append(micros).ok());
+        std::shared_ptr<arrow::Array> array;
+        EXPECT_TRUE(builder.Finish(&array).ok());
+        auto column = ColumnTimeStampTz::create();
+        auto status = serde_tz_6->read_column_from_arrow(*column, array.get(), 
0, 1,
+                                                         
cctz::utc_time_zone());
+        if (status.ok()) {
+            *result = column->get_data()[0].to_string(cctz::utc_time_zone(), 
6);
+        }
+        return status;
+    };
+    std::string result;
+    ASSERT_TRUE(read(253402300799999999, &result).ok());
+    EXPECT_EQ("9999-12-31 23:59:59.999999+00:00", result);
+    ASSERT_TRUE(read(-62167219200000000, &result).ok());
+    EXPECT_EQ("0000-01-01 00:00:00.000000+00:00", result);
+    // 10000-01-01, 65537-01-01 (read as 0001-01-01 before the check) and the 
last microsecond of
+    // year -1.
+    for (const int64_t micros : {int64_t {253402300800000000}, int64_t 
{2005980768000000000},
+                                 int64_t {-62167219200000001}}) {
+        SCOPED_TRACE(micros);
+        const auto status = read(micros, &result);
+        EXPECT_FALSE(status.ok());
+        EXPECT_NE(std::string::npos, status.to_string().find("outside the 
Doris 0000-9999 range"))
+                << status.to_string();
+    }
+    // 0000-02-29 UTC is inside the range, but year 0 is not a leap year in 
Doris.
+    const auto leap_day = read(-62162121600000000, &result);
+    EXPECT_FALSE(leap_day.ok());
+    EXPECT_NE(std::string::npos, leap_day.to_string().find("not a valid Doris 
date"))
+            << leap_day.to_string();
+}
 } // namespace doris


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

Reply via email to