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

Gabriel39 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 978d5c8d34b [fix](be) Compare Iceberg equality-delete keys in the 
wider of the delete and data file types (#69711)
978d5c8d34b is described below

commit 978d5c8d34b90de606e466b704b4a9a72d6bc233
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 08:11:01 2026 +0000

    [fix](be) Compare Iceberg equality-delete keys in the wider of the delete 
and data file types (#69711)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69536, #68267
    
    Problem Summary:
    
    **In short.** FileScannerV2 now compares an Iceberg equality-delete key
    in the wider of the delete file's and the data file's own key types,
    instead of in the one table type the FE sends for the whole scan. For
    some table histories that single type is wrong: the reader then deletes
    rows it must keep, keeps rows it must delete, or fails the scan. This
    follows the review of #69536, where these cases came up.
    
    **Background.** An equality delete removes a data row whose key values
    equal a tombstone's. After a type promotion the two sides can be stored
    in different types: tombstones written before an INT -> LONG promotion
    hold INT values that must still match LONG data. #68267 made
    FileScannerV2 compare in the current schema's type, and for a key that
    the current schema no longer has, the FE sends a type recovered from the
    retained schemas (#69536). The reader casts both the tombstones and the
    data keys to that one type.
    
    **The problem, and what it cost.** One type per scan cannot describe
    every delete file:
    
    | History | Table type from the FE | Delete file | Data file | Before |
    After |
    |---|---|---|---|---|---|
    | `expireSnapshots().cleanExpiredMetadata(true)` removed the schema of a
    schema-only INT -> LONG promotion of a dropped key | INT | LONG
    4294967296 | INT, one NULL key | the tombstone becomes NULL in INT and
    deletes the NULL-key row | row kept |
    | A read pinned before a later DATE -> TIMESTAMP_NS promotion of the key
    | TIMESTAMP_NS | DATE 1000-01-01 | DATE 1000-01-01 | scan fails:
    `TIMESTAMP_NS overflow when casting row ...` | row deleted |
    | No retained schema gives the key a concrete type, so the FE sends
    Iceberg `unknown` as STRING | STRING | TIMESTAMP_NS 2024-01-02 00:00 |
    DATE 2024-01-02 | `2024-01-02` != `2024-01-02 00:00:00.000000000`, the
    row stays visible | row deleted |
    | The delete was prepared under a TIMESTAMP_NS schema that the table
    later abandoned for TIMESTAMP | TIMESTAMP | TIMESTAMP_NS
    12:00:00.000000500 | TIMESTAMP 12:00:00.000000 | the tombstone is
    rounded to microseconds and deletes the row | row kept |
    
    Three of the four return wrong rows without any error. The V1 reader is
    not changed by this PR.
    
    **How this PR fixes it.**
    
    - The delete block keeps the delete file's own key types. When the
    predicate for a data file is built, each key is compared in the wider of
    the delete file's type and the data file's type
    (`equality_delete_comparison_type`). The accepted widenings are the
    Iceberg promotions (INT -> LONG, FLOAT -> DOUBLE, decimal precision,
    DATE -> TIMESTAMP and DATE -> TIMESTAMP_NS), a wider fractional scale of
    the same date-time kind, wider integers, STRING -> VARBINARY (an Iceberg
    BINARY key decodes as either one, depending on
    `enable_mapping_varbinary`), and TIMESTAMP -> TIMESTAMP_NS. The last one
    is not an Iceberg promotion; it is accepted for the abandoned-schema
    case above, because scaling microseconds to nanoseconds is exact. When
    the table field is `timestamptz`, a `DATETIMEV2` key (Parquet INT96,
    which carries no instant annotation, as Spark and Hive write it) and a
    `TIMESTAMPTZ` key compare as `TIMESTAMPTZ` at the larger scale, the
    `DATETIMEV2` side converted with the session time zone as the projection
    converts that column. When the table field is the STRING placeholder the
    FE sends for an Iceberg TIME, which Doris cannot represent, an ORC TIME
    key (a LONG annotated `iceberg.long-type=TIME`, read as `BIGINT`) and a
    Parquet one (`TIME(MICROS)`, read as `TIMEV2(6)`) compare as
    `TIMEV2(6)`, the `BIGINT` side converted exactly as microseconds from
    midnight by a dedicated expression (`CAST(BIGINT AS TIME)` reads the
    integer's digits as HHMMSS), and a value outside
    00:00:00..23:59:59.999999 fails the scan. Two types that convert in
    neither direction fail the scan instead of being compared in a third
    type.
    - The data keys are cast to that type. When it is the data file's wider
    type, the tombstones are widened once per scanner, and the converted
    delete block and its hash index are cached next to the original
    (`_convert_equality_delete_filter`). A table without type changes is
    compared as before, with no cast and no copy.
    - A key that the data file lacks is compared through its initial
    default, in the wider of the delete file's type and the table type. When
    that default is NULL (no initial default, or a struct default `{}` whose
    leaf has none) the key is NULL in that file whatever its table type, so
    it is compared in the delete file's type (an `unknown` key arrives as
    STRING). A non-NULL default behind the STRING placeholder of an Iceberg
    TIME is Iceberg's text of the value (`00:00`), with at most six
    fractional digits, so it is parsed exactly in the tombstones' type,
    `TIMEV2` (`TIMEV2(6)` against an ORC TIME tombstone), by the strict
    comparison cast. A non-NULL default of a `timestamptz_ns` key fails the
    scan with `NotSupported`: V2 reads its tombstones as `TIMESTAMPTZ(6)`
    (Parquet floors the nanoseconds, ORC rounds them), so no parse of the
    nanosecond default compares exactly; the default epoch would match a
    tombstone 100 ns later and delete every row of the file.
    - `AncestorNullDefaultExpr`, which gives a missing child the NULL of its
    NULL parent and its default otherwise, now opens its children. Its
    `open()` returned before opening them, because `prepare()` had already
    set the flag its guard checks, so a default cast to the comparison type
    below it was never opened and aborted the BE with `Check failed:
    _open_finished` in `Cast`.
    - The comparison casts are strict whatever the session's cast mode,
    through a new `strict` flag on `EvolutionCast`: a non-strict overflow
    would turn a key into NULL and let a NULL tombstone delete it. DATE ->
    TIMESTAMP_NS was already strict; other `EvolutionCast` users are
    unchanged.
    - A delete file without rows is skipped before its keys are resolved: it
    deletes nothing, but the predicate evaluates its keys first, so its
    strict casts could fail the scan (a DATE key 1000-01-01 against an empty
    TIMESTAMP_NS delete file).
    - The table types from the FE still resolve table fields and the
    defaults of missing keys; the delete-file cache key is unchanged.
    
    **Results.**
    
    - 14 new BE unit tests in `iceberg_reader_test.cpp`: the four histories
    above; INT tombstones against LONG, INT and LONG data files of one scan
    (the third split reuses the cached widened copy); a missing key's INT
    default against a LONG tombstone; a missing key without a default under
    an `unknown` table type; a microsecond key outside the nanosecond range,
    which must fail; and incomparable key types. With the reader before this
    PR, 6 of them fail; the other three pin behavior the old code shares.
    Removing the TIMESTAMP -> TIMESTAMP_NS widening fails 2, and making the
    casts non-strict fails 1. Three more came from review: an INT96 data key
    against an adjusted-UTC tombstone in Asia/Shanghai, a NULL leaf under a
    struct default `{}`, and an empty TIMESTAMP_NS delete file against a
    DATE key; removing the INT96 rule, the NULL-leaf walk or the empty-file
    skip fails its test. Two more came from the third round: Iceberg TIME
    keys across all four ORC / Parquet data and delete file pairs, with NULL
    and non-NULL tombstones, and a nested Iceberg TIME key added with a text
    default, against Parquet TIME and ORC TIME tombstones, under NULL and
    non-NULL parents. Removing the TIME pair rule or the default parsing, or
    converting the ORC TIME with a SQL cast, fails them; leaving
    `AncestorNullDefaultExpr`'s children unopened aborts. One more came from
    the fourth round: a nested `timestamptz_ns` key with the defaults
    `...0000001`, the epoch and `...0000005` fails against an epoch, a 100
    ns and a 500 ns tombstone, while a key without a default still matches
    NULL tombstones only; parsing those defaults again fails it.
    - End to end on a local cluster: a v2 table in a `file://` Hadoop
    catalog, built with the Iceberg 1.12.0 Java API: `k INT` with rows (1,
    NULL), (2, 0), (3, 1); `k` promoted to LONG without a snapshot; a delete
    of `k = 4294967296` written under that schema; `k` dropped; the delete
    committed; then
    `expireSnapshots().retainLast(2).cleanExpiredMetadata(true)`, which
    leaves schema 0 (`k INT`) and schema 2 (no `k`). `SELECT id` through
    FileScannerV2 returned 2, 3 before this PR and returns 1, 2, 3 with it.
    
    branch-4.1 and branch-4.2 have the same FileScannerV2 code.
    
    ### Release note
    
    Fix FileScannerV2 comparing Iceberg equality-delete keys in a single
    scan-wide type, which could delete or keep the wrong rows, or fail the
    scan, after a key's type changed (for example a schema-only promotion
    removed by `cleanExpiredMetadata`, or a key whose retained schemas only
    have it as `unknown`).
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [x] Unit Test
    - `run-be-ut.sh --run
    
--filter='IcebergV2ReaderTest.*:*EqualityDelete*:*ColumnMapper*:*EvolutionCast*:*TableReader*'`:
    526 of 526 pass on macOS arm64 (ASAN), including the 14 new cases.
    - Rebased onto master `43f9ceb48d7`: #69639 moved the strict-mode
    override of `EvolutionCast` from `prepare()` into
    `configure_cast_options()`, so the new `strict` flag now sets
    `CastOptions::strict_mode` there, next to the existing DATE ->
    TIMESTAMP_NS rule. The run above is on the content before the rebase
    (the third-round commit applies to both unchanged); the 67 TUs that this
    PR changes or that master changed since `5caaeb3e3d4` were
    syntax-checked against the rebased tree with the UT build flags, and CI
    runs the suites again.
        - [x] Manual test (add detailed scripts or steps below)
    - The local end-to-end check described under Results, with an ASAN BE
    built from this change.
        - [ ] No need to test or manual test. Explain why:
    - Behavior changed:
        - [ ] No.
    - [x] Yes. FileScannerV2 compares equality-delete keys per delete file
    and data file; two key types that convert in neither direction now fail
    the scan; the comparison casts are strict.
    - Does this need documentation?
        - [x] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 be/src/format_v2/expr/evolution_cast.cpp        |   6 +-
 be/src/format_v2/expr/evolution_cast.h          |  15 +-
 be/src/format_v2/table/iceberg_reader.cpp       | 542 +++++++++++++--
 be/src/format_v2/table/iceberg_reader.h         |  20 +-
 be/test/format_v2/table/iceberg_reader_test.cpp | 836 +++++++++++++++++++++++-
 5 files changed, 1350 insertions(+), 69 deletions(-)

diff --git a/be/src/format_v2/expr/evolution_cast.cpp 
b/be/src/format_v2/expr/evolution_cast.cpp
index d8d3133b5e0..b845fa9706b 100644
--- a/be/src/format_v2/expr/evolution_cast.cpp
+++ b/be/src/format_v2/expr/evolution_cast.cpp
@@ -75,8 +75,10 @@ Status EvolutionCast::execute_column_impl(VExprContext* 
context, const Block* bl
 }
 
 void EvolutionCast::configure_cast_options(CastOptions& options) const {
-    if (remove_nullable(_children[0]->data_type())->get_primitive_type() == 
TYPE_DATEV2 &&
-        remove_nullable(_data_type)->get_primitive_type() == 
TYPE_TIMESTAMP_NS) {
+    if (_strict) {
+        options.strict_mode = true;
+    } else if 
(remove_nullable(_children[0]->data_type())->get_primitive_type() == 
TYPE_DATEV2 &&
+               remove_nullable(_data_type)->get_primitive_type() == 
TYPE_TIMESTAMP_NS) {
         // Iceberg DATE promotion must fail outside the nanosecond range, even 
in non-strict SQL
         // sessions. Otherwise a localized IS NOT NULL filter can silently 
discard retained rows.
         options.strict_mode = true;
diff --git a/be/src/format_v2/expr/evolution_cast.h 
b/be/src/format_v2/expr/evolution_cast.h
index c7e0e306018..63616fc1b8c 100644
--- a/be/src/format_v2/expr/evolution_cast.h
+++ b/be/src/format_v2/expr/evolution_cast.h
@@ -26,15 +26,19 @@ class EvolutionCast final : public Cast {
     ENABLE_FACTORY_CREATOR(EvolutionCast);
 
 public:
-    explicit EvolutionCast(const DataTypePtr& type, bool 
truncate_datetimev2_precision = false)
-            : Cast(type), 
_truncate_datetimev2_precision(truncate_datetimev2_precision) {}
+    explicit EvolutionCast(const DataTypePtr& type, bool 
truncate_datetimev2_precision = false,
+                           bool strict = false)
+            : Cast(type),
+              _truncate_datetimev2_precision(truncate_datetimev2_precision),
+              _strict(strict) {}
 
     Status execute_column_impl(VExprContext* context, const Block* block, 
const Selector* selector,
                                size_t count, ColumnPtr& result_column) const 
override;
     std::string debug_string() const override;
     Status clone_node(VExprSPtr* cloned_expr) const override {
         DORIS_CHECK(cloned_expr != nullptr);
-        *cloned_expr = EvolutionCast::create_shared(_data_type, 
_truncate_datetimev2_precision);
+        *cloned_expr =
+                EvolutionCast::create_shared(_data_type, 
_truncate_datetimev2_precision, _strict);
         return Status::OK();
     }
 
@@ -43,6 +47,11 @@ protected:
 
 private:
     bool _truncate_datetimev2_precision = false;
+    // Fail on every value the target type cannot hold, whatever the session's 
strict cast mode.
+    // Iceberg equality deletes compare keys after a widening cast, where a 
non-strict overflow
+    // would turn a key into NULL and match NULL tombstones: a data key 
TIMESTAMP 1600-01-01 cast
+    // to TIMESTAMP_NS would become NULL and be deleted by a NULL tombstone.
+    bool _strict = false;
 };
 
 } // namespace doris::format
diff --git a/be/src/format_v2/table/iceberg_reader.cpp 
b/be/src/format_v2/table/iceberg_reader.cpp
index a879a0d1f89..9463fe0d9b0 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -42,8 +42,11 @@
 #include "core/data_type/data_type_map.h"
 #include "core/data_type/data_type_number.h"
 #include "core/data_type/data_type_struct.h"
+#include "core/data_type/data_type_time.h"
+#include "core/data_type/data_type_timestamptz.h"
 #include "core/data_type/define_primitive_type.h"
 #include "core/field.h"
+#include "core/value/time_value.h"
 #include "exprs/vliteral.h"
 #include "exprs/vslot_ref.h"
 #include "format/table/deletion_vector_reader.h"
@@ -846,7 +849,12 @@ public:
 
     Status open(RuntimeState* state, VExprContext* context,
                 FunctionContext::FunctionStateScope scope) override {
-        RETURN_IF_ERROR_OR_PREPARED(VExpr::open(state, context, scope));
+        DCHECK(_prepare_finished);
+        // Opens the children, as EqualityDeletePredicate does. 
RETURN_IF_ERROR_OR_PREPARED would
+        // return at once, since prepare() has set `_prepared`, leaving a 
default cast to the
+        // comparison type, e.g. the TIME text `00:00` parsed as TIMEV2, 
unopened, so executing it
+        // fails "Check failed: _open_finished" in Cast.
+        RETURN_IF_ERROR(VExpr::open(state, context, scope));
         _open_finished = true;
         return Status::OK();
     }
@@ -915,6 +923,108 @@ private:
     std::string _expr_name;
 };
 
+// Converts an Iceberg TIME read from ORC, microseconds from midnight as 
BIGINT, to TIMEV2, which
+// holds microseconds as well (as a double, exactly): 3600000001 becomes 
01:00:00.000001. An
+// ordinary CAST(BIGINT AS TIME) reads the integer's decimal digits as HHMMSS 
instead, turning
+// 3600 into 00:36:00. A value outside 00:00:00..23:59:59.999999 is no Iceberg 
TIME and fails, as
+// the strict comparison casts do.
+class IcebergTimeMicrosExpr final : public VExpr {
+public:
+    IcebergTimeMicrosExpr(DataTypePtr data_type, std::string expr_name)
+            : VExpr(std::move(data_type), false), 
_expr_name(std::move(expr_name)) {
+        _node_type = TExprNodeType::FUNCTION_CALL;
+        DORIS_CHECK(remove_nullable(_data_type)->get_primitive_type() == 
TYPE_TIMEV2);
+    }
+
+    Status prepare(RuntimeState* state, const RowDescriptor& row_desc,
+                   VExprContext* context) override {
+        RETURN_IF_ERROR_OR_PREPARED(VExpr::prepare(state, row_desc, context));
+        _prepare_finished = true;
+        return Status::OK();
+    }
+
+    Status open(RuntimeState* state, VExprContext* context,
+                FunctionContext::FunctionStateScope scope) override {
+        DCHECK(_prepare_finished);
+        // As in AncestorNullDefaultExpr: the child, e.g. a nested key's 
NestedStructFieldExpr,
+        // must be opened, which RETURN_IF_ERROR_OR_PREPARED would skip after 
prepare().
+        RETURN_IF_ERROR(VExpr::open(state, context, scope));
+        _open_finished = true;
+        return Status::OK();
+    }
+
+    void close(VExprContext* context, FunctionContext::FunctionStateScope 
scope) override {
+        VExpr::close(context, scope);
+    }
+
+    Status execute_column_impl(VExprContext* context, const Block* block, 
const Selector* selector,
+                               size_t count, ColumnPtr& result_column) const 
override {
+        DORIS_CHECK(_children.size() == 1);
+        ColumnPtr input;
+        RETURN_IF_ERROR(_children.front()->execute_column(context, block, 
selector, count, input));
+        input = input->convert_to_full_column_if_const();
+        DORIS_CHECK(input->size() == count);
+        const NullMap* null_map = nullptr;
+        const IColumn* micros_column = input.get();
+        if (const auto* nullable = 
check_and_get_column<ColumnNullable>(*input);
+            nullable != nullptr) {
+            null_map = &nullable->get_null_map_data();
+            micros_column = &nullable->get_nested_column();
+        }
+        DORIS_CHECK(_data_type->is_nullable() == (null_map != nullptr));
+        const auto& micros = assert_cast<const 
ColumnInt64&>(*micros_column).get_data();
+        auto times = ColumnTimeV2::create();
+        auto& values = times->get_data();
+        values.resize_fill(count, 0);
+        for (size_t row = 0; row < count; ++row) {
+            if (null_map != nullptr && (*null_map)[row] != 0) {
+                continue;
+            }
+            if (micros[row] < 0 || micros[row] >= MICROS_PER_DAY) {
+                return Status::InvalidArgument(
+                        "Iceberg TIME value {} of {} is outside 
00:00:00..23:59:59.999999",
+                        micros[row], _expr_name);
+            }
+            values[row] = static_cast<double>(micros[row]);
+        }
+        if (null_map == nullptr) {
+            result_column = std::move(times);
+        } else {
+            auto nulls = ColumnUInt8::create();
+            nulls->get_data().assign(null_map->begin(), null_map->end());
+            result_column = ColumnNullable::create(std::move(times), 
std::move(nulls));
+        }
+        return Status::OK();
+    }
+
+    const std::string& expr_name() const override { return _expr_name; }
+
+    Status clone_node(VExprSPtr* cloned_expr) const override {
+        DORIS_CHECK(cloned_expr != nullptr);
+        *cloned_expr = std::make_shared<IcebergTimeMicrosExpr>(*this);
+        return Status::OK();
+    }
+
+private:
+    static constexpr int64_t MICROS_PER_DAY = 24 * 
TimeValue::ONE_HOUR_MICROSECONDS;
+
+    std::string _expr_name;
+};
+
+// The struct element of `parent` that holds `child` on an equality key's 
path: matched by field id
+// when both carry one, otherwise by name.
+static size_t equality_delete_child_index(const format::ColumnDefinition& 
parent,
+                                          const format::ColumnDefinition& 
child) {
+    const auto child_it = std::ranges::find_if(parent.children, [&child](const 
auto& candidate) {
+        if (candidate.has_identifier_field_id() && 
child.has_identifier_field_id()) {
+            return candidate.get_identifier_field_id() == 
child.get_identifier_field_id();
+        }
+        return candidate.name == child.name;
+    });
+    DORIS_CHECK(child_it != parent.children.end());
+    return cast_set<size_t>(child_it - parent.children.begin());
+}
+
 static Status build_nested_equality_delete_key_expr(
         const std::vector<const format::ColumnDefinition*>& path, VExprSPtr 
root_expr,
         VExprSPtr* key_expr) {
@@ -939,16 +1049,7 @@ static Status build_nested_equality_delete_key_expr(
                     "Iceberg equality delete field {} has non-struct ancestor 
{}", child->name,
                     parent->name);
         }
-        const auto child_it =
-                std::ranges::find_if(parent->children, [child](const auto& 
candidate) {
-                    if (candidate.has_identifier_field_id() && 
child->has_identifier_field_id()) {
-                        return candidate.get_identifier_field_id() ==
-                               child->get_identifier_field_id();
-                    }
-                    return candidate.name == child->name;
-                });
-        DORIS_CHECK(child_it != parent->children.end());
-        child_indexes.push_back(cast_set<size_t>(child_it - 
parent->children.begin()));
+        child_indexes.push_back(equality_delete_child_index(*parent, *child));
         expr_name += "." + child->name;
     }
     if (!child_indexes.empty()) {
@@ -974,6 +1075,208 @@ static Status build_equality_delete_key_expr(
     return build_nested_equality_delete_key_expr(path, std::move(root_expr), 
key_expr);
 }
 
+// Whether a missing key's default is NULL. `missing_path` runs from the 
missing root to the key,
+// and the root's initial default is walked down it with the key expression's 
child matching. The
+// root having a default is not enough: a missing `payload` defaulting to the 
struct `{}` takes
+// each child's own default, so payload.k is NULL when `k` has none.
+static Status equality_delete_default_leaf_is_null(
+        const std::vector<const format::ColumnDefinition*>& missing_path, 
bool* is_null) {
+    DORIS_CHECK(!missing_path.empty());
+    DORIS_CHECK(is_null != nullptr);
+    const auto* root = missing_path.front();
+    DORIS_CHECK(root != nullptr);
+    if (!root->initial_default_value.has_value()) {
+        *is_null = true;
+        return Status::OK();
+    }
+    std::deque<std::string> binary_storage;
+    Field root_default;
+    RETURN_IF_ERROR(
+            build_v2_initial_default_field(*root, root->type, &binary_storage, 
&root_default));
+    const Field* value = &root_default;
+    for (size_t index = 1; index < missing_path.size() && !value->is_null(); 
++index) {
+        DORIS_CHECK(value->get_type() == TYPE_STRUCT);
+        const auto& children = value->get<TYPE_STRUCT>();
+        const size_t child_index =
+                equality_delete_child_index(*missing_path[index - 1], 
*missing_path[index]);
+        DORIS_CHECK(child_index < children.size());
+        value = &children[child_index];
+    }
+    *is_null = value->is_null();
+    return Status::OK();
+}
+
+static int equality_delete_integer_width(PrimitiveType type) {
+    switch (type) {
+    case TYPE_TINYINT:
+        return 1;
+    case TYPE_SMALLINT:
+        return 2;
+    case TYPE_INT:
+        return 4;
+    case TYPE_BIGINT:
+        return 8;
+    case TYPE_LARGEINT:
+        return 16;
+    default:
+        return 0;
+    }
+}
+
+// Whether every `from` value converts exactly to a `to` value that compares 
equal to the same
+// Iceberg value. These are the Iceberg promotions (INT -> LONG, FLOAT -> 
DOUBLE, decimal
+// precision, DATE -> TIMESTAMP and DATE -> TIMESTAMP_NS), a wider fractional 
scale of the same
+// date-time kind, and STRING -> VARBINARY: an Iceberg BINARY key decodes as 
either one, depending
+// on enable_mapping_varbinary, and both hold the same bytes. TIMESTAMP -> 
TIMESTAMP_NS is not an
+// Iceberg promotion, but a delete can be prepared under a nanosecond schema 
that the table later
+// abandons. Scaling microseconds by 1000 is exact, whereas rounding the 
tombstone
+// 12:00:00.000000500 to microseconds would delete the data key 
12:00:00.000000.
+static bool is_equality_delete_widening(const DataTypePtr& from, const 
DataTypePtr& to) {
+    const auto from_type = remove_nullable(from);
+    const auto to_type = remove_nullable(to);
+    if (from_type->equals(*to_type)) {
+        return true;
+    }
+    const auto from_primitive = from_type->get_primitive_type();
+    const auto to_primitive = to_type->get_primitive_type();
+    if (const int from_width = equality_delete_integer_width(from_primitive); 
from_width > 0) {
+        return equality_delete_integer_width(to_primitive) >= from_width;
+    }
+    switch (from_primitive) {
+    case TYPE_FLOAT:
+        return to_primitive == TYPE_DOUBLE;
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256:
+        return is_decimalv3(to_primitive) && to_type->get_scale() == 
from_type->get_scale() &&
+               to_type->get_precision() >= from_type->get_precision();
+    case TYPE_DATEV2:
+        return to_primitive == TYPE_DATETIMEV2 || to_primitive == 
TYPE_TIMESTAMP_NS;
+    case TYPE_DATETIMEV2:
+        return (to_primitive == TYPE_DATETIMEV2 &&
+                to_type->get_scale() >= from_type->get_scale()) ||
+               to_primitive == TYPE_TIMESTAMP_NS;
+    case TYPE_TIMESTAMPTZ:
+    case TYPE_TIMEV2:
+        return to_primitive == from_primitive && to_type->get_scale() >= 
from_type->get_scale();
+    case TYPE_VARCHAR:
+        return to_primitive == TYPE_STRING || to_primitive == TYPE_VARBINARY;
+    case TYPE_STRING:
+        return to_primitive == TYPE_VARBINARY;
+    default:
+        return false;
+    }
+}
+
+// Iceberg equality is by value, so a key is compared in the wider of the 
delete file's and the
+// data file's key types, and neither side is narrowed. One scan-wide table 
type cannot serve: after
+// expireSnapshots().cleanExpiredMetadata(true) drops the schema of a 
schema-only INT -> LONG
+// promotion, the table type of a dropped key is INT while its tombstones are 
LONG, and casting
+// 4294967296 to INT yields NULL, which deletes the rows whose key is NULL. 
`table_type` is the
+// key's type in the scan's schema metadata; it decides only how a timestamptz 
field read as
+// DATETIMEV2 and a TIME field behind a STRING placeholder compare.
+static Status equality_delete_comparison_type(int field_id, const DataTypePtr& 
delete_type,
+                                              const DataTypePtr& data_type,
+                                              const DataTypePtr& table_type,
+                                              const std::string& 
data_file_path,
+                                              DataTypePtr* comparison_type) {
+    DORIS_CHECK(delete_type != nullptr);
+    DORIS_CHECK(data_type != nullptr);
+    DORIS_CHECK(table_type != nullptr);
+    DORIS_CHECK(comparison_type != nullptr);
+    if (is_equality_delete_widening(delete_type, data_type)) {
+        *comparison_type = remove_nullable(data_type);
+        return Status::OK();
+    }
+    if (is_equality_delete_widening(data_type, delete_type)) {
+        *comparison_type = remove_nullable(delete_type);
+        return Status::OK();
+    }
+    const auto delete_value_type = remove_nullable(delete_type);
+    const auto data_value_type = remove_nullable(data_type);
+    const auto delete_primitive = delete_value_type->get_primitive_type();
+    const auto data_primitive = data_value_type->get_primitive_type();
+    // Parquet INT96 carries no instant annotation, so a timestamptz key in a 
Spark or Hive file
+    // that Iceberg imported reads as DATETIMEV2(6), while an adjusted-UTC 
tombstone of the same
+    // field, as a Flink upsert writes it, reads as TIMESTAMPTZ(6). Neither 
widens to the other.
+    // The projection converts that DATETIMEV2 to the table's TIMESTAMPTZ in 
the session time zone
+    // and the comparison cast does the same, so a tombstone matches exactly 
the value a query
+    // returns: in Asia/Shanghai the key 2024-01-01 08:00:00 compares as 
2024-01-01 00:00:00 UTC.
+    if (remove_nullable(table_type)->get_primitive_type() == TYPE_TIMESTAMPTZ 
&&
+        ((delete_primitive == TYPE_DATETIMEV2 && data_primitive == 
TYPE_TIMESTAMPTZ) ||
+         (delete_primitive == TYPE_TIMESTAMPTZ && data_primitive == 
TYPE_DATETIMEV2))) {
+        *comparison_type = std::make_shared<DataTypeTimeStampTz>(
+                std::max(delete_value_type->get_scale(), 
data_value_type->get_scale()));
+        return Status::OK();
+    }
+    // The FE sends an Iceberg TIME, which Doris cannot represent, as a STRING 
placeholder, so only
+    // the files type its values: microseconds from midnight in both formats, 
but ORC stores them as
+    // a LONG annotated iceberg.long-type=TIME, which reads as BIGINT, while 
Parquet's TIME(MICROS)
+    // reads as TIMEV2(6). Such a pair compares as TIMEV2(6), which holds 
microseconds too, so the
+    // ORC key 3600000001 matches the Parquet tombstone 01:00:00.000001; 
cast_equality_delete_key
+    // converts the BIGINT side without a SQL cast. Under any other table type 
the pair fails below.
+    if (remove_nullable(table_type)->get_primitive_type() == TYPE_STRING &&
+        ((delete_primitive == TYPE_BIGINT && data_primitive == TYPE_TIMEV2) ||
+         (delete_primitive == TYPE_TIMEV2 && data_primitive == TYPE_BIGINT))) {
+        *comparison_type = 
std::make_shared<DataTypeTimeV2>(TimeValue::MICROS_SCALE);
+        return Status::OK();
+    }
+    // No promotion leads from one type to the other, so no third type 
compares them faithfully:
+    // STRING tombstones cast to an INT key would delete the row with key 1 
for the string "1".
+    return Status::NotSupported(
+            "Iceberg equality delete field id {} is {} in the delete file and 
{} for data file "
+            "{}, and neither type converts exactly to the other",
+            field_id, remove_nullable(delete_type)->get_name(),
+            remove_nullable(data_type)->get_name(), data_file_path);
+}
+
+// The comparison type of a missing key whose non-NULL initial default sits 
behind the STRING
+// placeholder of an Iceberg TIME (see equality_delete_comparison_type), or 
nullptr for any other
+// table or tombstone type. The default is Iceberg's text of the value, 
`00:00` for midnight, with at
+// most six fractional digits, so the strict comparison cast parses it into 
the tombstones' type
+// exactly, and text that does not parse fails the scan; an ORC TIME 
tombstone, BIGINT, compares in
+// TIMEV2(6) as a present key does. Compared as text, the default `00:00` 
never matched a tombstone
+// printing 00:00:00.000000. A timestamptz_ns default is rejected by the 
caller instead.
+static DataTypePtr equality_delete_placeholder_default_type(const DataTypePtr& 
table_type,
+                                                            const DataTypePtr& 
delete_type) {
+    if (remove_nullable(table_type)->get_primitive_type() != TYPE_STRING) {
+        return nullptr;
+    }
+    switch (remove_nullable(delete_type)->get_primitive_type()) {
+    case TYPE_TIMEV2:
+        return remove_nullable(delete_type);
+    case TYPE_BIGINT:
+        return std::make_shared<DataTypeTimeV2>(TimeValue::MICROS_SCALE);
+    default:
+        return nullptr;
+    }
+}
+
+// Makes `key_expr` yield `comparison_type`, keeping its nullability.
+static VExprSPtr cast_equality_delete_key(VExprSPtr key_expr, const 
DataTypePtr& comparison_type) {
+    DORIS_CHECK(key_expr != nullptr);
+    DORIS_CHECK(comparison_type != nullptr);
+    const auto& source_type = key_expr->data_type();
+    if 
(remove_nullable(source_type)->equals(*remove_nullable(comparison_type))) {
+        return key_expr;
+    }
+    const auto result_type = source_type->is_nullable() ? 
make_nullable(comparison_type)
+                                                        : 
remove_nullable(comparison_type);
+    // Only an ORC Iceberg TIME key compares a BIGINT as TIMEV2 
(equality_delete_comparison_type),
+    // and its value is microseconds, which CAST(BIGINT AS TIME) would read as 
HHMMSS digits.
+    if (remove_nullable(source_type)->get_primitive_type() == TYPE_BIGINT &&
+        remove_nullable(comparison_type)->get_primitive_type() == TYPE_TIMEV2) 
{
+        auto time_expr =
+                std::make_shared<IcebergTimeMicrosExpr>(result_type, 
key_expr->expr_name());
+        time_expr->add_child(std::move(key_expr));
+        return time_expr;
+    }
+    auto cast_expr = EvolutionCast::create_shared(result_type, false, true);
+    cast_expr->add_child(std::move(key_expr));
+    return cast_expr;
+}
+
 Status IcebergTableReader::annotate_projected_column(const TFileScanSlotInfo& 
slot_info,
                                                      
format::ProjectedColumnBuildContext* context,
                                                      format::ColumnDefinition* 
column) const {
@@ -1173,16 +1476,20 @@ std::string IcebergTableReader::debug_string() const {
             equality_filters << ", ";
         }
         const auto& filter = _equality_delete_filters[idx];
+        const auto append_types = [&equality_filters](const 
std::vector<DataTypePtr>& types) {
+            for (size_t type_idx = 0; type_idx < types.size(); ++type_idx) {
+                if (type_idx > 0) {
+                    equality_filters << ", ";
+                }
+                equality_filters << (types[type_idx] == nullptr ? "null"
+                                                                : 
types[type_idx]->get_name());
+            }
+        };
         equality_filters << "EqualityDeleteFilter{field_ids="
                          << join_values_for_debug(filter.field_ids) << ", 
key_types=[";
-        for (size_t type_idx = 0; type_idx < filter.key_types.size(); 
++type_idx) {
-            if (type_idx > 0) {
-                equality_filters << ", ";
-            }
-            equality_filters << (filter.key_types[type_idx] == nullptr
-                                         ? "null"
-                                         : 
filter.key_types[type_idx]->get_name());
-        }
+        append_types(filter.key_types);
+        equality_filters << "], delete_key_types=[";
+        append_types(filter.delete_key_types);
         equality_filters << "], delete_block_rows=" << 
filter.delete_block.rows()
                          << ", delete_block_columns=" << 
filter.delete_block.columns() << "}";
     }
@@ -1542,9 +1849,10 @@ void 
IcebergTableReader::_append_equality_delete_row_count_carrier(
 Status IcebergTableReader::_build_missing_equality_delete_key_expr(
         const EqualityDeleteFilter& filter, size_t key_idx,
         const EqualityDeleteColumnPath& data_path, format::FileScanRequest* 
const request,
-        VExprSPtr* const key_expr) {
+        VExprSPtr* const key_expr, DataTypePtr* const comparison_type) {
     DORIS_CHECK(request != nullptr);
     DORIS_CHECK(key_expr != nullptr);
+    DORIS_CHECK(comparison_type != nullptr);
     auto table_path = 
_find_table_column_path_by_field_id(filter.field_ids[key_idx],
                                                           
filter.key_types[key_idx], true);
     if (!table_path.has_value() || data_path.size() >= table_path->size()) {
@@ -1569,11 +1877,41 @@ Status 
IcebergTableReader::_build_missing_equality_delete_key_expr(
     const auto* table_leaf = missing_path.back();
     DORIS_CHECK(table_leaf != nullptr);
     DORIS_CHECK(table_leaf->type != nullptr);
-    if (!table_leaf->type->equals(*filter.key_types[key_idx])) {
-        auto cast_expr = 
EvolutionCast::create_shared(filter.key_types[key_idx]);
-        cast_expr->add_child(default_expr);
-        default_expr = std::move(cast_expr);
+    bool default_is_null = false;
+    RETURN_IF_ERROR(equality_delete_default_leaf_is_null(missing_path, 
&default_is_null));
+    if (default_is_null) {
+        // A NULL default makes the key NULL in this file whatever its table 
type, so it is
+        // compared in the delete file's type, to which the table type need 
not convert: a dropped
+        // key whose retained schemas only typed it Iceberg `unknown` arrives 
as STRING, while its
+        // tombstones are, for example, DATE.
+        *comparison_type = remove_nullable(filter.delete_key_types[key_idx]);
+    } else if (remove_nullable(table_leaf->type)->get_primitive_type() == 
TYPE_STRING &&
+               
remove_nullable(filter.delete_key_types[key_idx])->get_primitive_type() ==
+                       TYPE_TIMESTAMPTZ) {
+        // An Iceberg timestamptz_ns key behind the STRING placeholder. Its 
tombstones are read as
+        // TIMESTAMPTZ(6), Parquet flooring and ORC rounding the nanoseconds, 
so no parse of the
+        // nanosecond text default compares exactly: the default `1970-01-01 
00:00:00.0000001+00:00`
+        // would match an epoch tombstone and delete every row of this file, 
and the default epoch
+        // a tombstone 100 ns after it.
+        return Status::NotSupported(
+                "Iceberg equality delete field id {} is a timestamptz_ns key 
that data file {} "
+                "does not store, with a non-NULL initial default; the delete 
file's values are "
+                "read as {} without their nanoseconds, so the default cannot 
be compared exactly",
+                filter.field_ids[key_idx], _data_file_path(),
+                remove_nullable(filter.delete_key_types[key_idx])->get_name());
+    } else if (auto placeholder_type = 
equality_delete_placeholder_default_type(
+                       table_leaf->type, filter.delete_key_types[key_idx]);
+               placeholder_type != nullptr) {
+        // A TIME `payload.k` added with the default `00:00` after the file 
was written: rows
+        // under a NULL `payload` still get a NULL key from 
AncestorNullDefaultExpr below, the
+        // others the parsed midnight, so a 00:00:00 tombstone deletes exactly 
those.
+        *comparison_type = std::move(placeholder_type);
+    } else {
+        RETURN_IF_ERROR(equality_delete_comparison_type(
+                filter.field_ids[key_idx], filter.delete_key_types[key_idx], 
table_leaf->type,
+                table_leaf->type, _data_file_path(), comparison_type));
     }
+    default_expr = cast_equality_delete_key(std::move(default_expr), 
*comparison_type);
     if (data_path.empty()) {
         *key_expr = std::move(default_expr);
         return Status::OK();
@@ -1586,8 +1924,7 @@ Status 
IcebergTableReader::_build_missing_equality_delete_key_expr(
     VExprSPtr ancestor_expr;
     RETURN_IF_ERROR(build_equality_delete_key_expr(data_path, block_position, 
&ancestor_expr));
     auto combined_expr = std::make_shared<AncestorNullDefaultExpr>(
-            make_nullable(filter.key_types[key_idx]),
-            ancestor_expr->expr_name() + "." + table_leaf->name);
+            make_nullable(*comparison_type), ancestor_expr->expr_name() + "." 
+ table_leaf->name);
     combined_expr->add_child(ancestor_expr);
     combined_expr->add_child(default_expr);
     *key_expr = std::move(combined_expr);
@@ -1597,37 +1934,62 @@ Status 
IcebergTableReader::_build_missing_equality_delete_key_expr(
 Status 
IcebergTableReader::_append_equality_delete_predicates(format::FileScanRequest* 
request) {
     DORIS_CHECK(request != nullptr);
     for (const auto& filter : _equality_delete_filters) {
-        auto delete_predicate = std::make_shared<EqualityDeletePredicate>(
-                filter.delete_block, filter.field_ids, filter.hash_index);
         DCHECK_EQ(filter.field_ids.size(), filter.key_types.size());
+        DCHECK_EQ(filter.field_ids.size(), filter.delete_key_types.size());
+        // A delete file without rows deletes nothing, but 
EqualityDeletePredicate evaluates its
+        // keys before it looks at the delete set, so the strict comparison 
casts would still run:
+        // a DATE key 1000-01-01 compared with an empty TIMESTAMP_NS delete 
file fails the scan
+        // with an overflow. It is skipped here rather than at load time, so 
the loaded filter
+        // still disables aggregate pushdown as before.
+        if (filter.delete_block.rows() == 0) {
+            continue;
+        }
+        VExprSPtrs key_exprs;
+        DataTypes comparison_types;
         bool has_missing_key = false;
         for (size_t idx = 0; idx < filter.field_ids.size(); ++idx) {
             EqualityDeleteColumnPath data_path;
             bool complete_path = false;
             RETURN_IF_ERROR(
                     _find_equality_delete_data_field(filter, idx, &data_path, 
&complete_path));
+            VExprSPtr key_expr;
+            DataTypePtr comparison_type;
             if (!complete_path) {
-                VExprSPtr key_expr;
-                
RETURN_IF_ERROR(_build_missing_equality_delete_key_expr(filter, idx, data_path,
-                                                                        
request, &key_expr));
-                delete_predicate->add_child(key_expr);
+                RETURN_IF_ERROR(_build_missing_equality_delete_key_expr(
+                        filter, idx, data_path, request, &key_expr, 
&comparison_type));
                 has_missing_key = true;
-                continue;
-            }
-            const auto* root = data_path.front();
-            const auto* field = data_path.back();
-            const auto field_column_id = 
format::LocalColumnId(root->file_local_id());
-            _append_file_scan_column(request, field_column_id, 
&request->predicate_columns);
-            const auto block_position = 
request->local_positions.at(field_column_id).value();
-            VExprSPtr key_expr;
-            RETURN_IF_ERROR(build_equality_delete_key_expr(data_path, 
block_position, &key_expr));
-            if (field->type->equals(*filter.key_types[idx])) {
-                delete_predicate->add_child(key_expr);
             } else {
-                auto cast_expr = 
EvolutionCast::create_shared(filter.key_types[idx]);
-                cast_expr->add_child(key_expr);
-                delete_predicate->add_child(cast_expr);
+                const auto* root = data_path.front();
+                const auto field_column_id = 
format::LocalColumnId(root->file_local_id());
+                _append_file_scan_column(request, field_column_id, 
&request->predicate_columns);
+                const auto block_position = 
request->local_positions.at(field_column_id).value();
+                RETURN_IF_ERROR(
+                        build_equality_delete_key_expr(data_path, 
block_position, &key_expr));
+                RETURN_IF_ERROR(equality_delete_comparison_type(
+                        filter.field_ids[idx], filter.delete_key_types[idx], 
key_expr->data_type(),
+                        filter.key_types[idx], _data_file_path(), 
&comparison_type));
+                key_expr = cast_equality_delete_key(std::move(key_expr), 
comparison_type);
             }
+            key_exprs.push_back(std::move(key_expr));
+            comparison_types.push_back(std::move(comparison_type));
+        }
+        // A data file can hold a key in a wider type than the delete file, 
e.g. a LONG written
+        // after an INT -> LONG promotion against INT tombstones written 
before it. The tombstones
+        // are then widened once per scanner and cached, instead of narrowing 
every data value.
+        EqualityDeleteFilter converted;
+        const bool needs_conversion = !std::ranges::equal(
+                filter.delete_key_types, comparison_types,
+                [](const DataTypePtr& delete_type, const DataTypePtr& 
comparison_type) {
+                    return 
remove_nullable(delete_type)->equals(*comparison_type);
+                });
+        if (needs_conversion) {
+            RETURN_IF_ERROR(_convert_equality_delete_filter(filter, 
comparison_types, &converted));
+        }
+        const auto& compared = needs_conversion ? converted : filter;
+        auto delete_predicate = std::make_shared<EqualityDeletePredicate>(
+                compared.delete_block, filter.field_ids, compared.hash_index);
+        for (auto& key_expr : key_exprs) {
+            delete_predicate->add_child(std::move(key_expr));
         }
         if (has_missing_key && request->predicate_columns.empty()) {
             _append_equality_delete_row_count_carrier(request);
@@ -1638,6 +2000,69 @@ Status 
IcebergTableReader::_append_equality_delete_predicates(format::FileScanRe
     return Status::OK();
 }
 
+Status IcebergTableReader::_convert_equality_delete_filter(const 
EqualityDeleteFilter& filter,
+                                                           const DataTypes& 
comparison_types,
+                                                           
EqualityDeleteFilter* result) {
+    DORIS_CHECK(_split_cache != nullptr);
+    DORIS_CHECK(result != nullptr);
+    DORIS_CHECK(comparison_types.size() == filter.delete_key_types.size());
+    std::ostringstream cache_key;
+    cache_key << filter.cache_key << ":compare";
+    for (const auto& type : comparison_types) {
+        cache_key << ':' << type->get_name();
+    }
+    Status convert_status = Status::OK();
+    bool cache_hit = false;
+    auto* cached_filter = _split_cache->get<EqualityDeleteFilter>(
+            cache_key.str(),
+            [&]() -> EqualityDeleteFilter* {
+                auto converted = std::make_unique<EqualityDeleteFilter>();
+                convert_status = 
_build_converted_equality_delete_filter(filter, comparison_types,
+                                                                         
converted.get());
+                if (!convert_status.ok()) {
+                    return nullptr;
+                }
+                converted->cache_key = cache_key.str();
+                return converted.release();
+            },
+            &cache_hit);
+    RETURN_IF_ERROR(convert_status);
+    DORIS_CHECK(cached_filter != nullptr);
+    if (!cache_hit) {
+        COUNTER_UPDATE(_profile.equality_delete_hash_index_memory,
+                       
static_cast<int64_t>(cached_filter->hash_index->memory_usage()));
+    }
+    *result = *cached_filter;
+    return Status::OK();
+}
+
+Status IcebergTableReader::_build_converted_equality_delete_filter(
+        const EqualityDeleteFilter& filter, const DataTypes& comparison_types,
+        EqualityDeleteFilter* result) const {
+    DORIS_CHECK(result != nullptr);
+    result->field_ids = filter.field_ids;
+    result->field_names = filter.field_names;
+    result->key_types = filter.key_types;
+    Block delete_block = filter.delete_block;
+    RowDescriptor row_desc;
+    for (size_t index = 0; index < comparison_types.size(); ++index) {
+        const auto& entry = delete_block.get_by_position(index);
+        VExprSPtr key_expr = VSlotRef::create_shared(cast_set<int>(index), 
cast_set<int>(index), -1,
+                                                     entry.type, entry.name);
+        auto context = VExprContext::create_shared(
+                cast_equality_delete_key(std::move(key_expr), 
comparison_types[index]));
+        RETURN_IF_ERROR(context->prepare(_runtime_state, row_desc));
+        RETURN_IF_ERROR(context->open(_runtime_state));
+        ColumnWithTypeAndName key;
+        RETURN_IF_ERROR(context->execute(&delete_block, key));
+        key.name = entry.name;
+        result->delete_key_types.push_back(key.type);
+        result->delete_block.insert(std::move(key));
+    }
+    result->hash_index = 
EqualityDeletePredicate::build_hash_index(result->delete_block);
+    return Status::OK();
+}
+
 Status IcebergTableReader::_create_delete_file_reader(const 
TIcebergDeleteFileDesc& delete_file,
                                                       const 
TFileScanRangeParams& scan_params,
                                                       
IcebergDeleteFileIOContext* delete_io_ctx,
@@ -1828,10 +2253,10 @@ Status 
IcebergTableReader::_resolve_equality_delete_fields(
             return Status::NotSupported(
                     "Iceberg equality delete does not support complex column 
{}", field->name);
         }
-        // Equality comparison must run in the promoted (current snapshot 
schema) type domain.
-        // Narrowing a wider data key into a historical delete-file type is 
lossy: an INT overflow
-        // becomes NULL, and NULL-safe equality then matches a NULL delete 
key. Delete values are
-        // promoted into this same domain when the delete file is read.
+        // The delete block keeps the delete file's own key type. Each data 
file's predicate
+        // compares a key in the wider of this type and its own 
(equality_delete_comparison_type):
+        // narrowing to either side is lossy, as an INT overflow becomes NULL 
and NULL-safe
+        // equality then matches a NULL key.
         const DataTypePtr delete_file_type =
                 path.size() > 1 ? make_nullable(field->type) : field->type;
         DataTypePtr key_type = delete_file_type;
@@ -1843,6 +2268,7 @@ Status 
IcebergTableReader::_resolve_equality_delete_fields(
         result->field_ids.push_back(field_id);
         result->field_names.push_back(field->name);
         result->key_types.push_back(key_type);
+        result->delete_key_types.push_back(delete_file_type);
     }
     return Status::OK();
 }
@@ -1890,14 +2316,7 @@ Status 
IcebergTableReader::_load_equality_delete_file(const TIcebergDeleteFileDe
         VExprSPtr key_expr;
         RETURN_IF_ERROR(build_equality_delete_key_expr(
                 path, request->local_positions.at(root_column_id).value(), 
&key_expr));
-        const auto& key_type = result->key_types[index];
-        if (!key_expr->data_type()->equals(*key_type)) {
-            // Historical delete values share the promoted comparison domain. 
DATE-to-nanos
-            // must report overflow rather than turn a non-NULL equality key 
into NULL.
-            auto cast_expr = EvolutionCast::create_shared(key_type);
-            cast_expr->add_child(key_expr);
-            key_expr = std::move(cast_expr);
-        }
+        
DORIS_CHECK(key_expr->data_type()->equals(*result->delete_key_types[index]));
         auto context = VExprContext::create_shared(std::move(key_expr));
         RETURN_IF_ERROR(context->prepare(_runtime_state, row_desc));
         RETURN_IF_ERROR(context->open(_runtime_state));
@@ -1908,7 +2327,7 @@ Status 
IcebergTableReader::_load_equality_delete_file(const TIcebergDeleteFileDe
     Block delete_block_template;
     for (size_t index = 0; index < delete_paths.size(); ++index) {
         const auto* field = delete_paths[index].back();
-        const auto& key_type = result->key_types[index];
+        const auto& key_type = result->delete_key_types[index];
         delete_block_template.insert({key_type->create_column(), key_type, 
field->name});
     }
     MutableBlock mutable_delete_block(delete_block_template.clone_empty());
@@ -1942,8 +2361,8 @@ Status 
IcebergTableReader::_read_equality_delete_file(const TIcebergDeleteFileDe
     std::ostringstream cache_key;
     cache_key << _delete_file_cache_key("iceberg_v2_equality_delete_", 
delete_file.path);
     if (scan_params.__isset.current_schema_id) {
-        // The promoted comparison type depends on the current snapshot 
schema, so a cached filter
-        // must not be reused across schemas.
+        // The table-side key types depend on the current snapshot schema, so 
a cached filter must
+        // not be reused across schemas.
         cache_key << ":schema=" << scan_params.current_schema_id;
     }
     cache_key << ':' << delete_file.field_ids.size();
@@ -1964,6 +2383,7 @@ Status 
IcebergTableReader::_read_equality_delete_file(const TIcebergDeleteFileDe
                 if (!read_status.ok()) {
                     return nullptr;
                 }
+                result->cache_key = cache_key.str();
                 return result.release();
             },
             &cache_hit);
diff --git a/be/src/format_v2/table/iceberg_reader.h 
b/be/src/format_v2/table/iceberg_reader.h
index 1a4292d0994..47f71353137 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -180,7 +180,15 @@ private:
                                                    size_t key_idx,
                                                    const 
EqualityDeleteColumnPath& data_path,
                                                    format::FileScanRequest* 
request,
-                                                   VExprSPtr* key_expr);
+                                                   VExprSPtr* key_expr,
+                                                   DataTypePtr* 
comparison_type);
+    // Returns `filter` with its delete keys converted to `comparison_types`, 
cached per scanner.
+    Status _convert_equality_delete_filter(const EqualityDeleteFilter& filter,
+                                           const DataTypes& comparison_types,
+                                           EqualityDeleteFilter* result);
+    Status _build_converted_equality_delete_filter(const EqualityDeleteFilter& 
filter,
+                                                   const DataTypes& 
comparison_types,
+                                                   EqualityDeleteFilter* 
result) const;
     Status _find_equality_delete_data_field(const EqualityDeleteFilter& 
filter, size_t key_idx,
                                             EqualityDeleteColumnPath* 
data_path,
                                             bool* complete_path) const;
@@ -238,9 +246,19 @@ private:
         // Delete-file names are retained for Iceberg tables imported from 
formats that did not
         // persist field ids. In BY_NAME mode they are the fallback binding 
key.
         std::vector<std::string> field_names;
+        // Table-side types of the keys, from the scan's schema metadata. They 
resolve table
+        // fields and build defaults for keys a data file lacks. The 
comparison type comes from
+        // the file types; a table type only lets a timestamptz key read as 
DATETIMEV2 compare
+        // as TIMESTAMPTZ, and a STRING placeholder (an Iceberg TIME or 
timestamptz_ns, which
+        // Doris cannot represent) lets an ORC TIME key and its text default 
compare as TIMEV2.
         std::vector<DataTypePtr> key_types;
+        // Types of the delete block columns: the delete file's own key types, 
or the comparison
+        // types of a converted filter.
+        std::vector<DataTypePtr> delete_key_types;
         Block delete_block;
         std::shared_ptr<const EqualityDeleteHashIndex> hash_index;
+        // Split-cache key of this filter, extended by the comparison types 
for converted copies.
+        std::string cache_key;
     };
     std::vector<EqualityDeleteFilter> _equality_delete_filters;
     // Scanner-shared cache supplied in SplitReadOptions. Parsed delete files 
outlive one data-file
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp 
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 2651501c62e..8b41271b196 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -90,6 +90,7 @@
 #include "util/debug_points.h"
 #include "util/defer_op.h"
 #include "util/hash_util.hpp"
+#include "util/timezone_utils.h"
 #include "util/url_coding.h"
 
 namespace doris::format {
@@ -1022,9 +1023,11 @@ void write_nested_key_value_orc_file(const std::string& 
file_path, const std::ve
     output.write(memory_stream.getData(), 
static_cast<std::streamsize>(memory_stream.getLength()));
 }
 
+// Writes the adjusted-UTC `event_time` (field id 0) and `id` (field id 1). 
With `int96`,
+// `event_time` is stored as INT96, as Spark and Hive write timestamps, which 
drops the annotation.
 void write_timestamp_int_parquet_file(const std::string& file_path,
                                       const std::vector<int64_t>& timestamps,
-                                      const std::vector<int32_t>& ids) {
+                                      const std::vector<int32_t>& ids, bool 
int96 = false) {
     ASSERT_EQ(timestamps.size(), ids.size());
     const auto timestamp_metadata = 
arrow::key_value_metadata({"PARQUET:field_id"}, {"0"});
     const auto id_metadata = arrow::key_value_metadata({"PARQUET:field_id"}, 
{"1"});
@@ -1045,9 +1048,13 @@ void write_timestamp_int_parquet_file(const std::string& 
file_path,
     builder.version(::parquet::ParquetVersion::PARQUET_2_6);
     builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
     builder.compression(::parquet::Compression::UNCOMPRESSED);
+    ::parquet::ArrowWriterProperties::Builder arrow_builder;
+    if (int96) {
+        arrow_builder.enable_force_write_int96_timestamps();
+    }
     PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
                                                       
static_cast<int64_t>(timestamps.size()),
-                                                      builder.build()));
+                                                      builder.build(), 
arrow_builder.build()));
 }
 
 void write_iceberg_equality_delete_bigint_parquet_file(const std::string& 
file_path,
@@ -4964,6 +4971,741 @@ TEST(IcebergV2ReaderTest, 
IcebergEqualityDeletePromotesHistoricalDeleteKeyToCurr
     std::filesystem::remove_all(test_dir);
 }
 
+template <typename Builder, typename Value>
+std::shared_ptr<arrow::Array> build_nullable_array(
+        Builder* builder, const std::vector<std::optional<Value>>& values) {
+    for (const auto& value : values) {
+        if (value.has_value()) {
+            EXPECT_TRUE(builder->Append(*value).ok());
+        } else {
+            EXPECT_TRUE(builder->AppendNull().ok());
+        }
+    }
+    return finish_array(builder);
+}
+
+std::shared_ptr<arrow::Array> build_nullable_int32_keys(
+        const std::vector<std::optional<int32_t>>& values) {
+    arrow::Int32Builder builder;
+    return build_nullable_array(&builder, values);
+}
+
+std::shared_ptr<arrow::Array> build_date32_keys(const 
std::vector<std::optional<int32_t>>& days) {
+    arrow::Date32Builder builder;
+    return build_nullable_array(&builder, days);
+}
+
+std::shared_ptr<arrow::Array> build_timestamp_keys(
+        arrow::TimeUnit::type unit, const std::vector<std::optional<int64_t>>& 
ticks) {
+    arrow::TimestampBuilder builder(arrow::timestamp(unit), 
arrow::default_memory_pool());
+    return build_nullable_array(&builder, ticks);
+}
+
+void write_parquet_table(const std::string& file_path, const 
std::shared_ptr<arrow::Table>& table) {
+    auto file_result = arrow::io::FileOutputStream::Open(file_path);
+    ASSERT_TRUE(file_result.ok()) << file_result.status();
+    std::shared_ptr<arrow::io::FileOutputStream> out = *file_result;
+    ::parquet::WriterProperties::Builder builder;
+    builder.version(::parquet::ParquetVersion::PARQUET_2_6);
+    builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    builder.compression(::parquet::Compression::UNCOMPRESSED);
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
+                                                      
std::max<int64_t>(table->num_rows(), 1),
+                                                      builder.build()));
+}
+
+// Writes `id` (field id 0) and, unless `keys` is null, the nullable key `k` 
(field id 1).
+void write_id_and_key_parquet_file(const std::string& file_path, const 
std::vector<int32_t>& ids,
+                                   const std::shared_ptr<arrow::Array>& keys) {
+    std::vector<std::shared_ptr<arrow::Field>> fields {
+            arrow::field("id", arrow::int32(), false)
+                    
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"0"}))};
+    std::vector<std::shared_ptr<arrow::Array>> columns 
{build_int32_array(ids)};
+    if (keys != nullptr) {
+        fields.push_back(
+                arrow::field("k", keys->type(), true)
+                        
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"1"})));
+        columns.push_back(keys);
+    }
+    write_parquet_table(file_path, arrow::Table::Make(arrow::schema(fields), 
columns));
+}
+
+struct KeyDataFile {
+    std::vector<int32_t> ids;
+    // nullptr: the data file has no `k`, as one written before the key was 
added.
+    std::shared_ptr<arrow::Array> keys;
+};
+
+// Writes an equality delete file at the given path and returns its format.
+using EqualityDeleteFileWriter = std::function<TFileFormatType::type(const 
std::string&)>;
+
+// Reads each data file as one split of a single reader. Every split carries 
the same equality
+// delete file on `equality_field_id`, whose one column has the name and field 
id of `key_field`
+// and holds `delete_keys`; for a nested key that is a struct array whose 
child carries the key's
+// field id. `write_delete_file`, when given, writes that file instead, for 
physical shapes Arrow
+// does not produce. `key_field` describes that column in the scan's schema 
metadata, as the FE
+// sends it. Returns the surviving ids of each split.
+Status read_ids_after_key_equality_delete(const std::string& test_name,
+                                          const std::vector<KeyDataFile>& 
data_files,
+                                          const std::shared_ptr<arrow::Array>& 
delete_keys,
+                                          schema::external::TFieldPtr 
key_field,
+                                          std::vector<std::vector<int32_t>>* 
ids_per_split,
+                                          int32_t equality_field_id = 1,
+                                          bool enable_mapping_timestamp_tz = 
false,
+                                          const EqualityDeleteFileWriter& 
write_delete_file = {}) {
+    const auto test_dir = std::filesystem::temp_directory_path() / test_name;
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto delete_file_path = (test_dir / 
"equality-delete.parquet").string();
+    auto delete_file_format = TFileFormatType::FORMAT_PARQUET;
+    if (write_delete_file) {
+        delete_file_format = write_delete_file(delete_file_path);
+    } else {
+        const auto& delete_column = *key_field.field_ptr;
+        write_parquet_table(
+                delete_file_path,
+                arrow::Table::Make(
+                        arrow::schema({arrow::field(delete_column.name, 
delete_keys->type(), true)
+                                               
->WithMetadata(arrow::key_value_metadata(
+                                                       {"PARQUET:field_id"},
+                                                       
{std::to_string(delete_column.id)}))}),
+                        {delete_keys}));
+    }
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(0, "id", 
std::make_shared<DataTypeInt32>()));
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    if (enable_mapping_timestamp_tz) {
+        scan_params.__set_enable_mapping_timestamp_tz(true);
+    }
+    scan_params.__set_current_schema_id(100);
+    scan_params.__set_history_schema_info(
+            {external_schema(100, {external_schema_field("id", 0), 
std::move(key_field)})});
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+    for (size_t index = 0; index < data_files.size(); ++index) {
+        const auto file_path = (test_dir / ("data-" + std::to_string(index) + 
".parquet")).string();
+        write_id_and_key_parquet_file(file_path, data_files[index].ids, 
data_files[index].keys);
+        auto split_options = build_split_options(file_path);
+        split_options.cache = &cache;
+        
split_options.current_range.__set_table_format_params(make_iceberg_table_format_desc(
+                file_path, 
{make_iceberg_equality_delete_file(delete_file_path, {equality_field_id},
+                                                              
delete_file_format)}));
+        RETURN_IF_ERROR(reader.prepare_split(split_options));
+        std::vector<int32_t> ids;
+        bool eos = false;
+        while (!eos) {
+            Block block = build_table_block(projected_columns);
+            RETURN_IF_ERROR(reader.get_block(&block, &eos));
+            if (block.rows() == 0) {
+                continue;
+            }
+            const auto& id_column =
+                    assert_cast<const 
ColumnInt32&>(expect_not_null_table_column(block, 0));
+            for (size_t row = 0; row < block.rows(); ++row) {
+                ids.push_back(id_column.get_element(row));
+            }
+        }
+        ids_per_split->push_back(std::move(ids));
+    }
+    RETURN_IF_ERROR(reader.close());
+    std::filesystem::remove_all(test_dir);
+    return Status::OK();
+}
+
+constexpr int64_t MICROS_PER_DAY = 86400LL * 1000 * 1000;
+constexpr int32_t DAYS_1000_01_01 = -354285;
+constexpr int32_t DAYS_1600_01_01 = -135140;
+constexpr int32_t DAYS_2000_01_01 = 10957;
+constexpr int32_t DAYS_2024_01_02 = 19724;
+constexpr int32_t DAYS_2024_01_03 = 19725;
+
+// After expireSnapshots().cleanExpiredMetadata(true) removes the schema of a 
schema-only
+// INT -> LONG promotion, the FE types a dropped key INT although its 
tombstones are LONG.
+// Comparing in INT turned 4294967296 into NULL, which deleted the NULL key of 
the old file and
+// the 4294967296 key of the new one together.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteKeepsWideTombstonesUnderNarrowTableType) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_narrow_table_type",
+            {{.ids = {1, 2, 3}, .keys = 
build_nullable_int32_keys({std::nullopt, 0, 1})},
+             {.ids = {4, 5}, .keys = build_nullable_int64_array({std::nullopt, 
4294967296})}},
+            build_nullable_int64_array({4294967296}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::INT), false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{1, 2, 3}, {4}}));
+}
+
+// INT tombstones written before an INT -> LONG promotion meet LONG data files 
and INT data files
+// of the same scan. LONG files compare in LONG through a widened copy of the 
delete block, which
+// the third split takes from the scanner cache; the INT file uses the delete 
block as read.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteWidensTombstonesPerDataFileType) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_widened_per_data_file",
+            {{.ids = {1, 2}, .keys = build_nullable_int64_array({7, 
4294967303})},
+             {.ids = {3, 4}, .keys = build_nullable_int32_keys({7, 8})},
+             {.ids = {5, 6}, .keys = build_nullable_int64_array({4294967303, 
7})}},
+            build_nullable_int32_keys({7}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::BIGINT), false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{2}, {4}, {5}}));
+}
+
+// A read pinned before a later DATE -> TIMESTAMP_NS promotion gets the 
promoted table type. The
+// DATE tombstone and the DATE data compare as DATE, so 1000-01-01 does not 
have to fit
+// TIMESTAMP_NS (1677-09-21..2262-04-11) and the scan no longer fails with an 
overflow.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteComparesDatesUnderNanosecondTableType) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_wide_table_type",
+            {{.ids = {1, 2}, .keys = build_date32_keys({DAYS_1000_01_01, 
DAYS_2000_01_01})}},
+            build_date32_keys({DAYS_1000_01_01}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::TIMESTAMP_NS), false,
+                                  true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{2}}));
+}
+
+// The FE encodes an Iceberg `unknown` key as STRING when no retained schema 
gives it a concrete
+// type. Comparing as STRING made the DATE key "2024-01-02" differ from the 
TIMESTAMP_NS tombstone
+// "2024-01-02 00:00:00.000000000", so the deleted row stayed visible.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteIgnoresUnknownTableTypeForDateAndNanosKeys) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_unknown_table_type",
+            {{.ids = {1, 2}, .keys = build_date32_keys({DAYS_2024_01_02, 
DAYS_2024_01_03})}},
+            build_timestamp_keys(arrow::TimeUnit::NANO, {DAYS_2024_01_02 * 
MICROS_PER_DAY * 1000}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::STRING), false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{2}}));
+}
+
+// A delete prepared under a TIMESTAMP_NS schema that the table later 
abandoned holds nanosecond
+// tombstones while the table type is TIMESTAMP. Rounding the tombstone 
12:00:00.000000500 to
+// microseconds matched one of the microsecond keys; in nanoseconds it matches 
neither.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteComparesNanosecondTombstonesInNanoseconds) {
+    const int64_t noon_micros = DAYS_2024_01_02 * MICROS_PER_DAY + 12LL * 3600 
* 1000 * 1000;
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_abandoned_nanos",
+            {{.ids = {1, 2},
+              .keys = build_timestamp_keys(arrow::TimeUnit::MICRO,
+                                           {noon_micros, noon_micros + 1})}},
+            build_timestamp_keys(arrow::TimeUnit::NANO, {noon_micros * 1000 + 
500}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::DATETIMEV2, -1, 6), false,
+                                  true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{1, 2}}));
+}
+
+// Comparing microsecond keys in nanoseconds must fail for a key outside 
1677-09-21..2262-04-11.
+// A non-strict cast would turn 1600-01-01 into NULL, and the NULL tombstone 
would delete it.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteRejectsMicrosOutsideNanosecondRange) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_micros_outside_nanos",
+            {{.ids = {1, 2},
+              .keys = build_timestamp_keys(
+                      arrow::TimeUnit::MICRO,
+                      {DAYS_1600_01_01 * MICROS_PER_DAY, DAYS_2024_01_02 * 
MICROS_PER_DAY})}},
+            build_timestamp_keys(arrow::TimeUnit::NANO, {std::nullopt}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::DATETIMEV2, -1, 6), false,
+                                  true),
+            &ids);
+    ASSERT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("TIMESTAMP_NS overflow"), 
std::string::npos) << status;
+}
+
+// An equality delete file without rows deletes nothing, so the strict 
comparison casts that a
+// nonempty one needs must not fail the scan: DATE keys meeting TIMESTAMP_NS 
tombstones are cast to
+// TIMESTAMP_NS, which 1000-01-01 overflows, both as a key of the first file 
and as the initial
+// default for the second. Building a predicate for the empty file fails with
+// "TIMESTAMP_NS overflow".
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteSkipsEmptyDeleteFileBeforeKeyCasts) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_empty_delete_file",
+            {{.ids = {1, 2}, .keys = build_date32_keys({DAYS_1000_01_01, 
DAYS_2000_01_01})},
+             {.ids = {3}, .keys = nullptr}},
+            build_timestamp_keys(arrow::TimeUnit::NANO, {}),
+            external_schema_field("k", 1, {}, "1000-01-01",
+                                  
external_primitive_type(TPrimitiveType::DATEV2), false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {{1, 2}, {3}}));
+}
+
+// A data file without the key compares its initial default, typed by the 
table, against the
+// tombstones in the wider of the two types: INT default 5 matches the LONG 
tombstone 5.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteWidensMissingKeyDefaultToTombstoneType) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_missing_key_default_widened",
+            {{.ids = {1, 2, 3}, .keys = nullptr}}, 
build_nullable_int64_array({5}),
+            external_schema_field("k", 1, {}, "5", 
external_primitive_type(TPrimitiveType::INT),
+                                  false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {std::vector<int32_t> 
{}}));
+}
+
+// Without an initial default a missing key is NULL whatever the table type, 
so it is compared in
+// the tombstones' type even when the table type cannot convert to it 
(`unknown` arrives as
+// STRING): the NULL tombstone deletes every row, the DATE one deletes none.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteMatchesNullMissingKeyUnderUnknownTableType) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_missing_key_unknown_type",
+            {{.ids = {1, 2}, .keys = nullptr}}, 
build_date32_keys({std::nullopt, DAYS_2024_01_02}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::STRING), false, true),
+            &ids);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {std::vector<int32_t> 
{}}));
+}
+
+// The nested delete key `payload.k` (field id 3 under `payload`) holding 
`days` as DATE under
+// non-NULL parents.
+std::shared_ptr<arrow::Array> build_struct_date32_keys(
+        const std::vector<std::optional<int32_t>>& days) {
+    auto key_field = arrow::field("k", arrow::date32(), true)
+                             
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"3"}));
+    auto keys = arrow::StructArray::Make({build_date32_keys(days)}, 
{key_field});
+    DORIS_CHECK(keys.ok());
+    return *keys;
+}
+
+// A missing struct whose initial default is `{}` takes each child's own 
default, so `payload.k`
+// without one is NULL, and it compares in the tombstones' type like a missing 
key without a
+// default. Here `k` is STRING, as the FE encodes Iceberg `unknown`, and DATE 
does not convert to
+// it. The NULL tombstone deletes every row and the DATE one none. Deciding 
NULL by the struct's
+// default alone compares STRING with DATE and fails the scan with 
NotSupported.
+TEST(IcebergV2ReaderTest, 
IcebergMissingWholeStructEqualityKeyComparesNullLeafInTombstoneType) {
+    const std::vector<std::pair<std::optional<int32_t>, std::vector<int32_t>>> 
cases {
+            {std::nullopt, {}}, {DAYS_2024_01_02, {1, 2, 3}}};
+    for (const auto& [tombstone, expected_ids] : cases) {
+        SCOPED_TRACE(tombstone.has_value() ? "DATE tombstone" : "NULL 
tombstone");
+        std::vector<std::vector<int32_t>> ids;
+        const auto status = read_ids_after_key_equality_delete(
+                "doris_iceberg_equality_delete_null_struct_default_leaf",
+                {{.ids = {1, 2, 3}, .keys = nullptr}}, 
build_struct_date32_keys({tombstone}),
+                external_struct_schema_field(
+                        "payload", 1,
+                        {external_schema_field("k", 3, {}, std::nullopt,
+                                               
external_primitive_type(TPrimitiveType::STRING),
+                                               false, true)},
+                        true, "{}"),
+                &ids, 3);
+        ASSERT_TRUE(status.ok()) << status;
+        EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {expected_ids}));
+    }
+}
+
+constexpr int64_t TIME_01_00_00_000001_MICROS = 3600LL * 1000 * 1000 + 1;
+constexpr int64_t TIME_02_00_00_MICROS = 7200LL * 1000 * 1000;
+
+// Iceberg's Parquet writer stores TIME as TIME(isAdjustedToUTC=false, 
MICROS), read as TIMEV2(6),
+// and timestamptz_ns as TIMESTAMP(isAdjustedToUTC=true, NANOS), both INT64.
+std::shared_ptr<const ::parquet::LogicalType> iceberg_parquet_time_type() {
+    return ::parquet::LogicalType::Time(false, 
::parquet::LogicalType::TimeUnit::MICROS);
+}
+
+// Writes the Iceberg TIME `k` (field id 1) as Iceberg stores it, microseconds 
from midnight in
+// both formats: Parquet's TIME(MICROS) and ORC's LONG annotated 
iceberg.long-type=TIME, which reads
+// as BIGINT. Nonempty `ids` add the INT `id` (field id 0) in front.
+void write_iceberg_time_file(const std::string& file_path, FileFormat format,
+                             const std::vector<int32_t>& ids,
+                             const std::vector<std::optional<int64_t>>& 
micros) {
+    ASSERT_TRUE(ids.empty() || ids.size() == micros.size());
+    if (format == FileFormat::PARQUET) {
+        ::parquet::schema::NodeVector fields;
+        if (!ids.empty()) {
+            fields.push_back(::parquet::schema::PrimitiveNode::Make(
+                    "id", ::parquet::Repetition::REQUIRED, 
::parquet::LogicalType::None(),
+                    ::parquet::Type::INT32, -1, 0));
+        }
+        fields.push_back(::parquet::schema::PrimitiveNode::Make(
+                "k", ::parquet::Repetition::OPTIONAL, 
iceberg_parquet_time_type(),
+                ::parquet::Type::INT64, -1, 1));
+        auto schema = std::static_pointer_cast<::parquet::schema::GroupNode>(
+                ::parquet::schema::GroupNode::Make("schema", 
::parquet::Repetition::REQUIRED,
+                                                   fields));
+        auto out = arrow::io::FileOutputStream::Open(file_path).ValueOrDie();
+        auto writer = ::parquet::ParquetFileWriter::Open(out, schema);
+        auto* group = writer->AppendRowGroup();
+        if (!ids.empty()) {
+            auto* id_writer = 
static_cast<::parquet::Int32Writer*>(group->NextColumn());
+            for (const int32_t id : ids) {
+                id_writer->WriteBatch(1, nullptr, nullptr, &id);
+            }
+            id_writer->Close();
+        }
+        auto* key_writer = 
static_cast<::parquet::Int64Writer*>(group->NextColumn());
+        for (const auto& value : micros) {
+            const int16_t level = value.has_value() ? 1 : 0;
+            const int64_t micros_value = value.value_or(0);
+            key_writer->WriteBatch(1, &level, nullptr, &micros_value);
+        }
+        key_writer->Close();
+        group->Close();
+        writer->Close();
+        ASSERT_TRUE(out->Close().ok());
+        return;
+    }
+    auto type = std::unique_ptr<::orc::Type>(::orc::Type::buildTypeFromString(
+            ids.empty() ? "struct<k:bigint>" : "struct<id:int,k:bigint>"));
+    const size_t key_index = ids.empty() ? 0 : 1;
+    if (!ids.empty()) {
+        type->getSubtype(0)->setAttribute("iceberg.id", "0");
+    }
+    type->getSubtype(key_index)->setAttribute("iceberg.id", "1");
+    type->getSubtype(key_index)->setAttribute("iceberg.long-type", "TIME");
+    MemoryOutputStream stream(1024 * 1024);
+    ::orc::WriterOptions options;
+    auto writer = ::orc::createWriter(*type, &stream, options);
+    auto batch = writer->createRowBatch(micros.size());
+    auto& root = dynamic_cast<::orc::StructVectorBatch&>(*batch);
+    root.numElements = micros.size();
+    if (!ids.empty()) {
+        auto& id_batch = 
dynamic_cast<::orc::LongVectorBatch&>(*root.fields[0]);
+        id_batch.numElements = ids.size();
+        for (size_t row = 0; row < ids.size(); ++row) {
+            id_batch.data[row] = ids[row];
+        }
+    }
+    auto& key_batch = 
dynamic_cast<::orc::LongVectorBatch&>(*root.fields[key_index]);
+    key_batch.numElements = micros.size();
+    key_batch.hasNulls = true;
+    for (size_t row = 0; row < micros.size(); ++row) {
+        key_batch.notNull[row] = micros[row].has_value();
+        key_batch.data[row] = micros[row].value_or(0);
+    }
+    writer->add(*batch);
+    writer->close();
+    std::ofstream out(file_path, std::ios::binary);
+    out.write(stream.getData(), 
static_cast<std::streamsize>(stream.getLength()));
+}
+
+// The FE sends an Iceberg TIME, which Doris cannot represent, as a STRING 
placeholder, so a hidden
+// TIME key compares in the files' own types: BIGINT microseconds in ORC, 
TIMEV2(6) in Parquet. The
+// pair compares as TIMEV2(6) with the ORC value converted exactly, in either 
direction, so the NULL
+// tombstone deletes row 1 and 01:00:00.000001 deletes row 2 (3600000001). 
Without that rule the
+// mixed pairs fail with NotSupported; a SQL cast would read 3600000001 as 
HHMMSS digits, and the
+// old STRING comparison never matched "3600000001" against "01:00:00.000001". 
Same-format pairs
+// compare as they are.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteComparesTimeKeysAcrossOrcAndParquet) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_equality_delete_time_formats";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const std::vector<std::pair<std::optional<int64_t>, std::vector<int32_t>>> 
cases {
+            {std::nullopt, {2, 3}}, {TIME_01_00_00_000001_MICROS, {1, 3}}};
+    // Every run writes new files: the reader caches Parquet footers and pages 
by path and size, so
+    // a same-size file rewritten at one path could be read as its earlier 
content.
+    int run = 0;
+    for (auto data_format : {FileFormat::ORC, FileFormat::PARQUET}) {
+        for (auto delete_format : {FileFormat::ORC, FileFormat::PARQUET}) {
+            for (const auto& [tombstone, expected_ids] : cases) {
+                SCOPED_TRACE(::testing::Message()
+                             << "data=" << static_cast<int>(data_format)
+                             << " delete=" << static_cast<int>(delete_format) 
<< " tombstone="
+                             << (tombstone.has_value() ? 
std::to_string(*tombstone) : "NULL"));
+                const auto file_path = (test_dir / ("data-" + 
std::to_string(run))).string();
+                const auto delete_path = (test_dir / ("delete-" + 
std::to_string(run++))).string();
+                write_iceberg_time_file(
+                        file_path, data_format, {1, 2, 3},
+                        {std::nullopt, TIME_01_00_00_000001_MICROS, 
TIME_02_00_00_MICROS});
+                write_iceberg_time_file(delete_path, delete_format, {}, 
{tombstone});
+                std::vector<ColumnDefinition> columns = {
+                        make_table_column(0, "id", 
std::make_shared<DataTypeInt32>())};
+                auto scan_params = make_local_scan_params(data_format);
+                
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+                scan_params.__set_current_schema_id(100);
+                scan_params.__set_history_schema_info({external_schema(
+                        100, {external_schema_field("id", 0),
+                              external_schema_field("k", 1, {}, std::nullopt,
+                                                    
external_primitive_type(TPrimitiveType::STRING),
+                                                    false, true)})});
+                RuntimeState state {TQueryOptions(), TQueryGlobals()};
+                RuntimeProfile profile("time_formats");
+                io::FileReaderStats stats;
+                io::FileCacheStatistics cache_stats;
+                auto io_ctx = make_io_context(&stats, &cache_stats);
+                ShardedKVCache cache(1);
+                iceberg::IcebergTableReader reader;
+                init_iceberg_reader(&reader, columns, &scan_params, io_ctx, 
&state, &profile,
+                                    data_format);
+                auto split = build_split_options(file_path);
+                split.current_split_format = data_format;
+                split.cache = &cache;
+                
split.current_range.__set_table_format_params(make_iceberg_table_format_desc(
+                        file_path, {make_iceberg_equality_delete_file(
+                                           delete_path, {1},
+                                           delete_format == FileFormat::PARQUET
+                                                   ? 
TFileFormatType::FORMAT_PARQUET
+                                                   : 
TFileFormatType::FORMAT_ORC)}));
+                const auto status = reader.prepare_split(split);
+                ASSERT_TRUE(status.ok()) << status;
+                EXPECT_EQ(read_iceberg_ids(&reader, columns), expected_ids);
+                ASSERT_TRUE(reader.close().ok());
+            }
+        }
+    }
+    std::filesystem::remove_all(test_dir);
+}
+
+// `payload` (field id 1) of a data file written before `payload.k` existed: a 
struct whose only
+// child is the INT `a` (field id 2). std::nullopt is a NULL `payload`.
+std::shared_ptr<arrow::Array> build_payload_without_key(
+        const std::vector<std::optional<int32_t>>& values) {
+    auto a_field = arrow::field("a", arrow::int32(), true)
+                           
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"2"}));
+    std::unique_ptr<arrow::ArrayBuilder> builder;
+    EXPECT_TRUE(
+            arrow::MakeBuilder(arrow::default_memory_pool(), 
arrow::struct_({a_field}), &builder)
+                    .ok());
+    auto& struct_builder = static_cast<arrow::StructBuilder&>(*builder);
+    auto& a_builder = 
static_cast<arrow::Int32Builder&>(*struct_builder.field_builder(0));
+    for (const auto& value : values) {
+        if (value.has_value()) {
+            EXPECT_TRUE(struct_builder.Append().ok());
+            EXPECT_TRUE(a_builder.Append(*value).ok());
+        } else {
+            EXPECT_TRUE(struct_builder.AppendNull().ok());
+        }
+    }
+    std::shared_ptr<arrow::Array> result;
+    EXPECT_TRUE(struct_builder.Finish(&result).ok());
+    return result;
+}
+
+// Writes the nested equality delete column `payload` (field id 1) whose child 
`k` (field id 3) is
+// an INT64 leaf of `logical_type`, holding `keys` under non-NULL parents.
+void write_nested_int64_key_delete_parquet_file(
+        const std::string& file_path,
+        const std::shared_ptr<const ::parquet::LogicalType>& logical_type,
+        const std::vector<std::optional<int64_t>>& keys) {
+    ::parquet::schema::NodeVector key_fields 
{::parquet::schema::PrimitiveNode::Make(
+            "k", ::parquet::Repetition::OPTIONAL, logical_type, 
::parquet::Type::INT64, -1, 3)};
+    ::parquet::schema::NodeVector fields {
+            ::parquet::schema::GroupNode::Make("payload", 
::parquet::Repetition::OPTIONAL,
+                                               key_fields, 
::parquet::ConvertedType::NONE, 1)};
+    auto schema = std::static_pointer_cast<::parquet::schema::GroupNode>(
+            ::parquet::schema::GroupNode::Make("schema", 
::parquet::Repetition::REQUIRED, fields));
+    auto out = arrow::io::FileOutputStream::Open(file_path).ValueOrDie();
+    auto writer = ::parquet::ParquetFileWriter::Open(out, schema);
+    auto* group = writer->AppendRowGroup();
+    auto* key_writer = 
static_cast<::parquet::Int64Writer*>(group->NextColumn());
+    for (const auto& key : keys) {
+        // Definition level 2: `payload` and `k` present; 1: `payload` present 
with a NULL `k`.
+        const int16_t level = key.has_value() ? 2 : 1;
+        const int64_t value = key.value_or(0);
+        key_writer->WriteBatch(1, &level, nullptr, &value);
+    }
+    key_writer->Close();
+    group->Close();
+    writer->Close();
+    ASSERT_TRUE(out->Close().ok());
+}
+
+// The same column in ORC, where `k` is an Iceberg TIME: a LONG annotated 
iceberg.long-type=TIME.
+void write_nested_time_key_delete_orc_file(const std::string& file_path,
+                                           const 
std::vector<std::optional<int64_t>>& micros) {
+    auto type = std::unique_ptr<::orc::Type>(
+            
::orc::Type::buildTypeFromString("struct<payload:struct<k:bigint>>"));
+    type->getSubtype(0)->setAttribute("iceberg.id", "1");
+    type->getSubtype(0)->getSubtype(0)->setAttribute("iceberg.id", "3");
+    type->getSubtype(0)->getSubtype(0)->setAttribute("iceberg.long-type", 
"TIME");
+    MemoryOutputStream stream(1024 * 1024);
+    ::orc::WriterOptions options;
+    auto writer = ::orc::createWriter(*type, &stream, options);
+    auto batch = writer->createRowBatch(micros.size());
+    auto& root = dynamic_cast<::orc::StructVectorBatch&>(*batch);
+    root.numElements = micros.size();
+    auto& payload = dynamic_cast<::orc::StructVectorBatch&>(*root.fields[0]);
+    payload.numElements = micros.size();
+    auto& keys = dynamic_cast<::orc::LongVectorBatch&>(*payload.fields[0]);
+    keys.numElements = micros.size();
+    keys.hasNulls = true;
+    for (size_t row = 0; row < micros.size(); ++row) {
+        keys.notNull[row] = micros[row].has_value();
+        keys.data[row] = micros[row].value_or(0);
+    }
+    writer->add(*batch);
+    writer->close();
+    std::ofstream out(file_path, std::ios::binary);
+    out.write(stream.getData(), 
static_cast<std::streamsize>(stream.getLength()));
+}
+
+// Iceberg TIME has no Doris type, so the FE sends `payload.k` as a STRING 
placeholder whose initial
+// default is Iceberg's text of the value. That text is parsed in the 
tombstones' type (an ORC TIME
+// tombstone, BIGINT, compares as TIMEV2(6)). Rows under the data file's NULL 
`payload` keep a NULL
+// key and the others take the default, so a NULL tombstone deletes only row 
1, the default's
+// tombstone rows 2 and 3, and another value none. Comparing the STRING 
default with the TIMEV2
+// tombstones failed the split with NotSupported.
+TEST(IcebergV2ReaderTest, 
IcebergMissingNestedPlaceholderKeyParsesTextDefaultInTombstoneType) {
+    struct PlaceholderDefault {
+        std::string name;
+        std::string text_default;
+        // Writes a delete file holding the tombstone, in the file's unit.
+        std::function<TFileFormatType::type(const std::string&, 
std::optional<int64_t>)> writer;
+        int64_t default_value;
+        int64_t other_value;
+        bool enable_mapping_timestamp_tz;
+    };
+    const std::vector<PlaceholderDefault> kinds {
+            {"Parquet TIME", "00:00",
+             [](const std::string& path, std::optional<int64_t> key) {
+                 write_nested_int64_key_delete_parquet_file(path, 
iceberg_parquet_time_type(),
+                                                            {key});
+                 return TFileFormatType::FORMAT_PARQUET;
+             },
+             0, TIME_01_00_00_000001_MICROS, false},
+            {"ORC TIME", "00:00",
+             [](const std::string& path, std::optional<int64_t> key) {
+                 write_nested_time_key_delete_orc_file(path, {key});
+                 return TFileFormatType::FORMAT_ORC;
+             },
+             0, TIME_01_00_00_000001_MICROS, false}};
+    // A directory per run, as in 
IcebergEqualityDeleteComparesTimeKeysAcrossOrcAndParquet: the
+    // 1-row INT64 delete files of one kind have the same size whatever the 
tombstone.
+    int run = 0;
+    for (const auto& kind : kinds) {
+        const std::vector<std::pair<std::optional<int64_t>, 
std::vector<int32_t>>> cases {
+                {std::nullopt, {2, 3}}, {kind.default_value, {1}}, 
{kind.other_value, {1, 2, 3}}};
+        for (const auto& [tombstone, expected_ids] : cases) {
+            SCOPED_TRACE(::testing::Message()
+                         << kind.name << " tombstone="
+                         << (tombstone.has_value() ? 
std::to_string(*tombstone) : "NULL"));
+            std::vector<std::vector<int32_t>> ids;
+            const auto status = read_ids_after_key_equality_delete(
+                    "doris_iceberg_equality_delete_placeholder_text_default_" +
+                            std::to_string(run++),
+                    {{.ids = {1, 2, 3}, .keys = 
build_payload_without_key({std::nullopt, 1, 2})}},
+                    nullptr,
+                    external_struct_schema_field(
+                            "payload", 1,
+                            {external_schema_field("a", 2, {}, std::nullopt,
+                                                   
external_primitive_type(TPrimitiveType::INT),
+                                                   false, true),
+                             external_schema_field("k", 3, {}, 
kind.text_default,
+                                                   
external_primitive_type(TPrimitiveType::STRING),
+                                                   false, true)},
+                            true),
+                    &ids, 3, kind.enable_mapping_timestamp_tz,
+                    [&kind, tombstone = tombstone](const std::string& path) {
+                        return kind.writer(path, tombstone);
+                    });
+            ASSERT_TRUE(status.ok()) << status;
+            EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {expected_ids}));
+        }
+    }
+}
+
+// A timestamptz_ns `payload.k` behind the STRING placeholder: its Parquet 
tombstones are read as
+// TIMESTAMPTZ(6), the nanoseconds floored, so a non-NULL default cannot be 
compared exactly and the
+// scan fails. Parsing the default instead deleted the rows for the default 
`...0000001` against an
+// epoch tombstone and for the default epoch against a tombstone 100 ns later, 
and kept them for the
+// default `...0000005` against its own tombstone. Without a default the key 
is NULL: a NULL
+// tombstone deletes every row, an epoch tombstone none.
+TEST(IcebergV2ReaderTest, 
IcebergMissingPlaceholderTimestampTzNsDefaultFailsTheScan) {
+    const auto write_tombstone = [](std::optional<int64_t> nanos) {
+        return [nanos](const std::string& path) {
+            write_nested_int64_key_delete_parquet_file(
+                    path,
+                    ::parquet::LogicalType::Timestamp(true,
+                                                      
::parquet::LogicalType::TimeUnit::NANOS),
+                    {nanos});
+            return TFileFormatType::FORMAT_PARQUET;
+        };
+    };
+    const auto read_ids = [](const std::string& test_name,
+                             const std::optional<std::string>& text_default,
+                             const EqualityDeleteFileWriter& write_delete_file,
+                             std::vector<std::vector<int32_t>>* ids) {
+        return read_ids_after_key_equality_delete(
+                test_name,
+                {{.ids = {1, 2, 3}, .keys = 
build_payload_without_key({std::nullopt, 1, 2})}},
+                nullptr,
+                external_struct_schema_field(
+                        "payload", 1,
+                        {external_schema_field("a", 2, {}, std::nullopt,
+                                               
external_primitive_type(TPrimitiveType::INT), false,
+                                               true),
+                         external_schema_field("k", 3, {}, text_default,
+                                               
external_primitive_type(TPrimitiveType::STRING),
+                                               false, true)},
+                        true),
+                ids, 3, true, write_delete_file);
+    };
+    const std::vector<std::pair<std::string, int64_t>> rejected {
+            {"1970-01-01 00:00:00.0000001+00:00", 0},
+            {"1970-01-01 00:00:00+00:00", 100},
+            {"1970-01-01 00:00:00.0000005+00:00", 500}};
+    int run = 0;
+    for (const auto& [text_default, tombstone] : rejected) {
+        SCOPED_TRACE(::testing::Message() << text_default << " tombstone=" << 
tombstone);
+        std::vector<std::vector<int32_t>> ids;
+        const auto status = read_ids(
+                "doris_iceberg_equality_delete_timestamptz_ns_default_" + 
std::to_string(run++),
+                text_default, write_tombstone(tombstone), &ids);
+        ASSERT_FALSE(status.ok()) << "ids=" << ::testing::PrintToString(ids);
+        EXPECT_TRUE(status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>()) << status;
+        EXPECT_NE(status.to_string().find("timestamptz_ns key"), 
std::string::npos) << status;
+    }
+
+    const std::vector<std::pair<std::optional<int64_t>, std::vector<int32_t>>> 
without_default {
+            {std::nullopt, {}}, {0, {1, 2, 3}}};
+    for (const auto& [tombstone, expected_ids] : without_default) {
+        SCOPED_TRACE(::testing::Message()
+                     << "no default, tombstone="
+                     << (tombstone.has_value() ? std::to_string(*tombstone) : 
"NULL"));
+        std::vector<std::vector<int32_t>> ids;
+        const auto status = read_ids(
+                "doris_iceberg_equality_delete_timestamptz_ns_default_" + 
std::to_string(run++),
+                std::nullopt, write_tombstone(tombstone), &ids);
+        ASSERT_TRUE(status.ok()) << status;
+        EXPECT_EQ(ids, (std::vector<std::vector<int32_t>> {expected_ids}));
+    }
+}
+
+// Keys whose types do not convert exactly in either direction fail the scan 
instead of being
+// compared in a third type.
+TEST(IcebergV2ReaderTest, IcebergEqualityDeleteRejectsIncomparableKeyTypes) {
+    std::vector<std::vector<int32_t>> ids;
+    const auto status = read_ids_after_key_equality_delete(
+            "doris_iceberg_equality_delete_incomparable_types",
+            {{.ids = {1}, .keys = build_nullable_int32_keys({1})}},
+            build_nullable_string_array({"1"}),
+            external_schema_field("k", 1, {}, std::nullopt,
+                                  
external_primitive_type(TPrimitiveType::INT), false, true),
+            &ids);
+    ASSERT_FALSE(status.ok());
+    EXPECT_TRUE(status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>()) << status;
+    EXPECT_NE(status.to_string().find("neither type converts exactly to the 
other"),
+              std::string::npos)
+            << status;
+}
+
 TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteMatchesNullForMissingDataColumn) {
     const auto test_dir = std::filesystem::temp_directory_path() /
                           "doris_iceberg_equality_delete_missing_column_test";
@@ -5559,6 +6301,96 @@ TEST(IcebergV2ReaderTest, 
IcebergEqualityDeletePreservesTimestampTzAcrossDstFold
     std::filesystem::remove_all(test_dir);
 }
 
+// Iceberg imports a Spark or Hive INT96 timestamp as timestamptz, but INT96 
reads as DATETIMEV2
+// while the adjusted-UTC tombstone of the same field reads as TIMESTAMPTZ, 
and neither widens to
+// the other. They compare as TIMESTAMPTZ, the INT96 key converted in the 
session time zone as the
+// projection converts it, so the tombstone T1 deletes the row holding T1. 
This plan carries no
+// Parquet timestamp-semantics version, so INT96 also decodes in the session 
time zone; converting
+// it back in a zone other than Asia/Shanghai, such as UTC, would miss T1 by 
eight hours. Without
+// the TIMESTAMPTZ rule the split fails with NotSupported, with or without the 
key projected.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteComparesInt96DataKeyAsTimestampTz) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          
"doris_iceberg_equality_delete_int96_timestamp_tz_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+
+    const auto file_path = (test_dir / "split.parquet").string();
+    const auto delete_file_path = (test_dir / 
"equality-delete.parquet").string();
+    // 2024-01-01 00:00:00.123456 UTC and one second later.
+    constexpr int64_t T1_MICROS = 1'704'067'200'123'456L;
+    constexpr int64_t T2_MICROS = T1_MICROS + 1'000'000L;
+    write_timestamp_int_parquet_file(file_path, {T1_MICROS, T2_MICROS}, {1, 
2}, true);
+    auto physical_reader = ::parquet::ParquetFileReader::OpenFile(file_path, 
false);
+    ASSERT_EQ(::parquet::Type::INT96,
+              
physical_reader->metadata()->schema()->Column(0)->physical_type());
+    physical_reader->Close();
+    write_iceberg_timestamp_equality_delete_parquet_file(delete_file_path, 0, 
T1_MICROS,
+                                                         "event_time", true);
+
+    for (const bool project_key : {false, true}) {
+        SCOPED_TRACE(project_key ? "id and event_time projected" : "id 
projected");
+        std::vector<ColumnDefinition> projected_columns;
+        projected_columns.push_back(make_table_column(1, "id", 
std::make_shared<DataTypeInt32>()));
+        if (project_key) {
+            projected_columns.push_back(
+                    make_table_column(0, "event_time", 
std::make_shared<DataTypeTimeStampTz>(6)));
+        }
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        scan_params.__set_enable_mapping_timestamp_tz(true);
+        scan_params.__set_current_schema_id(100);
+        scan_params.__set_history_schema_info({external_schema(
+                100,
+                {external_schema_field("event_time", 0, {}, std::nullopt,
+                                       
external_primitive_type(TPrimitiveType::TIMESTAMPTZ, -1, 6)),
+                 external_schema_field("id", 1)})});
+
+        RuntimeProfile profile("test_profile");
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        state.set_timezone("Asia/Shanghai");
+        io::FileReaderStats file_reader_stats;
+        io::FileCacheStatistics file_cache_stats;
+        auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+        ShardedKVCache cache(1);
+        doris::format::iceberg::IcebergTableReader reader;
+        init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+        auto split_options = build_split_options(file_path);
+        split_options.cache = &cache;
+        
split_options.current_range.__set_table_format_params(make_iceberg_table_format_desc(
+                file_path, 
{make_iceberg_equality_delete_file(delete_file_path, {0})}));
+        ASSERT_TRUE(reader.prepare_split(split_options).ok());
+        std::vector<int32_t> ids;
+        std::vector<std::string> event_times;
+        bool eos = false;
+        while (!eos) {
+            Block block = build_table_block(projected_columns);
+            const auto status = reader.get_block(&block, &eos);
+            ASSERT_TRUE(status.ok()) << status;
+            if (block.rows() == 0) {
+                continue;
+            }
+            const auto& id_column =
+                    assert_cast<const 
ColumnInt32&>(expect_not_null_table_column(block, 0));
+            for (size_t row = 0; row < block.rows(); ++row) {
+                ids.push_back(id_column.get_element(row));
+                if (project_key) {
+                    event_times.push_back(projected_columns[1].type->to_string(
+                            *block.get_by_position(1).column, row));
+                }
+            }
+        }
+        EXPECT_EQ(ids, std::vector<int32_t>({2}));
+        if (project_key) {
+            // The projection converts the INT96 value back in the same zone, 
giving T2.
+            EXPECT_EQ(event_times, std::vector<std::string>({"2024-01-01 
00:00:01.123456+00:00"}));
+        }
+        ASSERT_TRUE(reader.close().ok());
+    }
+    std::filesystem::remove_all(test_dir);
+}
+
 TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteUsesNameMappingWithoutFileFieldIds) {
     const auto test_dir = std::filesystem::temp_directory_path() /
                           "doris_iceberg_equality_delete_name_mapping_test";


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

Reply via email to