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 b3403658e89 [fix](arrow) Reject invalid UTF-8 and out-of-range 
timestamps in Flight results (#69771)
b3403658e89 is described below

commit b3403658e89a7622f9e162619bc332a714110d94
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 07:06:37 2026 +0000

    [fix](arrow) Reject invalid UTF-8 and out-of-range timestamps in Flight 
results (#69771)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68596, #68604
    
    Problem Summary:
    
    #### In short
    
    This ports branch-4.x's #68596 and #68604 to master. An Arrow Flight
    result can carry values that Arrow clients cannot decode: a `STRING`
    value that is not valid UTF-8 (for example `unhex('84')`), and a
    `DATETIME` outside 0001-01-01..9999-12-31 (Doris's own range starts at
    `0000-01-01 00:00:00`). The Arrow writers accept both, so the BE
    publishes the batch and the failure surfaces later in the client:
    PyArrow rejects the string, and raises `OverflowError` when it converts
    the timestamp. This PR checks every batch in the Flight convertor before
    it is handed out and fails the query with a server-side error that names
    the column, the nested path and the row. It also makes a failed
    `_query_plan` scan of the Spark and Flink connectors report its error:
    the BE used to end such a scan as a complete result, so a rejected batch
    would have become silently missing rows.
    
    #### Background
    
    - `ArrowFlightArrowBlockConvertor`
    (`be/src/format/arrow/arrow_block_convertor.h`) builds the Arrow batches
    of an Arrow Flight result: `ArrowFlightBatchReader` uses it for Flight
    SQL and ADBC clients, which also covers a remote Doris catalog with
    `use_arrow_flight = true`, and the memory scratch sink uses it for the
    `_query_plan` scan API of the Spark and Flink Doris connectors.
    - The STRING serde appends the bytes of each value to an Arrow `utf8`
    (or `large_utf8`) builder without looking at them. Arrow requires these
    values to be valid UTF-8, but only `Array::ValidateFull()` checks it;
    building and publishing the array does not.
    - The memory scratch sink queues the batches that `get_next` of the BE
    Thrift scanner hands to those connectors, and ends the queue with a
    `nullptr` sentinel. Its `close()` put the sentinel whatever the
    execution status, and nothing has recorded a status in the queue since
    the non-pipeline engine was removed (#35803), so a fragment that failed
    (a scan error, a cancellation) ended the scan as if it were complete.
    - `DATETIME(p)` is published as `timestamp[s]`, `timestamp[ms]` or
    `timestamp[us]` by its scale. Arrow accepts any `int64` there, so a
    year-0 value such as `0000-12-31 00:00:00` (`-62135683200000000` us) is
    published as it is, while Python's `datetime`, which PyArrow converts
    to, only covers years 1..9999.
    
    #### The problem, and what it cost
    
    | Result value | Before this PR |
    |---|---|
    | `unhex('84')`, `unhex('C0AF')`, a surrogate or a code point above
    U+10FFFF, also inside an ARRAY / STRUCT / MAP key or value | Published
    as is; the client fails decoding the batch, after earlier batches were
    already delivered |
    | Two rows `unhex('C2')` and `unhex('A2')` | Published; each row is
    invalid UTF-8 on its own, though the concatenated buffer is valid |
    | `CAST('0000-12-31 00:00:00' AS DATETIME(6))`, or a zero date read from
    a table or an INT96 Parquet file | Published as `-62135683200000000`;
    PyArrow raises `OverflowError` converting it |
    | A `_query_plan` scan (Spark / Flink connector) whose fragment fails |
    `get_next` reports the end of the scan after the rows fetched so far;
    with the validation below, a table with one invalid UTF-8 row would read
    zero rows and no error |
    
    The query looks successful on the server, so neither the error nor the
    offending column shows up in Doris; the Flight client gets a failure
    that does not say which value caused it.
    
    #### How this PR fixes it
    
    - `ArrowBlockConvertor::convert_to_arrow` becomes virtual, and
    `ArrowFlightArrowBlockConvertor` overrides it: after the base
    conversion, it runs `ValidateFull()` on every column and returns
    `InvalidArgument("Invalid Arrow Flight result in column <n> ('<name>'):
    ...")` on failure. `ValidateFull()` checks each value, nested children
    included, so the row boundaries of the `C2` / `A2` case are respected.
    Binary columns keep their bytes; NULL values are skipped through the
    validity bitmap.
    - A new `FlightTimestampValidator` walks each column recursively (list
    values, map keys and values, struct fields), reads the actual timestamp
    unit, and checks each value against 0001-01-01..9999-12-31, also against
    the local calendar when the Arrow type has a time zone. Values under a
    NULL list / map / struct parent are not observable and are not checked.
    A value outside the range returns `InvalidArgument("Arrow Flight
    timestamp in column '<path>' at row <n> is outside the supported
    0001-9999 range (type: ...)")`, with the row counted in the block.
    - For a timestamp with a time zone, the local bounds are computed with
    the exact offsets (cctz, Python's zoneinfo) and with the minute-rounded
    offsets of pytz, which PyArrow uses for zone names when it is installed
    (`(offset + 30) // 60 * 60`), and the stricter bound wins. pytz turns
    America/Chicago's local mean time -05:50:36 into -05:51, so 0001-01-01
    05:50:36Z, local 00:00:00 for cctz, would be 0000-12-31 23:59:36 in
    PyArrow; the lower bound there is 05:51:00Z. In such zones up to 30
    seconds at the start of year 1 are rejected.
    - `MemoryScratchSinkLocalState::close` records a failed execution status
    in the result queue before it puts the sentinel, and
    `ResultQueueMgr::fetch_result` checks the queue status again when it
    takes the sentinel, because a fetch already waiting for data passed the
    first check before the failure. `get_next` then returns the error to the
    connector.
    - Of the Arrow convertors, only the Flight convertor changes. The
    ordinary Doris Arrow convertor (Python UDFs) and the table-format
    convertors (Parquet, Iceberg, Paimon writers) keep their current
    behavior; `DorisArrowBlockConvertor` still publishes the same values in
    the new tests.
    - The code is branch-4.2's final form of the two PRs; master's Flight
    convertor already overrides `init()` and `write_column()`, and the new
    override sits next to them.
    
    #### Results
    
    | Query over Arrow Flight | Before | After |
    |---|---|---|
    | `SELECT unhex(encoded) AS payload` with an invalid UTF-8 row | Batch
    published, client fails decoding | Query fails: `Invalid Arrow Flight
    result in column 1 ('payload'): Invalid: Invalid UTF8 sequence at string
    index 0` |
    | `SELECT array(unhex(encoded)) AS nested` ... | Batch published | `...
    ('nested'): Invalid: List child array invalid: Invalid: Invalid UTF8
    sequence at string index 0` |
    | `SELECT CAST(value AS DATETIME(6)) AS event_time` over `0000-12-31
    00:00:00` | Batch published, client fails converting | Query fails:
    `Arrow Flight timestamp in column 'event_time' at row 1 is outside the
    supported 0001-9999 range (type: timestamp[us])`; nested values report
    paths such as `nested[]`, `nested.key`, `nested.child` |
    | Remote Doris catalog over Flight reading a `0000-01-01 00:00:00`
    DATETIME | Value returned | `Arrow Flight timestamp in column
    'datetime_0' at row 1 is outside the supported 0001-9999 range (type:
    timestamp[s])` |
    | `_query_plan` scan through `open_scanner` / `get_next` over a table
    with an invalid UTF-8 row | 0 rows, then the end of the scan |
    `get_next` returns `[INVALID_ARGUMENT]Invalid Arrow Flight result in
    column 2 (''): Invalid: Invalid UTF8 sequence at string index 1` |
    | The same scan over a `0000-12-31 00:00:00` DATETIME | 0 rows, then the
    end of the scan | `Arrow Flight timestamp in column '' at row 2 is
    outside the supported 0001-9999 range (type: timestamp[us])` |
    | Valid Unicode text, NULLs, binary bytes, `0001-01-01`, `9999-12-31
    23:59:59.999999` | Returned | Returned unchanged |
    
    Behavior change: a Flight query (Flight SQL, ADBC, a remote Doris
    catalog with `use_arrow_flight = true`, or a `_query_plan` read of the
    Spark / Flink connector) whose result contains a `STRING` value that is
    not valid UTF-8, or a `DATETIME` outside years 0001..9999, now fails
    with the error above instead of returning data. A remote Doris catalog
    that used to round-trip the zero date now reports this error; the three
    remote Doris suites that inserted `0000-01-01 00:00:00` as their minimum
    value now use `0001-01-01 00:00:00`, and
    `test_remote_doris_all_types_select` asserts the zero-date error
    separately. A `_query_plan` read whose fragment fails for any reason,
    also a scan error or a cancellation, now gets the error from `get_next`
    instead of ending as a complete result.
    
    Tests:
    
    - BE UT `ArrowBlockConvertorTest`: six new cases for invalid UTF-8 (four
    malformed encodings, per-row boundaries across a slice and the next
    batch, nested array / struct / map key / map value, large strings, bytes
    under a NULL), and valid text, NULLs and binary.
    - BE UT `ArrowFlightTimestampTest` (new file): zero date, year 0 and
    year 10000 at scales 0 / 3 / 6, calendar boundaries and pre-epoch
    fractions, slices and later batches, nested values and map keys, UTC and
    zoned bounds, the America/Chicago lower bound with pytz's rounded
    offset, naive bounds that do not depend on the session time zone, and
    values under NULL parents; the ordinary Doris convertor still publishes
    the same values.
    - BE UT `ResultQueueMgrTest`: a fetch already waiting for data reports a
    failure recorded before the sentinel; an OK status keeps a normal end.
    - Regression `arrow_flight_sql_p0/test_query_plan_scan_validation` reads
    `_query_plan` scans the way the connectors do (FE `_query_plan`, then BE
    `open_scanner` / `get_next` over Thrift): a valid scan reads all rows,
    and the invalid UTF-8 and year-0 scans get the errors above.
    - Regression `arrow_flight_sql_p0/test_flight_utf8_validation`,
    `test_flight_timestamp_range` (both red before this PR on master, green
    with it), `test_flight_int96_timestamp_range` (reads the INT96 fixtures
    of `group4` over HDFS; needs the Hive docker environment), and the three
    remote Doris suites.
---
 .../exec/operator/memory_scratch_sink_operator.cpp |   4 +
 be/src/format/arrow/arrow_block_convertor.cpp      | 178 +++++++++++++++
 be/src/format/arrow/arrow_block_convertor.h        |   9 +-
 be/src/runtime/result_queue_mgr.cpp                |   5 +-
 .../format/arrow/arrow_block_convertor_test.cpp    | 133 +++++++++++
 .../format/arrow/arrow_flight_timestamp_test.cpp   | 250 +++++++++++++++++++++
 be/test/runtime/result_queue_mgr_test.cpp          |  55 +++++
 .../test_remote_doris_all_types_select.out         |   4 +-
 .../remote_doris/test_remote_doris_statistics.out  |   2 +-
 .../test_flight_int96_timestamp_range.groovy       |  83 +++++++
 .../test_flight_timestamp_range.groovy             |  81 +++++++
 .../test_flight_utf8_validation.groovy             |  71 ++++++
 .../test_query_plan_scan_validation.groovy         | 120 ++++++++++
 .../test_remote_doris_all_types_select.groovy      |  15 +-
 .../test_remote_doris_statistics.groovy            |   3 +-
 .../test_remote_doris_table_stats.groovy           |   3 +-
 16 files changed, 1005 insertions(+), 11 deletions(-)

diff --git a/be/src/exec/operator/memory_scratch_sink_operator.cpp 
b/be/src/exec/operator/memory_scratch_sink_operator.cpp
index 1d795001719..9af197a1eb9 100644
--- a/be/src/exec/operator/memory_scratch_sink_operator.cpp
+++ b/be/src/exec/operator/memory_scratch_sink_operator.cpp
@@ -57,6 +57,10 @@ Status MemoryScratchSinkLocalState::close(RuntimeState* 
state, Status exec_statu
     SCOPED_TIMER(exec_time_counter());
     SCOPED_TIMER(_close_timer);
     if (_queue != nullptr) {
+        // The nullptr sentinel alone reads as a complete result. Record a 
failed execution (a scan
+        // error, a rejected Arrow batch, a cancellation) first, so the 
external scanner reports it
+        // instead of ending with the batches queued so far. update_status() 
ignores OK.
+        _queue->update_status(exec_status);
         _queue->blocking_put(nullptr);
     }
     RETURN_IF_ERROR(Base::close(state, exec_status));
diff --git a/be/src/format/arrow/arrow_block_convertor.cpp 
b/be/src/format/arrow/arrow_block_convertor.cpp
index bfa4e08ad6e..f7267d683a2 100644
--- a/be/src/format/arrow/arrow_block_convertor.cpp
+++ b/be/src/format/arrow/arrow_block_convertor.cpp
@@ -17,6 +17,8 @@
 
 #include "format/arrow/arrow_block_convertor.h"
 
+#include <arrow/array/array_nested.h>
+#include <arrow/array/array_primitive.h>
 #include <arrow/array/builder_base.h>
 #include <arrow/array/builder_binary.h>
 #include <arrow/array/builder_decimal.h>
@@ -34,6 +36,7 @@
 #include <cctz/time_zone.h>
 #include <glog/logging.h>
 
+#include <algorithm>
 #include <array>
 #include <cstring>
 #include <ctime>
@@ -62,6 +65,156 @@ namespace doris {
 
 namespace {
 
+// PyArrow resolves an Arrow timezone name with pytz when pytz is installed, 
and pytz rounds every
+// UTC offset to whole minutes with Python's floor division, (offset + 30) // 
60 * 60. Example:
+// America/Chicago's local mean time, -05:50:36 (-21036 s), becomes -05:51 
(-21060 s).
+int64_t pytz_rounded_utc_offset(int64_t offset_seconds) {
+    const int64_t shifted = offset_seconds + 30;
+    // C++ division truncates toward zero; -21006 / 60 is -350, the floor is 
-351.
+    const int64_t minutes = shifted / 60 - (shifted % 60 < 0 ? 1 : 0);
+    return minutes * 60;
+}
+
+class FlightTimestampValidator {
+public:
+    Status init(std::shared_ptr<arrow::Array> array, std::string path,
+                const cctz::time_zone& timezone) {
+        _array = std::move(array);
+        _path = std::move(path);
+        switch (_array->type_id()) {
+        case arrow::Type::TIMESTAMP: {
+            const auto& type = static_cast<const 
arrow::TimestampType&>(*_array->type());
+            int64_t units_per_second = 1;
+            switch (type.unit()) {
+            case arrow::TimeUnit::SECOND:
+                units_per_second = 1;
+                break;
+            case arrow::TimeUnit::MILLI:
+                units_per_second = 1000;
+                break;
+            case arrow::TimeUnit::MICRO:
+                units_per_second = 1000000;
+                break;
+            case arrow::TimeUnit::NANO:
+                units_per_second = 1000000000;
+                break;
+            }
+            // 0001-01-01 00:00:00 and 10000-01-01 00:00:00 as seconds since 
the epoch.
+            constexpr int64_t civil_start_seconds = -62135596800LL;
+            constexpr int64_t civil_end_seconds = 253402300800LL;
+            int64_t min_seconds = civil_start_seconds;
+            int64_t end_seconds = civil_end_seconds;
+            if (!type.timezone().empty()) {
+                // The ordinary Arrow writer has already checked the 
schema/timezone binding.
+                const auto local_start = cctz::convert(cctz::civil_second(1, 
1, 1), timezone);
+                const auto local_end = cctz::convert(cctz::civil_second(10000, 
1, 1), timezone);
+                // Python first constructs UTC and then applies the Arrow 
timezone. Both calendar
+                // representations must fit, including offsets at the first 
and last supported day,
+                // with the exact offsets (zoneinfo) and with pytz's rounded 
ones. Example for
+                // America/Chicago: 0001-01-01 05:50:36Z is local 0001-01-01 
00:00:00 with the
+                // exact -05:50:36, but 0000-12-31 23:59:36 with pytz's 
-05:51, so the lower bound
+                // is 05:51:00Z.
+                const int64_t start_offset = 
timezone.lookup(local_start).offset;
+                const int64_t end_offset = timezone.lookup(local_end).offset;
+                min_seconds = std::max<int64_t>(
+                        {min_seconds, local_start.time_since_epoch().count(),
+                         civil_start_seconds - 
pytz_rounded_utc_offset(start_offset)});
+                end_seconds = std::min<int64_t>(
+                        {end_seconds, local_end.time_since_epoch().count(),
+                         civil_end_seconds - 
pytz_rounded_utc_offset(end_offset)});
+            }
+            // Nanosecond bounds do not fit int64_t, even though every encoded 
value does.
+            _min = static_cast<__int128>(min_seconds) * units_per_second;
+            _end = static_cast<__int128>(end_seconds) * units_per_second;
+            break;
+        }
+        case arrow::Type::LIST: {
+            const auto& list = static_cast<const arrow::ListArray&>(*_array);
+            RETURN_IF_ERROR(add_child(list.values(), _path + "[]", timezone));
+            break;
+        }
+        case arrow::Type::MAP: {
+            const auto& map = static_cast<const arrow::MapArray&>(*_array);
+            RETURN_IF_ERROR(add_child(map.keys(), _path + ".key", timezone));
+            RETURN_IF_ERROR(add_child(map.items(), _path + ".value", 
timezone));
+            break;
+        }
+        case arrow::Type::STRUCT: {
+            const auto& structure = static_cast<const 
arrow::StructArray&>(*_array);
+            for (int i = 0; i < structure.num_fields(); ++i) {
+                RETURN_IF_ERROR(add_child(structure.field(i),
+                                          _path + "." + 
_array->type()->field(i)->name(),
+                                          timezone));
+            }
+            break;
+        }
+        default:
+            break;
+        }
+        return Status::OK();
+    }
+
+    Status validate(int64_t start, int64_t end, size_t block_start, int64_t 
parent_row = -1) const {
+        if (!has_timestamps()) {
+            return Status::OK();
+        }
+        for (int64_t row = start; row < end; ++row) {
+            // Children of a NULL struct/list/map are not observable, even if 
their physical
+            // buffers contain default zero dates. Never validate those masked 
values.
+            if (_array->IsNull(row)) {
+                continue;
+            }
+            const int64_t output_row = parent_row < 0 ? row : parent_row;
+            if (_array->type_id() == arrow::Type::TIMESTAMP) {
+                const int64_t value = static_cast<const 
arrow::TimestampArray&>(*_array).Value(row);
+                if (value < _min || value >= _end) {
+                    return Status::InvalidArgument(
+                            "Arrow Flight timestamp in column '{}' at row {} 
is outside the "
+                            "supported 0001-9999 range (type: {})",
+                            _path, block_start + output_row + 1, 
_array->type()->ToString());
+                }
+                continue;
+            }
+            int64_t child_start = row;
+            int64_t child_end = row + 1;
+            if (_array->type_id() == arrow::Type::LIST) {
+                const auto& list = static_cast<const 
arrow::ListArray&>(*_array);
+                child_start = list.value_offset(row);
+                child_end = child_start + list.value_length(row);
+            } else if (_array->type_id() == arrow::Type::MAP) {
+                const auto& map = static_cast<const arrow::MapArray&>(*_array);
+                child_start = map.value_offset(row);
+                child_end = child_start + map.value_length(row);
+            }
+            for (const auto& child : _children) {
+                RETURN_IF_ERROR(child.validate(child_start, child_end, 
block_start, output_row));
+            }
+        }
+        return Status::OK();
+    }
+
+private:
+    bool has_timestamps() const {
+        return _array->type_id() == arrow::Type::TIMESTAMP || 
!_children.empty();
+    }
+
+    Status add_child(std::shared_ptr<arrow::Array> array, std::string path,
+                     const cctz::time_zone& timezone) {
+        FlightTimestampValidator child;
+        RETURN_IF_ERROR(child.init(std::move(array), std::move(path), 
timezone));
+        if (child.has_timestamps()) {
+            _children.push_back(std::move(child));
+        }
+        return Status::OK();
+    }
+
+    std::shared_ptr<arrow::Array> _array;
+    std::string _path;
+    std::vector<FlightTimestampValidator> _children;
+    __int128 _min = 0;
+    __int128 _end = 0;
+};
+
 std::shared_ptr<arrow::DataType> arrow_storage_type(const 
std::shared_ptr<arrow::DataType>& type) {
     switch (type->id()) {
     case arrow::Type::EXTENSION:
@@ -324,6 +477,31 @@ Status ArrowFlightArrowBlockConvertor::write_column(const 
std::shared_ptr<const
                                                   array_builder, start, end, 
ctz);
 }
 
+Status ArrowFlightArrowBlockConvertor::convert_to_arrow(const Block& block, 
arrow::MemoryPool* pool,
+                                                        
std::shared_ptr<arrow::RecordBatch>* result,
+                                                        size_t start_row, 
size_t end_row) const {
+    std::shared_ptr<arrow::RecordBatch> batch;
+    RETURN_IF_ERROR(ArrowBlockConvertor::convert_to_arrow(block, pool, &batch, 
start_row, end_row));
+    // String builders accept arbitrary bytes, but Flight UTF-8 values must be 
valid per row,
+    // including nested children. Validate before publishing the batch to the 
reader.
+    for (int i = 0; i < batch->num_columns(); ++i) {
+        auto status = batch->column(i)->ValidateFull();
+        if (!status.ok()) {
+            return Status::InvalidArgument("Invalid Arrow Flight result in 
column {} ('{}'): {}",
+                                           i + 1, 
batch->schema()->field(i)->name(),
+                                           status.ToString());
+        }
+        // Arrow accepts the full int64 timestamp domain; ValidateFull cannot 
enforce the calendar
+        // range required by Flight clients. Check the encoded values before 
publishing the batch.
+        FlightTimestampValidator validator;
+        RETURN_IF_ERROR(
+                validator.init(batch->column(i), 
batch->schema()->field(i)->name(), _timezone));
+        RETURN_IF_ERROR(validator.validate(0, batch->num_rows(), start_row));
+    }
+    *result = std::move(batch);
+    return Status::OK();
+}
+
 Status DorisArrowBlockConvertor::init() {
     if (_arrow_schema == nullptr) {
         // cctz names fixed offsets as "Fixed/UTC+HH:MM:SS", which is not the 
Arrow
diff --git a/be/src/format/arrow/arrow_block_convertor.h 
b/be/src/format/arrow/arrow_block_convertor.h
index 1f35ee30652..c18c28df72a 100644
--- a/be/src/format/arrow/arrow_block_convertor.h
+++ b/be/src/format/arrow/arrow_block_convertor.h
@@ -58,9 +58,9 @@ public:
     virtual Status init();
     const std::shared_ptr<arrow::Schema>& arrow_schema() const { return 
_arrow_schema; }
 
-    Status convert_to_arrow(const Block& block, arrow::MemoryPool* pool,
-                            std::shared_ptr<arrow::RecordBatch>* result, 
size_t start_row = 0,
-                            size_t end_row = 0) const;
+    virtual Status convert_to_arrow(const Block& block, arrow::MemoryPool* 
pool,
+                                    std::shared_ptr<arrow::RecordBatch>* 
result,
+                                    size_t start_row = 0, size_t end_row = 0) 
const;
 
     virtual Status convert_from_arrow(const 
std::shared_ptr<arrow::RecordBatch>& batch,
                                       const DataTypes& types, Block* block) 
const;
@@ -113,6 +113,9 @@ class ArrowFlightArrowBlockConvertor final : public 
DorisArrowBlockConvertor {
 public:
     using DorisArrowBlockConvertor::DorisArrowBlockConvertor;
     Status init() override;
+    Status convert_to_arrow(const Block& block, arrow::MemoryPool* pool,
+                            std::shared_ptr<arrow::RecordBatch>* result, 
size_t start_row = 0,
+                            size_t end_row = 0) const override;
 
 protected:
     Status write_column(const std::shared_ptr<const IDataType>& type, const 
DataTypeSerDe& serde,
diff --git a/be/src/runtime/result_queue_mgr.cpp 
b/be/src/runtime/result_queue_mgr.cpp
index 13850cd0954..51208f324be 100644
--- a/be/src/runtime/result_queue_mgr.cpp
+++ b/be/src/runtime/result_queue_mgr.cpp
@@ -63,9 +63,12 @@ Status ResultQueueMgr::fetch_result(const TUniqueId& 
fragment_instance_id,
     if (success) {
         // sentinel nullptr indicates scan end
         if (*result == nullptr) {
-            *eos = true;
             // put sentinel for consistency, avoid repeated invoking fetch 
result when have no rowbatch
             queue->blocking_put(nullptr);
+            // The sink records a failed execution before it puts the 
sentinel. Check again here:
+            // a fetch that was already waiting for data passed the check 
above before the failure.
+            RETURN_IF_ERROR(queue->status());
+            *eos = true;
         } else {
             *eos = false;
         }
diff --git a/be/test/format/arrow/arrow_block_convertor_test.cpp 
b/be/test/format/arrow/arrow_block_convertor_test.cpp
index 16cd9c3146c..b6fa589570b 100644
--- a/be/test/format/arrow/arrow_block_convertor_test.cpp
+++ b/be/test/format/arrow/arrow_block_convertor_test.cpp
@@ -23,6 +23,7 @@
 #include <arrow/ipc/api.h>
 #include <gtest/gtest.h>
 
+#include "core/column/column_nullable.h"
 #include "core/column/column_vector.h"
 #include "core/data_type/data_type_array.h"
 #include "core/data_type/data_type_factory.hpp"
@@ -307,4 +308,136 @@ TEST_F(ArrowBlockConvertorTest, 
TableConvertersRejectMismatchedNestedSchemas) {
     }
 }
 
+TEST_F(ArrowBlockConvertorTest, FlightRejectsInvalidUtf8BeforeReturningBatch) {
+    auto type = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
false);
+    for (const std::string& value :
+         {std::string("\x84"), std::string("\xc0\xaf"), 
std::string("\xed\xa0\x80"),
+          std::string("\xf4\x90\x80\x80")}) {
+        auto column = type->create_column();
+        column->insert(Field::create_field<TYPE_STRING>(value));
+        Block block {{std::move(column), type, "payload"}};
+        ArrowFlightArrowBlockConvertor flight(block, "UTC", 
cctz::utc_time_zone());
+        ASSERT_TRUE(flight.init().ok());
+        const ArrowBlockConvertor& converter = flight;
+        std::shared_ptr<arrow::RecordBatch> batch;
+        const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+        EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+        EXPECT_NE(std::string::npos, status.to_string().find("payload"));
+        EXPECT_NE(std::string::npos, status.to_string().find("UTF8"));
+        EXPECT_EQ(nullptr, batch);
+
+        DorisArrowBlockConvertor ordinary(block, "UTC", cctz::utc_time_zone());
+        ASSERT_TRUE(ordinary.init().ok());
+        EXPECT_TRUE(ordinary.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+    }
+}
+
+TEST_F(ArrowBlockConvertorTest, FlightChecksEachStringAndEveryBatch) {
+    auto type = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
false);
+    auto column = type->create_column();
+    for (const std::string& value :
+         {std::string("valid"), std::string("\xc2"), std::string("\xa2")}) {
+        column->insert(Field::create_field<TYPE_STRING>(value));
+    }
+    Block block {{std::move(column), type, "payload"}};
+    ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch, 0, 1).ok());
+    ASSERT_EQ(1, batch->num_rows());
+    batch.reset();
+    // Adjacent invalid strings form valid UTF-8 when concatenated; row 
boundaries matter.
+    EXPECT_FALSE(
+            converter.convert_to_arrow(block, arrow::default_memory_pool(), 
&batch, 1, 3).ok());
+    EXPECT_EQ(nullptr, batch);
+}
+
+TEST_F(ArrowBlockConvertorTest, FlightPreservesValidTextNullsAndBinary) {
+    auto text = 
make_nullable(DataTypeFactory::instance().create_data_type(TYPE_STRING, false));
+    auto strings = text->create_column();
+    
strings->insert(Field::create_field<TYPE_STRING>(std::string("\xe4\xb8\xad\xf0\x9f\x98\x80")));
+    strings->insert(Field::create_field<TYPE_STRING>(std::string()));
+    strings->insert(Field::create_field<TYPE_STRING>(std::string("a\0b", 3)));
+    strings->insert_default();
+    auto binary = DataTypeFactory::instance().create_data_type(TYPE_VARBINARY, 
false);
+    auto bytes = binary->create_column();
+    for (int i = 0; i < 4; ++i) {
+        bytes->insert_data("\x84\0\xff", 3);
+    }
+    Block block {{std::move(strings), text, "text"}, {std::move(bytes), 
binary, "binary"}};
+    ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+    ASSERT_TRUE(batch->ValidateFull().ok());
+    const auto& values = static_cast<const 
arrow::StringArray&>(*batch->column(0));
+    EXPECT_EQ("\xe4\xb8\xad\xf0\x9f\x98\x80", values.GetString(0));
+    EXPECT_EQ("", values.GetString(1));
+    EXPECT_EQ(std::string("a\0b", 3), values.GetString(2));
+    EXPECT_TRUE(values.IsNull(3));
+    ASSERT_EQ(arrow::Type::BINARY, batch->column(1)->type_id());
+    const auto& binary_values = static_cast<const 
arrow::BinaryArray&>(*batch->column(1));
+    EXPECT_EQ(std::string("\x84\0\xff", 3), binary_values.GetString(0));
+}
+
+TEST_F(ArrowBlockConvertorTest, FlightRejectsNestedInvalidUtf8) {
+    auto text = 
make_nullable(DataTypeFactory::instance().create_data_type(TYPE_STRING, false));
+    const auto invalid = Field::create_field<TYPE_STRING>(std::string("\x84"));
+    const auto valid = Field::create_field<TYPE_STRING>(std::string("key"));
+    DataTypes types {std::make_shared<DataTypeArray>(text),
+                     std::make_shared<DataTypeStruct>(DataTypes {text}, 
Strings {"child"}),
+                     std::make_shared<DataTypeMap>(text, text),
+                     std::make_shared<DataTypeMap>(text, text)};
+    FieldVector fields {
+            Field::create_field<TYPE_ARRAY>(Array {invalid}),
+            Field::create_field<TYPE_STRUCT>(Struct {invalid}),
+            Field::create_field<TYPE_MAP>(Map 
{Field::create_field<TYPE_ARRAY>(Array {valid}),
+                                               
Field::create_field<TYPE_ARRAY>(Array {invalid})}),
+            Field::create_field<TYPE_MAP>(Map 
{Field::create_field<TYPE_ARRAY>(Array {invalid}),
+                                               
Field::create_field<TYPE_ARRAY>(Array {valid})})};
+    for (size_t i = 0; i < types.size(); ++i) {
+        SCOPED_TRACE(types[i]->get_name());
+        auto column = types[i]->create_column();
+        column->insert(fields[i]);
+        Block block {{std::move(column), types[i], "nested"}};
+        ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+        ASSERT_TRUE(converter.init().ok());
+        std::shared_ptr<arrow::RecordBatch> batch;
+        const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+        EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+        EXPECT_NE(std::string::npos, status.to_string().find("nested"));
+        EXPECT_EQ(nullptr, batch);
+    }
+}
+
+TEST_F(ArrowBlockConvertorTest, FlightRejectsInvalidLargeString) {
+    auto type = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
false);
+    auto column = type->create_column();
+    column->insert(Field::create_field<TYPE_STRING>(std::string("\x84")));
+    Block block {{std::move(column), type, "large_text"}};
+    auto schema = arrow::schema({arrow::field("large_text", 
arrow::large_utf8(), false)});
+    ArrowFlightArrowBlockConvertor converter(schema, cctz::utc_time_zone());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+    EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+    EXPECT_NE(std::string::npos, status.to_string().find("large_text"));
+    EXPECT_EQ(nullptr, batch);
+}
+
+TEST_F(ArrowBlockConvertorTest, FlightIgnoresBytesMaskedByNull) {
+    auto text = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
false);
+    auto values = text->create_column();
+    values->insert_data("\x84", 1);
+    auto nulls = ColumnUInt8::create();
+    nulls->insert_value(1);
+    Block block {{ColumnNullable::create(std::move(values), std::move(nulls)), 
make_nullable(text),
+                  "nullable_text"}};
+    ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+    ASSERT_TRUE(batch->ValidateFull().ok());
+    EXPECT_TRUE(batch->column(0)->IsNull(0));
+}
+
 } // namespace doris
diff --git a/be/test/format/arrow/arrow_flight_timestamp_test.cpp 
b/be/test/format/arrow/arrow_flight_timestamp_test.cpp
new file mode 100644
index 00000000000..8aa88082658
--- /dev/null
+++ b/be/test/format/arrow/arrow_flight_timestamp_test.cpp
@@ -0,0 +1,250 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include <arrow/api.h>
+#include <gtest/gtest.h>
+
+#include "core/column/column_nullable.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_date_or_datetime_v2.h"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
+#include "format/arrow/arrow_block_convertor.h"
+#include "format/arrow/arrow_row_batch.h"
+#include "util/timezone_utils.h"
+
+namespace doris {
+namespace {
+using DateTime = DateV2Value<DateTimeV2ValueType>;
+
+DateTime make_datetime(uint16_t year, uint8_t month, uint8_t day, uint8_t 
hour, uint8_t minute,
+                       uint16_t second, uint32_t microsecond) {
+    DateTime value;
+    // Invalid calendar values must reach the converter to exercise its range 
validation.
+    value.unchecked_set_time(year, month, day, hour, minute, second, 
microsecond);
+    return value;
+}
+
+Block timestamp_block(int scale, const std::vector<DateTime>& values) {
+    auto column = ColumnDateTimeV2::create();
+    for (const auto& value : values) {
+        column->insert_value(value);
+    }
+    return Block {{std::move(column), 
std::make_shared<DataTypeDateTimeV2>(scale), "event_time"}};
+}
+
+class ArrowFlightTimestampTest : public testing::Test {
+protected:
+    static void SetUpTestSuite() { TimezoneUtils::load_timezones_to_cache(); }
+};
+
+TEST_F(ArrowFlightTimestampTest, 
RejectsOutOfRangeInEveryUnitWithoutPublishingBatch) {
+    for (int scale : {0, 3, 6}) {
+        for (const auto& value : {DateTime {}, make_datetime(0, 12, 31, 0, 0, 
0, 0),
+                                  make_datetime(10000, 1, 1, 0, 0, 0, 0)}) {
+            SCOPED_TRACE(scale);
+            auto block = timestamp_block(scale, {value});
+            ArrowFlightArrowBlockConvertor flight(block, "UTC", 
cctz::utc_time_zone());
+            ASSERT_TRUE(flight.init().ok());
+            const ArrowBlockConvertor& converter = flight;
+            std::shared_ptr<arrow::RecordBatch> batch;
+            const auto status =
+                    converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+            EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+            EXPECT_NE(std::string::npos, 
status.to_string().find("event_time"));
+            EXPECT_NE(std::string::npos, status.to_string().find("row 1"));
+            EXPECT_NE(std::string::npos, status.to_string().find("0001-9999"));
+            EXPECT_EQ(nullptr, batch);
+
+            // Other Arrow consumers retain their existing date semantics.
+            DorisArrowBlockConvertor ordinary(flight.arrow_schema(), 
cctz::utc_time_zone());
+            ASSERT_TRUE(ordinary.init().ok());
+            ASSERT_TRUE(
+                    ordinary.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+            EXPECT_TRUE(batch->ValidateFull().ok());
+        }
+    }
+}
+
+TEST_F(ArrowFlightTimestampTest, 
PreservesCalendarBoundariesAndPreEpochFractions) {
+    for (int scale : {0, 3, 6}) {
+        const int64_t factor = scale == 0 ? 1 : scale == 3 ? 1000 : 1000000;
+        const uint32_t fraction = scale == 0 ? 0 : scale == 3 ? 999000 : 
999999;
+        auto block = timestamp_block(scale, {make_datetime(1, 1, 1, 0, 0, 0, 
0),
+                                             make_datetime(9999, 12, 31, 23, 
59, 59, fraction),
+                                             make_datetime(1969, 12, 31, 23, 
59, 59, fraction)});
+        ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+        ASSERT_TRUE(converter.init().ok());
+        std::shared_ptr<arrow::RecordBatch> batch;
+        ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+        const auto& values = static_cast<const 
arrow::TimestampArray&>(*batch->column(0));
+        EXPECT_EQ(-62135596800LL * factor, values.Value(0));
+        EXPECT_EQ(253402300800LL * factor - 1, values.Value(1));
+        EXPECT_EQ(-1, values.Value(2));
+    }
+}
+
+TEST_F(ArrowFlightTimestampTest, ChecksSlicesAndSubsequentBatches) {
+    auto block = timestamp_block(6, {make_datetime(2024, 1, 1, 0, 0, 0, 0), 
DateTime {}});
+    ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch, 0, 1).ok());
+    auto previous = batch;
+    const auto status =
+            converter.convert_to_arrow(block, arrow::default_memory_pool(), 
&batch, 1, 2);
+    EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code());
+    EXPECT_NE(std::string::npos, status.to_string().find("row 2"));
+    EXPECT_EQ(previous, batch);
+}
+
+TEST_F(ArrowFlightTimestampTest, RejectsNestedTimestampValuesAndMapKeys) {
+    auto datetime = make_nullable(std::make_shared<DataTypeDateTimeV2>(6));
+    const auto invalid = Field::create_field<TYPE_DATETIMEV2>(DateTime {});
+    const auto valid = 
Field::create_field<TYPE_DATETIMEV2>(make_datetime(2024, 1, 1, 0, 0, 0, 0));
+    DataTypes types {std::make_shared<DataTypeArray>(datetime),
+                     std::make_shared<DataTypeStruct>(DataTypes {datetime}, 
Strings {"child"}),
+                     std::make_shared<DataTypeMap>(datetime, datetime),
+                     std::make_shared<DataTypeMap>(datetime, datetime),
+                     
std::make_shared<DataTypeArray>(std::make_shared<DataTypeStruct>(
+                             DataTypes {datetime}, Strings {"child"}))};
+    FieldVector fields {
+            Field::create_field<TYPE_ARRAY>(Array {valid, invalid}),
+            Field::create_field<TYPE_STRUCT>(Struct {invalid}),
+            Field::create_field<TYPE_MAP>(Map 
{Field::create_field<TYPE_ARRAY>(Array {valid}),
+                                               
Field::create_field<TYPE_ARRAY>(Array {invalid})}),
+            Field::create_field<TYPE_MAP>(Map 
{Field::create_field<TYPE_ARRAY>(Array {invalid}),
+                                               
Field::create_field<TYPE_ARRAY>(Array {valid})}),
+            Field::create_field<TYPE_ARRAY>(
+                    Array {Field::create_field<TYPE_STRUCT>(Struct 
{invalid})})};
+    for (size_t i = 0; i < types.size(); ++i) {
+        SCOPED_TRACE(types[i]->get_name());
+        auto column = types[i]->create_column();
+        column->insert_default();
+        column->insert(fields[i]);
+        Block block {{std::move(column), types[i], "nested"}};
+        ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+        ASSERT_TRUE(converter.init().ok());
+        std::shared_ptr<arrow::RecordBatch> batch;
+        const auto status =
+                converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch, 1, 2);
+        EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+        EXPECT_NE(std::string::npos, status.to_string().find("nested"));
+        EXPECT_NE(std::string::npos, status.to_string().find("row 2"));
+        EXPECT_EQ(nullptr, batch);
+    }
+}
+
+TEST_F(ArrowFlightTimestampTest, ChecksBothUtcAndZonedCalendarBounds) {
+    const std::vector<std::pair<std::string, DateTime>> cases {
+            {"+08:00", make_datetime(1, 1, 1, 0, 0, 0, 0)},
+            {"-08:00", make_datetime(9999, 12, 31, 23, 0, 0, 0)},
+            {"-08:00", make_datetime(0, 12, 31, 23, 0, 0, 0)},
+            {"+08:00", make_datetime(10000, 1, 1, 1, 0, 0, 0)}};
+    for (const auto& [zone, value] : cases) {
+        SCOPED_TRACE(zone);
+        cctz::time_zone timezone;
+        ASSERT_TRUE(TimezoneUtils::find_cctz_time_zone(zone, timezone));
+        auto block = timestamp_block(6, {value});
+        // Flight-generated DATETIMEV2 schemas are naive; keep an explicit 
zoned schema
+        // here so this test still checks both UTC and local calendar bounds.
+        std::shared_ptr<arrow::Schema> schema;
+        ASSERT_TRUE(DorisArrowSchemaConvertor(block, 
zone).get_arrow_schema(&schema).ok());
+        ArrowFlightArrowBlockConvertor converter(schema, timezone);
+        ASSERT_TRUE(converter.init().ok());
+        std::shared_ptr<arrow::RecordBatch> batch;
+        const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+        EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+        EXPECT_EQ(nullptr, batch);
+
+        auto valid = timestamp_block(
+                6, {make_datetime(1, 1, 2, 0, 0, 0, 0), make_datetime(9999, 
12, 30, 23, 0, 0, 0)});
+        ASSERT_TRUE(converter.convert_to_arrow(valid, 
arrow::default_memory_pool(), &batch).ok());
+    }
+}
+
+// PyArrow converts with pytz when it is installed, and pytz rounds 
America/Chicago's local mean
+// time, -05:50:36, to -05:51. 0001-01-01 05:50:36Z, the local start with the 
exact offset, is
+// 0000-12-31 23:59:36 in pytz and fails to convert; the first accepted 
instant is 05:51:00Z.
+TEST_F(ArrowFlightTimestampTest, ZonedLowerBoundHoldsWithPytzRoundedOffsets) {
+    const std::string zone = "America/Chicago";
+    cctz::time_zone timezone;
+    ASSERT_TRUE(TimezoneUtils::find_cctz_time_zone(zone, timezone));
+    // Local 0001-01-01 00:00:00 with the exact offset is 05:50:36Z.
+    auto block = timestamp_block(6, {make_datetime(1, 1, 1, 0, 0, 0, 0)});
+    std::shared_ptr<arrow::Schema> schema;
+    ASSERT_TRUE(DorisArrowSchemaConvertor(block, 
zone).get_arrow_schema(&schema).ok());
+    ArrowFlightArrowBlockConvertor converter(schema, timezone);
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+    EXPECT_EQ(ErrorCode::INVALID_ARGUMENT, status.code()) << status;
+    EXPECT_EQ(nullptr, batch);
+
+    // Local 00:00:24 is 05:51:00Z, which is 0001-01-01 00:00:00 with pytz's 
offset.
+    auto valid = timestamp_block(6, {make_datetime(1, 1, 1, 0, 0, 24, 0)});
+    ASSERT_TRUE(converter.convert_to_arrow(valid, 
arrow::default_memory_pool(), &batch).ok());
+    const auto& values = static_cast<const 
arrow::TimestampArray&>(*batch->column(0));
+    EXPECT_EQ(-62135575740000000LL, values.Value(0));
+}
+
+TEST_F(ArrowFlightTimestampTest, NaiveBoundsDoNotDependOnSessionTimezone) {
+    cctz::time_zone timezone;
+    ASSERT_TRUE(TimezoneUtils::find_cctz_time_zone("+08:00", timezone));
+    auto block = timestamp_block(6, {make_datetime(1, 1, 1, 0, 0, 0, 0),
+                                     make_datetime(9999, 12, 31, 23, 59, 59, 
999999)});
+    ArrowFlightArrowBlockConvertor converter(block, "+08:00", timezone);
+    ASSERT_TRUE(converter.init().ok());
+    std::shared_ptr<arrow::RecordBatch> batch;
+    ASSERT_TRUE(converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch).ok());
+    const auto& values = static_cast<const 
arrow::TimestampArray&>(*batch->column(0));
+    EXPECT_EQ(-62135596800000000LL, values.Value(0));
+    EXPECT_EQ(253402300799999999LL, values.Value(1));
+}
+
+TEST_F(ArrowFlightTimestampTest, IgnoresTimestampsMaskedByNullParents) {
+    auto datetime = std::make_shared<DataTypeDateTimeV2>(6);
+    const auto invalid = Field::create_field<TYPE_DATETIMEV2>(DateTime {});
+    DataTypes types {
+            datetime, std::make_shared<DataTypeStruct>(DataTypes {datetime}, 
Strings {"child"}),
+            std::make_shared<DataTypeArray>(datetime),
+            std::make_shared<DataTypeMap>(make_nullable(datetime), 
make_nullable(datetime))};
+    FieldVector fields {
+            invalid, Field::create_field<TYPE_STRUCT>(Struct {invalid}),
+            Field::create_field<TYPE_ARRAY>(Array {invalid}),
+            Field::create_field<TYPE_MAP>(Map 
{Field::create_field<TYPE_ARRAY>(Array {invalid}),
+                                               
Field::create_field<TYPE_ARRAY>(Array {invalid})})};
+    for (size_t i = 0; i < types.size(); ++i) {
+        auto data = types[i]->create_column();
+        data->insert(fields[i]);
+        auto nulls = ColumnUInt8::create();
+        nulls->insert_value(1);
+        Block block {{ColumnNullable::create(std::move(data), 
std::move(nulls)),
+                      make_nullable(types[i]), "masked"}};
+        ArrowFlightArrowBlockConvertor converter(block, "UTC", 
cctz::utc_time_zone());
+        ASSERT_TRUE(converter.init().ok());
+        std::shared_ptr<arrow::RecordBatch> batch;
+        const auto status = converter.convert_to_arrow(block, 
arrow::default_memory_pool(), &batch);
+        ASSERT_TRUE(status.ok()) << status;
+        EXPECT_TRUE(batch->column(0)->IsNull(0));
+    }
+}
+
+} // namespace
+} // namespace doris
diff --git a/be/test/runtime/result_queue_mgr_test.cpp 
b/be/test/runtime/result_queue_mgr_test.cpp
index 707cb151306..ab80f8cad7e 100644
--- a/be/test/runtime/result_queue_mgr_test.cpp
+++ b/be/test/runtime/result_queue_mgr_test.cpp
@@ -26,11 +26,14 @@
 #include <gtest/gtest-message.h>
 #include <gtest/gtest-test-part.h>
 
+#include <chrono>
 #include <memory>
 #include <ostream>
+#include <thread>
 #include <utility>
 #include <vector>
 
+#include "common/status.h"
 #include "gtest/gtest_pred_impl.h"
 #include "runtime/record_batch_queue.h"
 
@@ -134,6 +137,58 @@ TEST_F(ResultQueueMgrTest, fetch_result_end) {
     EXPECT_TRUE(result == nullptr);
 }
 
+// MemoryScratchSink puts the sentinel when its task closes, also after a 
failure. A successful
+// close records OK, which must leave the sentinel a normal end of the scan.
+TEST_F(ResultQueueMgrTest, fetch_result_end_after_ok_status) {
+    ResultQueueMgr queue_mgr;
+    TUniqueId query_id;
+    query_id.lo = 10;
+    query_id.hi = 100;
+
+    BlockQueueSharedPtr block_queue_t;
+    queue_mgr.create_queue(query_id, &block_queue_t);
+    block_queue_t->update_status(Status::OK());
+    block_queue_t->blocking_put(nullptr);
+
+    std::shared_ptr<arrow::RecordBatch> result;
+    bool eos = false;
+    EXPECT_TRUE(queue_mgr.fetch_result(query_id, &result, &eos).ok());
+    EXPECT_TRUE(eos);
+}
+
+// A failed scan (for example a batch the Arrow Flight convertor rejects) also 
ends with the
+// sentinel. The failure is recorded before it, and must reach the external 
scanner instead of a
+// successful end with the rows fetched so far, also when its fetch already 
waits for data: that
+// fetch passed the status check before the failure happened.
+TEST_F(ResultQueueMgrTest, 
fetch_result_reports_failure_recorded_before_sentinel) {
+    ResultQueueMgr queue_mgr;
+    TUniqueId query_id;
+    query_id.lo = 10;
+    query_id.hi = 100;
+
+    BlockQueueSharedPtr block_queue_t;
+    queue_mgr.create_queue(query_id, &block_queue_t);
+
+    std::shared_ptr<arrow::RecordBatch> waiting_result;
+    bool waiting_eos = false;
+    Status waiting_status;
+    std::thread waiting_fetch([&]() {
+        waiting_status = queue_mgr.fetch_result(query_id, &waiting_result, 
&waiting_eos);
+    });
+    // Let the fetch pass its first status check and block on the empty queue.
+    std::this_thread::sleep_for(std::chrono::milliseconds(200));
+    block_queue_t->update_status(Status::InvalidArgument("rejected Arrow 
batch"));
+    block_queue_t->blocking_put(nullptr);
+    waiting_fetch.join();
+    EXPECT_TRUE(waiting_status.is<ErrorCode::INVALID_ARGUMENT>()) << 
waiting_status;
+
+    // Later fetches keep reporting the failure instead of the end of the scan.
+    std::shared_ptr<arrow::RecordBatch> result;
+    bool eos = false;
+    const auto status = queue_mgr.fetch_result(query_id, &result, &eos);
+    EXPECT_TRUE(status.is<ErrorCode::INVALID_ARGUMENT>()) << status;
+}
+
 TEST_F(ResultQueueMgrTest, normal_cancel) {
     TUniqueId query_id;
     query_id.lo = 10;
diff --git 
a/regression-test/data/external_table_p0/remote_doris/test_remote_doris_all_types_select.out
 
b/regression-test/data/external_table_p0/remote_doris/test_remote_doris_all_types_select.out
index a8823bb027e..70fb18a6da0 100644
--- 
a/regression-test/data/external_table_p0/remote_doris/test_remote_doris_all_types_select.out
+++ 
b/regression-test/data/external_table_p0/remote_doris/test_remote_doris_all_types_select.out
@@ -1,12 +1,12 @@
 -- This file is automatically generated. You should know what you did if you 
want to edit this
 -- !sql --
-2025-05-18T01:00       true    -128    -32768  -2147483648     
-9223372036854775808    -1234567890123456790    -123.456        -123456.789     
-123457 -123456789012346        -1234567890123456789012345678   1970-01-01      
0000-01-01T00:00        A       Hello   Hello, Doris!   ["apple", "banana", 
"orange"]   {"Emily":101, "age":25} {"f1":11, "f2":3.14, "f3":"Emily"}      
{"k1":"v31","k2":300,"k3":[123,456],"k4":[],"k5":{"i1":"iv1"}}
+2025-05-18T01:00       true    -128    -32768  -2147483648     
-9223372036854775808    -1234567890123456790    -123.456        -123456.789     
-123457 -123456789012346        -1234567890123456789012345678   1970-01-01      
0001-01-01T00:00        A       Hello   Hello, Doris!   ["apple", "banana", 
"orange"]   {"Emily":101, "age":25} {"f1":11, "f2":3.14, "f3":"Emily"}      
{"k1":"v31","k2":300,"k3":[123,456],"k4":[],"k5":{"i1":"iv1"}}
 2025-05-18T02:00       \N      \N      \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N      \N      \N      \N      \N      
\N      \N      \N
 2025-05-18T03:00       false   127     32767   2147483647      
9223372036854775807     1234567890123456789     123.456 123456.789      123457  
123456789012346 1234567890123456789012345678    9999-12-31      
9999-12-31T23:59:59                             []      {}      {"f1":11, 
"f2":3.14, "f3":"Emily"}      {}
 2025-05-18T04:00       true    0       0       0       0       0       0.0     
0       0       0       0       2023-10-01      2023-10-01T12:34:56     A       
Hello   Hello, Doris!   ["apple", "banana", "orange"]   {"Emily":101, "age":25} 
{"f1":11, "f2":3.14, "f3":"Emily"}      []
 
 -- !sql --
-2025-05-18T01:00       [1]     [-128]  [-32768]        [-2147483648]   
[-9223372036854775808]  [-1234567890123456790]  [-123.456]      [-123456.789]   
[-123457]       [-123456789012346]      [-1234567890123456789012345678] 
["0000-01-01"]  ["0000-01-01 00:00:00"] ["A"]   ["Hello"]       ["Hello, 
Doris!"]
+2025-05-18T01:00       [1]     [-128]  [-32768]        [-2147483648]   
[-9223372036854775808]  [-1234567890123456790]  [-123.456]      [-123456.789]   
[-123457]       [-123456789012346]      [-1234567890123456789012345678] 
["0000-01-01"]  ["0001-01-01 00:00:00"] ["A"]   ["Hello"]       ["Hello, 
Doris!"]
 2025-05-18T02:00       [null]  [null]  [null]  [null]  [null]  [null]  [null]  
[null]  [null]  [null]  [null]  [null]  [null]  [null]  [null]  [null]
 2025-05-18T03:00       [0]     [127]   [32767] [2147483647]    
[9223372036854775807]   [1234567890123456789]   [123.456]       [123456.789]    
[123457]        [123456789012346]       [1234567890123456789012345678]  
["9999-12-31"]  ["9999-12-31 23:59:59"] [""]    [""]    [""]
 2025-05-18T04:00       [1]     [0]     [0]     [0]     [0]     [0]     [0]     
[0]     [0]     [0]     [0]     ["2023-10-01"]  ["2023-10-01 12:34:56"] ["A"]   
["Hello"]       ["Hello, Doris!"]
diff --git 
a/regression-test/data/external_table_p0/remote_doris/test_remote_doris_statistics.out
 
b/regression-test/data/external_table_p0/remote_doris/test_remote_doris_statistics.out
index eecba5cbcd6..aebd716491e 100644
--- 
a/regression-test/data/external_table_p0/remote_doris/test_remote_doris_statistics.out
+++ 
b/regression-test/data/external_table_p0/remote_doris/test_remote_doris_statistics.out
@@ -4,7 +4,7 @@ c_bigint        4       3       1       -9223372036854775808    
9223372036854775807     32
 c_boolean      4       2       1       0       1       4
 c_char 4       2       1               A       2
 c_date 4       3       1       1970-01-01      9999-12-31      16
-c_datetime     4       3       1       0000-01-01 00:00:00     9999-12-31 
23:59:59     32
+c_datetime     4       3       1       0001-01-01 00:00:00     9999-12-31 
23:59:59     32
 c_decimal18    4       3       1       -123456789012346        123456789012346 
32
 c_decimal32    4       3       1       -1234567890123456789012345678   
1234567890123456789012345678    64
 c_decimal9     4       3       1       -123457 123457  16
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_flight_int96_timestamp_range.groovy
 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_int96_timestamp_range.groovy
new file mode 100644
index 00000000000..7cdc5f1bbfe
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_int96_timestamp_range.groovy
@@ -0,0 +1,83 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import java.sql.DriverManager
+import java.sql.Types
+import java.time.LocalDateTime
+
+suite("test_flight_int96_timestamp_range", 
"arrow_flight_sql,external,hive,tvf,external_docker") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableHiveTest"))) {
+        return
+    }
+    def host = context.config.otherConfigs.get("externalEnvIp")
+    def port = context.config.otherConfigs.get("hive2HdfsPort")
+    // External clusters can override the configured Flight port; use the 
running FE's endpoint.
+    def frontend = jdbc_sql_return_maparray("SHOW FRONTENDS").find {
+        it.IsMaster.toString().toBoolean() && it.Alive.toString().toBoolean()
+    }
+    assertNotNull(frontend)
+    assertTrue(frontend.ArrowFlightSqlPort.toString().toInteger() > 0)
+    def flightUrl = 
"jdbc:arrow-flight-sql://${frontend.Host}:${frontend.ArrowFlightSqlPort}" +
+            
"/catalog=${context.dbName}?useServerPrepStmts=false&useSSL=false&useEncryption=false"
+    Class.forName("org.apache.arrow.driver.jdbc.ArrowFlightJdbcDriver")
+    def query = { String file ->
+        """SELECT * FROM HDFS(
+           "uri" = 
"hdfs://${host}:${port}/user/doris/tvf_data/test_hdfs_parquet/group4/${file}",
+           "hadoop.username" = "doris", "format" = "parquet") LIMIT 10"""
+    }
+    // Keep the configured Flight identity without the TLS-dependent connect() 
wrapper.
+    DriverManager.getConnection(flightUrl, 
context.config.otherConfigs.get("extArrowFlightSqlUser"),
+            
context.config.otherConfigs.get("extArrowFlightSqlPassword")).withCloseable { 
flight ->
+        flight.createStatement().withCloseable { statement ->
+            // Nanosecond fractions in these fixtures truncate to valid 
DATETIME(6) values.
+            for (def file : 
["part-00000-570d8e52-652d-4892-8bdc-7fa5466ffa69.c000.snappy.parquet",
+                              
"part-00000-b945dfb5-9982-4f86-b903-dabef99caba1.c000.snappy.parquet",
+                              
"part-00000-721700d2-26d7-42a3-a8f9-b6601628ccd4.c000.snappy.parquet",
+                              
"part-00000-afeef968-a917-4d51-a652-e5a4214df453.c000.snappy.parquet"]) {
+                logger.info("Read INT96 Flight fixture: ${file}")
+                statement.executeQuery(query(file)).withCloseable { rows ->
+                    assertTrue(rows.next())
+                    def metadata = rows.getMetaData()
+                    for (int column = 1; column <= metadata.getColumnCount(); 
++column) {
+                        if (metadata.getColumnType(column) == Types.TIMESTAMP) 
{
+                            assertNotNull(rows.getObject(column, 
LocalDateTime.class))
+                        }
+                    }
+                    
assertEquals(LocalDateTime.parse("9999-12-31T23:59:59.999999"),
+                            rows.getObject(metadata.getColumnCount(), 
LocalDateTime.class))
+                    assertFalse(rows.next())
+                }
+            }
+        }
+        // Consume the result on the same discovered endpoint so zero-year 
values must fail over Flight.
+        try {
+            flight.createStatement().withCloseable { statement ->
+                
statement.executeQuery(query("int96_timestamps_nanos_outside_day_range.parquet"))
+                        .withCloseable { rows ->
+                    while (rows.next()) {
+                        for (int column = 1; column <= 
rows.getMetaData().getColumnCount(); ++column) {
+                            rows.getObject(column)
+                        }
+                    }
+                }
+            }
+            assertTrue(false, "Expected an out-of-range Flight timestamp 
error")
+        } catch (Exception error) {
+            assertTrue(error.toString().contains("outside the supported 
0001-9999 range"), error.toString())
+        }
+    }
+}
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_flight_timestamp_range.groovy 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_timestamp_range.groovy
new file mode 100644
index 00000000000..c494bd84684
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_timestamp_range.groovy
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import java.time.LocalDateTime
+
+suite("test_flight_timestamp_range", "arrow_flight_sql") {
+    // Reuse the configured Flight connection so TLS settings cannot skip the 
assertions or change credentials.
+    def flight = context.getArrowFlightSqlConnection()
+    def input = "${context.dbName}.flight_timestamp_input"
+    def expectRangeError = { String query ->
+        try {
+            arrow_flight_sql(query)
+            assertTrue(false, "Expected an out-of-range Flight timestamp 
error")
+        } catch (Exception error) {
+            assertTrue(error.toString().contains("outside the supported 
0001-9999 range"), error.toString())
+        }
+    }
+    arrow_flight_sql "DROP TABLE IF EXISTS ${input}"
+    arrow_flight_sql """CREATE TABLE ${input} (id INT, value STRING)
+           DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+           PROPERTIES ("replication_num" = "1")"""
+    try {
+        arrow_flight_sql """INSERT INTO ${input} VALUES
+               (1, '0001-01-01 00:00:00.000000'),
+               (2, '9999-12-31 23:59:59.999999'),
+               (3, '1969-12-31 23:59:59.999999'),
+               (4, NULL), (5, '0000-12-31 00:00:00.000000')"""
+        // Check that the source value reaches the date conversion instead of 
becoming SQL NULL.
+        assertEquals([['0000-12-31 00:00:00.000000']],
+                arrow_flight_sql("SELECT CAST(CAST(value AS DATETIME(6)) AS 
STRING) FROM ${input} WHERE id = 5"))
+        for (def scale : [0, 3, 6]) {
+            expectRangeError("SELECT CAST(value AS DATETIME(${scale})) AS 
event_time FROM ${input} WHERE id = 5")
+        }
+        expectRangeError("SELECT CAST('0000-12-31 00:00:00' AS DATETIME(6)) AS 
event_time")
+        for (def expression : ["array(CAST(value AS DATETIME(6)))",
+                                "named_struct('child', CAST(value AS 
DATETIME(6)))",
+                                "map('key', CAST(value AS DATETIME(6)))",
+                                "map(CAST(value AS DATETIME(6)), 'value')"]) {
+            expectRangeError("SELECT ${expression} AS nested FROM ${input} 
WHERE id = 5")
+        }
+        expectRangeError("SELECT CAST(value AS DATETIME(6)) AS event_time FROM 
${input} ORDER BY id")
+        flight.createStatement().withCloseable { statement ->
+            statement.executeQuery("SELECT CAST(value AS DATETIME(6)) FROM 
${input} WHERE id <= 4 ORDER BY id")
+                    .withCloseable { rows ->
+                // Typed access preserves proleptic calendar dates and 
microseconds.
+                for (def expected : ["0001-01-01T00:00:00", 
"9999-12-31T23:59:59.999999",
+                                      "1969-12-31T23:59:59.999999"]) {
+                    assertTrue(rows.next())
+                    assertEquals(LocalDateTime.parse(expected), 
rows.getObject(1, LocalDateTime.class))
+                }
+                assertTrue(rows.next())
+                assertNull(rows.getObject(1, LocalDateTime.class))
+                assertFalse(rows.next())
+            }
+            statement.executeQuery("SELECT array(CAST(value AS DATETIME(6))) 
FROM ${input} WHERE id = 4")
+                    .withCloseable { rows ->
+                assertTrue(rows.next())
+                def values = rows.getArray(1).getArray()
+                assertEquals(1, values.length)
+                assertNull(values[0])
+                assertFalse(rows.next())
+            }
+        }
+    } finally {
+        arrow_flight_sql "DROP TABLE IF EXISTS ${input}"
+    }
+}
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_flight_utf8_validation.groovy 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_utf8_validation.groovy
new file mode 100644
index 00000000000..f19312b70d3
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_utf8_validation.groovy
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_flight_utf8_validation", "arrow_flight_sql") {
+    // Reuse Flight credentials and avoid the TLS-dependent early return in 
connect().
+    def flight = context.getArrowFlightSqlConnection()
+    def input = "${context.dbName}.flight_utf8_input"
+    def expectInvalidUtf8 = { String query ->
+        try {
+            arrow_flight_sql(query)
+            assertTrue(false, "Expected an invalid UTF-8 Flight result error")
+        } catch (Exception error) {
+            assertTrue(error.toString().contains("Invalid UTF8"), 
error.toString())
+        }
+    }
+    arrow_flight_sql "DROP TABLE IF EXISTS ${input}"
+    arrow_flight_sql """CREATE TABLE ${input} (id INT, encoded STRING)
+           DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+           PROPERTIES ("replication_num" = "1")"""
+    try {
+        arrow_flight_sql """INSERT INTO ${input} VALUES
+               (1, '616263'), (2, 'E4B8ADF09F9880'), (3, ''), (4, NULL),
+               (5, '84'), (6, 'C0AF'), (7, 'EDA080'), (8, 'F4908080'),
+               (9, 'C2'), (10, 'A2')"""
+        for (def id : [5, 6, 7, 8]) {
+            expectInvalidUtf8("SELECT unhex(encoded) AS payload FROM ${input} 
WHERE id = ${id}")
+        }
+        // Constant folding can replace invalid bytes with valid text before 
Flight conversion.
+        // Keep a table scan and skip folding so the constant is evaluated and 
validated on the BE.
+        expectInvalidUtf8("""SELECT /*+ SET_VAR(debug_skip_fold_constant=true) 
*/
+                unhex('84') AS payload FROM ${input} WHERE id = 1""")
+        // These bytes are valid only when concatenated; each row must be 
valid independently.
+        expectInvalidUtf8("SELECT unhex(encoded) AS payload FROM ${input} 
WHERE id IN (9, 10) ORDER BY id")
+        for (def expression : ["array(unhex(encoded))",
+                                "named_struct('child', unhex(encoded))",
+                                "map('key', unhex(encoded))",
+                                "map(unhex(encoded), 'value')"]) {
+            expectInvalidUtf8("SELECT ${expression} AS nested FROM ${input} 
WHERE id = 5")
+        }
+        // Reaching an invalid value after valid rows must fail rather than 
publish malformed text.
+        expectInvalidUtf8("SELECT unhex(encoded) AS payload FROM ${input} 
ORDER BY id")
+        assertEquals([['abc'], ['中😀'], [''], [null]],
+                arrow_flight_sql("SELECT unhex(encoded) FROM ${input} WHERE id 
<= 4 ORDER BY id"))
+        // Binary payloads may contain arbitrary bytes and must keep their 
binary Arrow type.
+        flight.createStatement().withCloseable { statement ->
+            statement.executeQuery("SELECT to_binary(encoded) AS payload FROM 
${input} WHERE id = 5")
+                    .withCloseable { rows ->
+                assertTrue(rows.next())
+                assertTrue(rows.getObject(1) instanceof byte[])
+                assertEquals([0x84], rows.getBytes(1).collect { it & 0xff })
+                assertFalse(rows.next())
+            }
+        }
+    } finally {
+        arrow_flight_sql "DROP TABLE IF EXISTS ${input}"
+    }
+}
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_query_plan_scan_validation.groovy
 
b/regression-test/suites/arrow_flight_sql_p0/test_query_plan_scan_validation.groovy
new file mode 100644
index 00000000000..b1da14270d4
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_query_plan_scan_validation.groovy
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.memory.RootAllocator
+import 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.vector.ipc.ArrowStreamReader
+import org.apache.doris.regression.util.Http
+import org.apache.doris.thrift.BackendService
+import org.apache.doris.thrift.TScanCloseParams
+import org.apache.doris.thrift.TScanNextBatchParams
+import org.apache.doris.thrift.TScanOpenParams
+import org.apache.doris.thrift.TStatusCode
+import org.apache.thrift.protocol.TBinaryProtocol
+import org.apache.thrift.transport.TSocket
+
+// The Spark and Flink Doris connectors read a table through FE's _query_plan 
API and the BE
+// Thrift scanner (open_scanner / get_next / close_scanner), and the BE builds 
the batches with
+// the Arrow Flight convertor. A scan that fails on the BE, here on values 
that convertor
+// rejects, must reach the reader as an error instead of a normal end after 
the rows fetched so
+// far (none here).
+suite("test_query_plan_scan_validation", "arrow_flight_sql") {
+    def db = context.dbName
+    sql "DROP TABLE IF EXISTS query_plan_scan_input"
+    sql """CREATE TABLE query_plan_scan_input (id INT, encoded STRING, 
event_time STRING)
+           DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+           PROPERTIES ("replication_num" = "1")"""
+    sql """INSERT INTO query_plan_scan_input VALUES
+           (1, '616263', '2024-01-01 00:00:00'), (2, '84', '0000-12-31 
00:00:00')"""
+
+    // Reads a _query_plan scan the way the connectors do. Returns the number 
of rows read and
+    // the error get_next reported, or null when it reported the end of the 
scan.
+    def scan = { String query ->
+        def plan = Http.POST(
+                
"http://${context.config.feHttpAddress}/api/${db}/query_plan_scan_input/_query_plan";,
+                [sql: query], true, context.config.feHttpUser, 
context.config.feHttpPassword)
+        assertEquals(0, plan.code, plan.toString())
+        assertEquals(200, plan.data.status, plan.data.toString())
+        // One bucket and one replica: the plan has a single tablet on a 
single BE.
+        def tablets = plan.data.partitions.keySet().collect { it as long }
+        String routing = plan.data.partitions.values().first().routings.first()
+        String host = routing.substring(0, 
routing.lastIndexOf(':')).replace('[', '').replace(']', '')
+        int port = routing.substring(routing.lastIndexOf(':') + 1) as int
+
+        def socket = new TSocket(host, port, 60000)
+        socket.open()
+        def allocator = new RootAllocator()
+        try {
+            def client = new BackendService.Client(new TBinaryProtocol(socket))
+            def params = new TScanOpenParams()
+            params.setCluster("default_cluster")
+            params.setDatabase(db)
+            params.setTable("query_plan_scan_input")
+            params.setTabletIds(tablets)
+            params.setOpaquedQueryPlan(plan.data.opaqued_query_plan)
+            params.setUser(context.config.feHttpUser)
+            params.setPasswd(context.config.feHttpPassword ?: "")
+            def opened = client.openScanner(params)
+            assertEquals(TStatusCode.OK, opened.getStatus().getStatusCode(), 
opened.toString())
+            long rows = 0
+            String error = null
+            try {
+                while (true) {
+                    def batch = client.getNext(new TScanNextBatchParams()
+                            
.setContextId(opened.getContextId()).setOffset(rows))
+                    if (batch.getStatus().getStatusCode() != TStatusCode.OK) {
+                        error = batch.getStatus().getErrorMsgs().join("; ")
+                        break
+                    }
+                    if (batch.isEos()) {
+                        break
+                    }
+                    def reader = new ArrowStreamReader(new 
ByteArrayInputStream(batch.getRows()), allocator)
+                    try {
+                        while (reader.loadNextBatch()) {
+                            rows += reader.getVectorSchemaRoot().getRowCount()
+                        }
+                    } finally {
+                        reader.close()
+                    }
+                }
+            } finally {
+                client.closeScanner(new 
TScanCloseParams().setContextId(opened.getContextId()))
+            }
+            return [rows, error]
+        } finally {
+            allocator.close()
+            socket.close()
+        }
+    }
+
+    // Values the convertor accepts are read in full and the scan ends 
normally.
+    def (validRows, validError) = scan("""SELECT id, unhex(encoded), 
CAST(event_time AS DATETIME(6))
+            FROM ${db}.query_plan_scan_input WHERE id = 1""")
+    assertEquals(1L, validRows)
+    assertNull(validError)
+
+    // Invalid UTF-8 in a STRING result.
+    def (utf8Rows, utf8Error) = scan("SELECT id, unhex(encoded) FROM 
${db}.query_plan_scan_input")
+    assertTrue(utf8Error != null && utf8Error.contains("Invalid UTF8"),
+            "Expected an invalid UTF-8 scan error, read ${utf8Rows} rows, 
error: ${utf8Error}")
+
+    // A DATETIME before year 1.
+    def (timeRows, timeError) = scan("""SELECT id, CAST(event_time AS 
DATETIME(6))
+            FROM ${db}.query_plan_scan_input""")
+    assertTrue(timeError != null && timeError.contains("outside the supported 
0001-9999 range"),
+            "Expected an out-of-range timestamp scan error, read ${timeRows} 
rows, error: ${timeError}")
+}
diff --git 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_all_types_select.groovy
 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_all_types_select.groovy
index 72f443b8b72..45e5ec64784 100644
--- 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_all_types_select.groovy
+++ 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_all_types_select.groovy
@@ -67,8 +67,9 @@ suite("test_remote_doris_all_types_select", "p0,external") {
         );
     """
 
+    // Successful Flight reads require timestamps within the supported 
0001-9999 range.
     sql """
-        INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0000-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"}, '{"k1":"v31", "k2": 300, "k3": 
[123, 456], "k4": [], " [...]
+        INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0001-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"}, '{"k1":"v31", "k2": 300, "k3": 
[123, 456], "k4": [], " [...]
     """
     sql """
         INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t` 
values('2025-05-18 02:00:00.000', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
@@ -108,7 +109,7 @@ suite("test_remote_doris_all_types_select", "p0,external") {
     """
 
     sql """
-        INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t2` 
values('2025-05-18 01:00:00.000', [true], [-128], [-32768], [-2147483648], 
[-9223372036854775808], [-1234567890123456790], [-123.456], [-123456.789], 
[-123457], [-123456789012346], [-1234567890123456789012345678], ['0000-01-01'], 
['0000-01-01 00:00:00'], ['A'], ['Hello'], ['Hello, Doris!'])
+        INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t2` 
values('2025-05-18 01:00:00.000', [true], [-128], [-32768], [-2147483648], 
[-9223372036854775808], [-1234567890123456790], [-123.456], [-123456.789], 
[-123457], [-123456789012346], [-1234567890123456789012345678], ['0000-01-01'], 
['0001-01-01 00:00:00'], ['A'], ['Hello'], ['Hello, Doris!'])
     """
     sql """
         INSERT INTO 
`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t2` 
values('2025-05-18 02:00:00.000', [NULL], [NULL], [NULL], [NULL], [NULL], 
[NULL], [NULL], [NULL], [NULL], [NULL], [NULL], [NULL], [NULL], [NULL], [NULL], 
[NULL])
@@ -172,6 +173,16 @@ suite("test_remote_doris_all_types_select", "p0,external") 
{
         select * from 
`test_remote_doris_all_types_select_catalog`.`test_remote_doris_all_types_select_db`.`test_remote_doris_all_types_select_t3`
 order by id
     """
 
+    // Keep zero-year rejection covered separately from successful all-type 
round trips.
+    sql """INSERT INTO 
test_remote_doris_all_types_select_db.test_remote_doris_all_types_select_t3
+           (id, datetime_0) VALUES ('2025-05-18 02:00:00', '0000-01-01 
00:00:00')"""
+    test {
+        sql """SELECT datetime_0 FROM 
test_remote_doris_all_types_select_catalog
+               
.test_remote_doris_all_types_select_db.test_remote_doris_all_types_select_t3
+               WHERE id = '2025-05-18 02:00:00'"""
+        exception "outside the supported 0001-9999 range"
+    }
+
     sql """ DROP DATABASE IF EXISTS test_remote_doris_all_types_select_db """
     sql """ DROP CATALOG IF EXISTS 
`test_remote_doris_all_types_select_catalog` """
 }
diff --git 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_statistics.groovy
 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_statistics.groovy
index caa307db7fa..60381e2282c 100644
--- 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_statistics.groovy
+++ 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_statistics.groovy
@@ -82,8 +82,9 @@ suite("test_remote_doris_statistics", "p0,external") {
         );
     """
 
+    // Successful Flight reads require timestamps within the supported 
0001-9999 range.
     sql """
-        INSERT INTO 
`test_remote_doris_statistics_db`.`test_remote_doris_statistics_t1` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0000-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"})
+        INSERT INTO 
`test_remote_doris_statistics_db`.`test_remote_doris_statistics_t1` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0001-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"})
     """
     sql """
         INSERT INTO 
`test_remote_doris_statistics_db`.`test_remote_doris_statistics_t1` 
values('2025-05-18 02:00:00.000', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
diff --git 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_table_stats.groovy
 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_table_stats.groovy
index 6227f4b170d..2184b737658 100644
--- 
a/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_table_stats.groovy
+++ 
b/regression-test/suites/external_table_p0/remote_doris/test_remote_doris_table_stats.groovy
@@ -82,8 +82,9 @@ suite("test_remote_doris_table_stats", "p0,external") {
         );
     """
 
+    // Successful Flight reads require timestamps within the supported 
0001-9999 range.
     sql """
-        INSERT INTO 
`test_remote_doris_table_stats_db`.`test_remote_doris_table_stats_t1` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0000-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"})
+        INSERT INTO 
`test_remote_doris_table_stats_db`.`test_remote_doris_table_stats_t1` 
values('2025-05-18 01:00:00.000', true, -128, -32768, -2147483648, 
-9223372036854775808, -1234567890123456790, -123.456, -123456.789, -123457, 
-123456789012346, -1234567890123456789012345678, '1970-01-01', '0001-01-01 
00:00:00', 'A', 'Hello', 'Hello, Doris!', '["apple", "banana", "orange"]', 
{"Emily":101,"age":25} , {11, 3.14, "Emily"})
     """
     sql """
         INSERT INTO 
`test_remote_doris_table_stats_db`.`test_remote_doris_table_stats_t1` 
values('2025-05-18 02:00:00.000', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)


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

Reply via email to