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 a9253e979d7 [fix](iceberg) Decode Parquet timestamptz columns as 
instants by the Iceberg table type (#69845)
a9253e979d7 is described below

commit a9253e979d7048e81a1bb34f9b77c5176f340996
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Nov 2 01:30:52 2026 +0000

    [fix](iceberg) Decode Parquet timestamptz columns as instants by the 
Iceberg table type (#69845)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69711
    
    Problem Summary:
    
    **In short.** An Iceberg `timestamptz` column stored as Parquet INT96,
    the encoding Spark and Hive use for timestamps, reads shifted by the
    session time zone's offset in any non-UTC session: in Asia/Shanghai,
    2024-01-01T00:00:00Z reads as `2024-01-01 00:00:00+08:00`, eight hours
    early, while Iceberg itself reads `2024-01-01 08:00:00+08:00`. Equality
    deletes on such a key miss their rows for the same reason. This PR has
    the FE mark each `timestamptz` field of the scan's schema dictionary,
    and FileScannerV2 then decodes the matching Parquet columns of data
    files and equality-delete files as instants, which is how Iceberg reads
    them. It came up in the review of #69711.
    
    **Background.**
    
    - Parquet INT96 has no logical annotation, so a reader cannot tell from
    the file whether an INT96 column holds an instant or a wall clock. Spark
    writes its `timestamp` type (an instant) as INT96 by default, and
    `add_files`, `migrate` and `snapshot` attach such files to Iceberg
    tables unchanged, as `timestamptz` columns.
    - Iceberg does not look at the file annotation at all: its Parquet
    readers choose the timestamp reader from the table type, so a
    `timestamptz` field reads INT96, and INT64 with or without the UTC
    annotation, as an instant.
    - The FE sends `parquet_timestamp_semantics_version = 1` for every file
    scan. Parquet scans therefore always use FileScannerV2, and INT96
    decodes as a UTC wall clock (`DATETIMEV2`) unless the table format
    supplies the semantics. Paimon has the same ambiguity (TIMESTAMP and
    TIMESTAMP_LTZ share INT96) and solves it with
    `timestamp_is_adjusted_to_utc` on each field of its schema dictionary,
    which `PaimonReader` applies to the file schema. The Iceberg FE never
    set that field.
    
    **The problem, and what it cost.** For a `timestamptz` column, the
    projection casts the INT96 wall clock to `TIMESTAMPTZ` in the session
    time zone, so the instant moves by the session offset. Reproduced on
    master with an INT96 file attached like `add_files` and an
    Iceberg-written INT64 file holding the same instants, both in one table:
    
    | Session | Rows | Iceberg Java | Before | After |
    |---|---|---|---|---|
    | UTC | INT96 and INT64 | 2024-01-01 00:00:00 UTC | correct | correct |
    | Asia/Shanghai | INT64 (written by Iceberg) | `2024-01-01
    08:00:00+08:00` | correct | correct |
    | Asia/Shanghai | INT96 (written by Spark) | `2024-01-01 08:00:00+08:00`
    | `2024-01-01 00:00:00+08:00`, 8 hours early | `2024-01-01
    08:00:00+08:00` |
    
    The result is the same with field ids in the file, with only the table's
    name mapping, whatever the catalog's `enable.mapping.timestamp_tz` says
    (master always maps `timestamptz` to TIMESTAMPTZ), and with
    `enable_file_scanner_v2` on or off. The same shift breaks equality
    deletes, which compare the projected values:
    
    | Equality delete on a `timestamptz` key, non-UTC session | Before |
    After |
    |---|---|---|
    | INT96 data key, Iceberg-written tombstone of the same instant | the
    tombstone misses the row | the row is deleted |
    
    Wrong values and missed or extra deletes come back without any error.
    
    **How this PR fixes it.**
    
    - FE: `IcebergSchemaUtils.buildField` sets `timestamp_is_adjusted_to_utc
    = true` on every `timestamptz` field, at every nesting level; the
    dictionary also carries the hidden equality-delete keys. `timestamp`
    fields stay unmarked: marking them `false` would make BE read the
    Parquet statistics of an adjusted-UTC INT64 file as UTC wall clocks
    while its rows still decode as session wall clocks, so row-group pruning
    could drop matching rows.
    - BE, data files: `IcebergTableReader::annotate_file_schema` copies the
    marker onto the Parquet file column that `TableColumnMapper` will select
    for each field, using the mapper's own matchers
    (`find_column_by_field_id` / `find_column_by_name`) after the positional
    fallback ids are stamped, so field ids, the table's name mapping and
    positional ids all work. Like the mapper, it accepts an ID-less wrapper
    at every depth when `allow_idless_complex_wrapper_projection` is on. It
    then rewrites the column type with the helper `PaimonReader` already
    used, moved to `schema_history_util` as
    `apply_file_timestamp_semantics`. The mapping, the scan request and
    `ParquetReader` all see `TIMESTAMPTZ`, and INT96 decodes as an instant.
    Only a `DATETIMEV2` or `TIMESTAMPTZ` leaf takes the marker; a
    `TIMESTAMP_NS` leaf keeps its nanoseconds. Columns without a marked leaf
    keep the types the file reader reported, and a plan from an older FE
    carries no marker and reads as before.
    - BE, equality deletes: three kinds of column are read without a
    `ColumnMapping`: a hidden key column, the parent struct that is read for
    the NULLs of a nested key the data file lacks, and the key columns of an
    equality-delete file. Their projections now carry the markers of the
    annotated file column (`equality_delete_root_projection`, built on
    `attach_timestamp_semantics`, which the mapper now exports). Without
    them Parquet would decode an INT96 leaf as `DATETIMEV2` under a
    `TIMESTAMPTZ` file schema, and the read would fail its type check. That
    holds even when the query projects the column, because reading it as a
    predicate column replaces the projection its mapping marked.
    Equality-delete files get the same annotation, so tombstones and data
    keys of a `timestamptz` field are both instants. The `DATETIMEV2` /
    `TIMESTAMPTZ` comparison rule from #69711 stays for plans from older
    FEs.
    - Refactor: `find_current_schema` and
    `build_schema_identity_from_external_field` (now also carrying the
    marker) move to `schema_history_util`; `TableReader`'s four copies of
    the current-schema lookup use the helper.
    
    **Results.**
    
    - FE unit test
    `IcebergSchemaUtilsTest#timestamptzFieldsCarryInstantMarkerAtEveryLevel`:
    top-level, struct, list and map `timestamptz` fields are marked;
    `timestamp` and `timestamp_ns` fields are not, under both values of the
    mapping flag.
    - 5 new BE unit tests in `iceberg_reader_test.cpp`, all failing without
    the BE change: the file-schema annotation (field ids at every level,
    `TIMESTAMP_NS` untouched, ORC untouched); INT96 `timestamptz` in
    Asia/Shanghai with field ids, name mapping and positional ids, with and
    without the marker; INT96 inside a struct and a list; an INT96 equality
    key against an Iceberg tombstone, hidden or projected; and the DST
    fall-back above. Leaving the delete files unannotated fails the DST case
    (the second tombstone deletes the first row); projecting a hidden key or
    a tombstone without its marker fails both equality-delete cases with a
    read error; marking a `TIMESTAMP_NS` leaf fails the annotation case.
    With neither data nor delete files annotated (the reader before this
    PR), the INT96 key deletes nothing and the DST case deletes both rows.
    The existing case for an older FE's plan (no marker) still passes.
    - 2 more BE unit tests from review:
    - INT96 under a nested ID-less wrapper struct, read and used as an
    equality key. It fails when the walk accepts the wrapper only at the
    root.
    - A nested equality key missing from an older file whose parent struct
    holds an INT96 `timestamptz`. It checks the survivors with the parent
    hidden and projected, that the projected timestamp reads as an instant
    in Asia/Shanghai, and a plan without the marker. It fails with the
    reader's type check when the parent is read without the marker.
    - End to end on a local cluster: an Iceberg table holding an INT96
    Parquet file attached like `add_files` (one table with field ids in the
    file, one relying on `schema.name-mapping.default`) and an
    Iceberg-written INT64 file with the same instants, read through three
    catalogs (`enable.mapping.timestamp_tz` unset, `true`, `false`), with
    `enable_file_scanner_v2` on and off, in UTC and Asia/Shanghai: 24
    combinations. Before this PR the INT96 rows were 8 hours early in every
    Asia/Shanghai combination (8 of the 16 measured); with it the INT96 rows
    equal the INT64 rows in all 24, and the INT64 rows and the UTC results
    are unchanged.
    
    branch-4.1 and branch-4.2 have the same code.
    
    **Classes, and how they call each other.**
    
    - `IcebergSchemaUtils` (FE): builds the schema dictionary; now marks
    `timestamptz` fields.
    - `schema_history_util`: `find_current_schema`,
    `build_schema_identity_from_external_field`, and
    `apply_file_timestamp_semantics` (moved from `PaimonReader`).
    - `IcebergTableReader`: `annotate_file_schema` →
    `_apply_positional_id_fallback` (the existing logic) and
    `_apply_table_timestamp_semantics`; `_append_equality_delete_predicates`
    (hidden keys), `_build_missing_equality_delete_key_expr` (the parent of
    a missing nested key) and `_load_equality_delete_file` (tombstones)
    project their columns with the markers through
    `equality_delete_root_projection`.
    - `column_mapper`: `attach_timestamp_semantics` is exported.
    
    ```
    FE  IcebergSchemaUtils.buildField  --timestamptz-->  
TField.timestamp_is_adjusted_to_utc = true
                                                         (history_schema_info, 
current_schema_id = -1)
    BE  TableReader::open_reader
          +-- IcebergTableReader::annotate_file_schema                     
(Parquet only)
          |     +-- _apply_positional_id_fallback
          |     +-- _apply_table_timestamp_semantics
          |           +-- find_current_schema, 
build_schema_identity_from_external_field
          |           +-- find_column_by_field_id | find_column_by_name    (the 
mapper's matchers)
          |           +-- apply_file_timestamp_semantics                   
(shared with PaimonReader)
          +-- TableColumnMapper: mapping/projection take the marker from the 
file column
          +-- ParquetReader::open: apply_projection_timestamp_semantics -> 
INT96 decodes as TIMESTAMPTZ
        IcebergTableReader::_append_equality_delete_predicates
          +-- equality_delete_root_projection -> attach_timestamp_semantics   
(hidden key)
          +-- _build_missing_equality_delete_key_expr
                +-- equality_delete_root_projection                           
(parent of a missing key)
        IcebergTableReader::_load_equality_delete_file -> 
_apply_table_timestamp_semantics
                                                       + 
equality_delete_root_projection (tombstones)
    ```
    
    ORC files are not changed: ORC's file type already separates `TIMESTAMP`
    from `TIMESTAMP_INSTANT`.
    
    ### Release note
    
    Fix Iceberg `timestamptz` columns stored as Parquet INT96 (written by
    Spark or Hive and attached with `add_files`, `migrate` or `snapshot`)
    reading shifted by the session time zone's offset in non-UTC sessions,
    and equality deletes on such keys missing their rows.
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [x] Unit Test
    - BE: `run-be-ut.sh --run
    
--filter='IcebergV2ReaderTest.*:Paimon*ReaderTest.*:Hudi*Test.*:TableReaderTest.*:ColumnMapper*:FlussUnionLakeReaderTest.*:EqualityDeletePredicateTest.*:*PositionDelete*'`:
    532 of 532 pass on macOS arm64 (ASAN), including the 5 new cases. After
    the first review round, `IcebergV2ReaderTest` passes 122 of 122,
    including the new nested ID-less wrapper case. It fails when the walk
    accepts the wrapper only at the root. After the second review round, the
    whole filter passes 534 of 534 and `IcebergV2ReaderTest` 123 of 123,
    including the new missing nested key case. It fails when the missing
    key's parent is read without the marker. They ran on a local base; the
    commit was then applied to master `2a4191c1dc9` without conflicts, and
    the changed sources and their test files were syntax-checked against
    that tree with the UT build flags.
    - FE: `mvn -pl :fe-connector-iceberg -am package
    -Dtest=IcebergSchemaUtilsTest`: 35 of 35 pass, checkstyle passes.
        - [x] Manual test (add detailed scripts or steps below)
    - The local end-to-end check under Results, with an ASAN BE and the
    Iceberg connector built from this change.
        - [ ] No need to test or manual test. Explain why:
    - Behavior changed:
        - [ ] No.
    - [x] Yes. Parquet columns of Iceberg `timestamptz` fields decode as
    instants by the table type, as Iceberg reads them: INT96 no longer
    shifts by the session offset, an INT64 timestamp without the UTC
    annotation under a `timestamptz` field reads its value as UTC, and
    equality deletes on `timestamptz` keys compare instants. Plans from an
    older FE are unchanged.
    - 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/column_mapper.cpp                 |   6 +-
 be/src/format_v2/column_mapper.h                   |   8 +
 be/src/format_v2/table/iceberg_reader.cpp          | 166 +++-
 be/src/format_v2/table/iceberg_reader.h            |  35 +-
 be/src/format_v2/table/paimon_reader.cpp           |  57 +-
 be/src/format_v2/table/schema_history_util.cpp     | 117 +++
 be/src/format_v2/table/schema_history_util.h       |  18 +
 be/src/format_v2/table_reader.cpp                  |  95 +--
 be/test/format_v2/table/iceberg_reader_test.cpp    | 865 ++++++++++++++++++++-
 .../connector/iceberg/IcebergSchemaUtils.java      |  12 +-
 .../connector/iceberg/IcebergSchemaUtilsTest.java  |  42 +
 11 files changed, 1248 insertions(+), 173 deletions(-)

diff --git a/be/src/format_v2/column_mapper.cpp 
b/be/src/format_v2/column_mapper.cpp
index 2126f78478d..e8e275612d4 100644
--- a/be/src/format_v2/column_mapper.cpp
+++ b/be/src/format_v2/column_mapper.cpp
@@ -1957,9 +1957,9 @@ static bool has_timestamp_semantics(const 
ColumnDefinition& column) {
            std::ranges::any_of(column.children, has_timestamp_semantics);
 }
 
-static void attach_timestamp_semantics(const std::optional<bool>& 
timestamp_is_adjusted_to_utc,
-                                       const std::vector<ColumnDefinition>& 
file_children,
-                                       LocalColumnIndex* projection) {
+void attach_timestamp_semantics(const std::optional<bool>& 
timestamp_is_adjusted_to_utc,
+                                const std::vector<ColumnDefinition>& 
file_children,
+                                LocalColumnIndex* projection) {
     DORIS_CHECK(projection != nullptr);
     projection->timestamp_is_adjusted_to_utc = timestamp_is_adjusted_to_utc;
     for (const auto& file_child : file_children) {
diff --git a/be/src/format_v2/column_mapper.h b/be/src/format_v2/column_mapper.h
index 41c8cc953f7..0b4c1bfed0d 100644
--- a/be/src/format_v2/column_mapper.h
+++ b/be/src/format_v2/column_mapper.h
@@ -196,6 +196,14 @@ const ColumnDefinition* find_column_by_name(const 
ColumnDefinition& table_column
 const ColumnDefinition* find_column_by_field_id(const ColumnDefinition& 
table_column,
                                                 const 
std::vector<ColumnDefinition>& file_schema,
                                                 bool 
allow_idless_complex_wrapper_projection);
+// Put the timestamp semantics recorded on an annotated file column and its 
descendants onto a
+// projection of that column. A full projection gains a child only for a 
subtree that carries a
+// semantic; a partial projection keeps its selection. Callers that project a 
file column without
+// a ColumnMapping, such as a hidden equality-delete key, must call this or 
the file reader keeps
+// the physical type while the annotated file schema expects the table-format 
type.
+void attach_timestamp_semantics(const std::optional<bool>& 
timestamp_is_adjusted_to_utc,
+                                const std::vector<ColumnDefinition>& 
file_children,
+                                LocalColumnIndex* projection);
 
 // Generic mapping layer from table schema to file schema.
 // Iceberg uses BY_FIELD_ID. Plain by-name formats can reuse this component as 
well, so keep this
diff --git a/be/src/format_v2/table/iceberg_reader.cpp 
b/be/src/format_v2/table/iceberg_reader.cpp
index 9463fe0d9b0..82d086d417e 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -165,6 +165,73 @@ const char* file_format_name(FileFormat format) {
     return "UNKNOWN";
 }
 
+const format::ColumnDefinition* find_matched_file_column(
+        const format::ColumnDefinition& table_column,
+        const std::vector<format::ColumnDefinition>& file_columns,
+        format::TableColumnMappingMode mode, bool 
allow_idless_complex_wrapper_projection) {
+    return mode == format::TableColumnMappingMode::BY_FIELD_ID
+                   ? format::find_column_by_field_id(table_column, 
file_columns,
+                                                     
allow_idless_complex_wrapper_projection)
+                   : format::find_column_by_name(table_column, file_columns);
+}
+
+// Copy the table field's timestamp marker onto the file column 
TableColumnMapper selects for it,
+// descending the way the mapper does: ARRAY and MAP children by position, 
STRUCT children by the
+// mapping mode, through an ID-less wrapper at any depth when the mapper 
allows one. Only a
+// DATETIMEV2 or TIMESTAMPTZ leaf takes the marker. A TIMESTAMP_NS leaf (an 
INT64 NANOS column
+// without the UTC annotation) keeps its nanoseconds: the Parquet reader 
rewrites any marked
+// timestamp leaf, so a marker there would make it decode TIMESTAMPTZ while 
the file schema still
+// says TIMESTAMP_NS. Returns whether any leaf took a marker.
+bool annotate_matched_timestamp_semantics(const format::ColumnDefinition& 
table_column,
+                                          format::ColumnDefinition* 
file_column,
+                                          format::TableColumnMappingMode mode,
+                                          bool 
allow_idless_complex_wrapper_projection) {
+    DORIS_CHECK(file_column != nullptr);
+    DORIS_CHECK(file_column->type != nullptr);
+    switch (remove_nullable(file_column->type)->get_primitive_type()) {
+    case TYPE_DATETIMEV2:
+    case TYPE_TIMESTAMPTZ:
+        file_column->timestamp_is_adjusted_to_utc = 
table_column.timestamp_is_adjusted_to_utc;
+        return file_column->timestamp_is_adjusted_to_utc.has_value();
+    case TYPE_ARRAY:
+        DORIS_CHECK(file_column->children.size() == 1);
+        // FE omits nested metadata for a field it describes only by its root.
+        return table_column.children.size() == 1 &&
+               annotate_matched_timestamp_semantics(table_column.children[0],
+                                                    &file_column->children[0], 
mode,
+                                                    
allow_idless_complex_wrapper_projection);
+    case TYPE_MAP: {
+        DORIS_CHECK(file_column->children.size() == 2);
+        if (table_column.children.size() != 2) {
+            return false;
+        }
+        const bool key = annotate_matched_timestamp_semantics(
+                table_column.children[0], &file_column->children[0], mode,
+                allow_idless_complex_wrapper_projection);
+        const bool value = annotate_matched_timestamp_semantics(
+                table_column.children[1], &file_column->children[1], mode,
+                allow_idless_complex_wrapper_projection);
+        return key || value;
+    }
+    case TYPE_STRUCT: {
+        bool annotated = false;
+        for (const auto& table_child : table_column.children) {
+            // TableColumnMapper falls back to an ID-less wrapper below the 
root as well.
+            const auto* matched = find_matched_file_column(table_child, 
file_column->children, mode,
+                                                           
allow_idless_complex_wrapper_projection);
+            if (matched != nullptr) {
+                annotated |= annotate_matched_timestamp_semantics(
+                        table_child, &file_column->children[matched - 
file_column->children.data()],
+                        mode, allow_idless_complex_wrapper_projection);
+            }
+        }
+        return annotated;
+    }
+    default:
+        return false;
+    }
+}
+
 } // namespace
 
 bool IcebergTableReader::_scan_has_any_authoritative_name_mapping() const {
@@ -212,20 +279,28 @@ Status IcebergTableReader::annotate_file_schema(
     // Iceberg's positional fallback is a Parquet-reader-only compatibility 
path. Its ORC/Avro
     // readers instead derive a name mapping from the expected schema for 
id-less files, which is
     // exactly the current-name resurrection that v2 scan semantics 
deliberately rejects (#65784),
-    // so ORC keeps its existing behavior and stays out of scope here.
+    // so ORC keeps its existing behavior and stays out of scope here. ORC 
needs no timestamp
+    // marker either: its file type already separates TIMESTAMP from 
TIMESTAMP_INSTANT.
     if (_format != FileFormat::PARQUET) {
         return Status::OK();
     }
+    _apply_positional_id_fallback(file_schema);
+    return _apply_table_timestamp_semantics(file_schema, 
_mapping_mode(*file_schema));
+}
+
+void IcebergTableReader::_apply_positional_id_fallback(
+        std::vector<format::ColumnDefinition>* file_schema) {
+    DORIS_CHECK(file_schema != nullptr);
     // Iceberg's `hasIds` contract is existential: a single real field id 
keeps the file on the id
     // path and, exactly as in Iceberg, skips both the name mapping and the 
positional fallback.
     if (schema_has_any_field_id(*file_schema)) {
-        return Status::OK();
+        return;
     }
     // An old-FE plan keeps the legacy name fallback (rolling upgrade), and an 
explicit table-level
     // name mapping stays authoritative even when it does not cover this file.
     if (!supports_iceberg_scan_semantics_v2(_scan_params) ||
         _scan_has_any_authoritative_name_mapping()) {
-        return Status::OK();
+        return;
     }
     // ParquetSchemaUtil.addFallbackIds: ordinals start at 1, are counted over 
the physical
     // top-level fields in file order, and are not propagated to nested 
fields. Reader-synthesized
@@ -245,6 +320,46 @@ Status IcebergTableReader::annotate_file_schema(
         field.identifier = Field::create_field<TYPE_INT>(ordinal);
     }
     _positional_id_fallback = true;
+}
+
+// Iceberg decodes a Parquet timestamp by the table field's type, not by the 
file's annotation.
+// Spark and Hive write a timestamptz as INT96, which has no annotation, and 
`add_files` / `migrate`
+// attach those files unchanged. Without a marker FileScannerV2 decodes INT96 
as a UTC wall clock
+// (DATETIMEV2) and the projection casts that to TIMESTAMPTZ in the session 
time zone, so in
+// Asia/Shanghai 2024-01-01T00:00:00Z reads as 2024-01-01 00:00:00+08:00, 
eight hours early. FE
+// marks each timestamptz field; copying the marker onto the matched file 
column and rewriting its
+// type gives the mapping, the scan request and the equality-delete keys the 
instant Iceberg reads.
+// A plan from an older FE carries no marker and keeps the file types.
+Status IcebergTableReader::_apply_table_timestamp_semantics(
+        std::vector<format::ColumnDefinition>* file_schema,
+        format::TableColumnMappingMode mode) const {
+    DORIS_CHECK(file_schema != nullptr);
+    const auto* schema = format::find_current_schema(_scan_params);
+    if (schema == nullptr || !schema->__isset.root_field || 
!schema->root_field.__isset.fields) {
+        return Status::OK();
+    }
+    // As in configure_mapper_options(); only Parquet files get here.
+    const bool allow_idless_complex_wrapper_projection =
+            supports_iceberg_scan_semantics_v1(_scan_params);
+    for (const auto& field_ptr : schema->root_field.fields) {
+        const auto* field = format::get_field_ptr(field_ptr);
+        if (field == nullptr) {
+            continue;
+        }
+        const auto table_column = 
format::build_schema_identity_from_external_field(*field);
+        const auto* matched = find_matched_file_column(table_column, 
*file_schema, mode,
+                                                       
allow_idless_complex_wrapper_projection);
+        if (matched == nullptr) {
+            continue;
+        }
+        auto* file_column = &(*file_schema)[matched - file_schema->data()];
+        // Rebuilding a container recreates its type from the children, so 
leave every column
+        // without a marked leaf exactly as the file reader reported it.
+        if (annotate_matched_timestamp_semantics(table_column, file_column, 
mode,
+                                                 
allow_idless_complex_wrapper_projection)) {
+            
RETURN_IF_ERROR(format::apply_file_timestamp_semantics(file_column));
+        }
+    }
     return Status::OK();
 }
 
@@ -1075,6 +1190,23 @@ static Status build_equality_delete_key_expr(
     return build_nested_equality_delete_key_expr(path, std::move(root_expr), 
key_expr);
 }
 
+// Project the root file column of an equality key read without a 
ColumnMapping, carrying the
+// timestamp markers annotate_file_schema() and _load_equality_delete_file() 
put on the file schema.
+// The file block and the key expressions take the rewritten types from that 
schema, but the file
+// reader learns them only from the projection. For example, an older file 
holds payload
+// {existing INT, ts INT96 timestamptz} and the table later added payload.k: a 
delete on k reads
+// `payload` for its NULLs, and a bare top-level projection makes Parquet read
+// STRUCT<existing, DATETIMEV2(6)> into a STRUCT<existing, TIMESTAMPTZ(6)> 
file block, which fails
+// the scan.
+static format::LocalColumnIndex equality_delete_root_projection(
+        const format::ColumnDefinition& root) {
+    auto projection =
+            
format::LocalColumnIndex::top_level(format::LocalColumnId(root.file_local_id()));
+    format::attach_timestamp_semantics(root.timestamp_is_adjusted_to_utc, 
root.children,
+                                       &projection);
+    return projection;
+}
+
 // 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
@@ -1203,6 +1335,9 @@ static Status equality_delete_comparison_type(int 
field_id, const DataTypePtr& d
     // 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.
+    // When FE marks timestamptz fields, annotate_file_schema() and 
_load_equality_delete_file()
+    // read both Parquet sides as TIMESTAMPTZ already, so for Parquet this 
rule now serves plans
+    // from older FEs.
     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))) {
@@ -1919,7 +2054,10 @@ Status 
IcebergTableReader::_build_missing_equality_delete_key_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);
+    // The whole ancestor is read for its NULLs, so a marked timestamp leaf 
inside it, such as an
+    // INT96 timestamptz sibling of the missing key, needs its marker as well.
+    
RETURN_IF_ERROR(format::FileScanRequestBuilder(request).add_predicate_column(
+            equality_delete_root_projection(*root)));
     const auto block_position = 
request->local_positions.at(field_column_id).value();
     VExprSPtr ancestor_expr;
     RETURN_IF_ERROR(build_equality_delete_key_expr(data_path, block_position, 
&ancestor_expr));
@@ -1961,7 +2099,14 @@ Status 
IcebergTableReader::_append_equality_delete_predicates(format::FileScanRe
             } else {
                 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);
+                // A hidden key, like the missing key's ancestor above and the 
delete file's keys,
+                // has no ColumnMapping to carry the timestamp marker 
annotate_file_schema() put on
+                // its file column. Without it Parquet decodes an INT96 
timestamptz key as
+                // DATETIMEV2 while the key expression below, built from the 
annotated file schema,
+                // expects TIMESTAMPTZ. A projected key, or a projected 
ancestor of a missing key,
+                // moves into the predicate columns and must keep the marker 
its mapping carried.
+                
RETURN_IF_ERROR(format::FileScanRequestBuilder(request).add_predicate_column(
+                        equality_delete_root_projection(*root)));
                 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));
@@ -2284,6 +2429,15 @@ Status 
IcebergTableReader::_load_equality_delete_file(const TIcebergDeleteFileDe
 
     std::vector<format::ColumnDefinition> schema;
     RETURN_IF_ERROR(reader->get_schema(&schema));
+    if (delete_file.file_format == TFileFormatType::FORMAT_PARQUET) {
+        // Decode tombstones of a timestamptz key as instants, like the data 
keys they are
+        // compared with. Otherwise a catalog with 
enable_mapping_timestamp_tz=false reads them as
+        // session-local wall clocks, which cannot tell the two instants of a 
DST fall-back apart:
+        // in America/New_York a tombstone for 2024-11-03T05:30:00Z (01:30 
EDT) would then match
+        // the row at 06:30:00Z (01:30 EST) as well, or instead. Delete files 
carry field ids.
+        RETURN_IF_ERROR(_apply_table_timestamp_semantics(
+                &schema, format::TableColumnMappingMode::BY_FIELD_ID));
+    }
     std::vector<EqualityDeleteColumnPath> delete_paths;
     RETURN_IF_ERROR(_resolve_equality_delete_fields(delete_file, schema, 
&delete_paths, result));
 
@@ -2292,7 +2446,7 @@ Status 
IcebergTableReader::_load_equality_delete_file(const TIcebergDeleteFileDe
     for (const auto& path : delete_paths) {
         DORIS_CHECK(!path.empty());
         RETURN_IF_ERROR(request_builder.add_non_predicate_column(
-                format::LocalColumnId(path.front()->file_local_id())));
+                equality_delete_root_projection(*path.front())));
     }
     Block file_block_template;
     std::vector<const format::ColumnDefinition*> 
roots(request->local_positions.size());
diff --git a/be/src/format_v2/table/iceberg_reader.h 
b/be/src/format_v2/table/iceberg_reader.h
index 47f71353137..90feaac4f8f 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -68,19 +68,7 @@ public:
                                      format::ColumnDefinition* column) const 
override;
     std::string debug_string() const override;
     format::TableColumnMappingMode mapping_mode() const override {
-        const bool has_field_ids = 
supports_iceberg_scan_semantics_v1(_scan_params)
-                                           ? 
schema_has_any_field_id(_data_reader.file_schema)
-                                           : 
schema_has_all_field_ids(_data_reader.file_schema);
-        if (!_data_reader.file_schema.empty() && has_field_ids) {
-            return format::TableColumnMappingMode::BY_FIELD_ID;
-        }
-        if (!_data_reader.file_schema.empty() && 
supports_iceberg_scan_semantics_v2(_scan_params) &&
-            !_scan_has_any_authoritative_name_mapping()) {
-            // ID-less migrated files are name-readable only while Iceberg's 
explicit default name
-            // mapping exists; current names must not resurrect file fields 
after it is removed.
-            return format::TableColumnMappingMode::BY_FIELD_ID;
-        }
-        return format::TableColumnMappingMode::BY_NAME;
+        return _mapping_mode(_data_reader.file_schema);
     }
 
 protected:
@@ -149,6 +137,27 @@ private:
     static constexpr size_t ICEBERG_ROW_POS_BLOCK_POSITION = 1;
 
     bool _scan_has_any_authoritative_name_mapping() const;
+    // annotate_file_schema() runs before TableReader stores the file schema, 
so the mode is also
+    // computable from the schema being annotated.
+    format::TableColumnMappingMode _mapping_mode(
+            const std::vector<format::ColumnDefinition>& file_schema) const {
+        const bool has_field_ids = 
supports_iceberg_scan_semantics_v1(_scan_params)
+                                           ? 
schema_has_any_field_id(file_schema)
+                                           : 
schema_has_all_field_ids(file_schema);
+        if (!file_schema.empty() && has_field_ids) {
+            return format::TableColumnMappingMode::BY_FIELD_ID;
+        }
+        if (!file_schema.empty() && 
supports_iceberg_scan_semantics_v2(_scan_params) &&
+            !_scan_has_any_authoritative_name_mapping()) {
+            // ID-less migrated files are name-readable only while Iceberg's 
explicit default name
+            // mapping exists; current names must not resurrect file fields 
after it is removed.
+            return format::TableColumnMappingMode::BY_FIELD_ID;
+        }
+        return format::TableColumnMappingMode::BY_NAME;
+    }
+    void _apply_positional_id_fallback(std::vector<format::ColumnDefinition>* 
file_schema);
+    Status 
_apply_table_timestamp_semantics(std::vector<format::ColumnDefinition>* 
file_schema,
+                                            format::TableColumnMappingMode 
mode) const;
 
     class PositionDeleteRowsCollector final {
     public:
diff --git a/be/src/format_v2/table/paimon_reader.cpp 
b/be/src/format_v2/table/paimon_reader.cpp
index 4473240011d..2230b67ec21 100644
--- a/be/src/format_v2/table/paimon_reader.cpp
+++ b/be/src/format_v2/table/paimon_reader.cpp
@@ -31,7 +31,6 @@
 #include "core/column/column_string.h"
 #include "core/column/column_vector.h"
 #include "core/data_type/data_type_array.h"
-#include "core/data_type/data_type_factory.hpp"
 #include "core/data_type/data_type_map.h"
 #include "core/data_type/data_type_nullable.h"
 #include "core/data_type/data_type_number.h"
@@ -50,58 +49,6 @@
 namespace doris::format::paimon {
 namespace {
 
-DataTypePtr nullable_like_original(const DataTypePtr& original, DataTypePtr 
nested) {
-    return original != nullptr && original->is_nullable() ? 
make_nullable(nested) : nested;
-}
-
-// Unannotated INT96 cannot distinguish Paimon TIMESTAMP from TIMESTAMP_LTZ. 
Apply the
-// table schema's instant marker to each timestamp leaf, then rebuild its 
containers so the
-// parent type agrees with the children. Preserve precision and the original 
nullability.
-Status apply_paimon_timestamp_semantics(format::ColumnDefinition* column) {
-    DORIS_CHECK(column != nullptr);
-    DORIS_CHECK(column->type != nullptr);
-    const auto primitive = remove_nullable(column->type)->get_primitive_type();
-    // Validate before changing children: silently skipping a malformed parent 
leaves stale types.
-    if ((primitive == TYPE_ARRAY && column->children.size() != 1) ||
-        (primitive == TYPE_MAP && column->children.size() != 2)) {
-        return Status::InvalidArgument("Invalid Paimon {} column '{}': {} 
children",
-                                       column->type->get_name(), column->name,
-                                       column->children.size());
-    }
-    if (column->timestamp_is_adjusted_to_utc.has_value() &&
-        (primitive == TYPE_DATETIMEV2 || primitive == TYPE_TIMESTAMPTZ)) {
-        const auto target =
-                *column->timestamp_is_adjusted_to_utc ? TYPE_TIMESTAMPTZ : 
TYPE_DATETIMEV2;
-        column->type = DataTypeFactory::instance().create_data_type(
-                target, column->type->is_nullable(), 0, 
column->type->get_scale());
-        return Status::OK();
-    }
-
-    std::vector<DataTypePtr> child_types;
-    child_types.reserve(column->children.size());
-    for (auto& child : column->children) {
-        RETURN_IF_ERROR(apply_paimon_timestamp_semantics(&child));
-        child_types.push_back(child.type);
-    }
-    if (primitive == TYPE_ARRAY) {
-        column->type = nullable_like_original(column->type,
-                                              
std::make_shared<DataTypeArray>(child_types.front()));
-    } else if (primitive == TYPE_MAP) {
-        column->type = nullable_like_original(
-                column->type, 
std::make_shared<DataTypeMap>(make_nullable(child_types[0]),
-                                                            
make_nullable(child_types[1])));
-    } else if (primitive == TYPE_STRUCT) {
-        Strings child_names;
-        child_names.reserve(column->children.size());
-        for (const auto& child : column->children) {
-            child_names.push_back(child.name);
-        }
-        column->type = nullable_like_original(
-                column->type, std::make_shared<DataTypeStruct>(child_types, 
child_names));
-    }
-    return Status::OK();
-}
-
 ColumnDefinition* find_file_column(const ColumnDefinition& table_column,
                                    std::vector<ColumnDefinition>* file_schema,
                                    TableColumnMappingMode mode) {
@@ -288,8 +235,10 @@ Status 
PaimonReader::annotate_file_schema(std::vector<format::ColumnDefinition>*
                                                                   
file_schema));
     }
     if (_format == format::FileFormat::PARQUET) {
+        // Unannotated INT96 cannot distinguish Paimon TIMESTAMP from 
TIMESTAMP_LTZ; the markers
+        // copied from the split's history schema decide.
         for (auto& column : *file_schema) {
-            RETURN_IF_ERROR(apply_paimon_timestamp_semantics(&column));
+            RETURN_IF_ERROR(format::apply_file_timestamp_semantics(&column));
         }
         const bool projects_variant =
                 std::ranges::any_of(_projected_columns, contains_variant_type);
diff --git a/be/src/format_v2/table/schema_history_util.cpp 
b/be/src/format_v2/table/schema_history_util.cpp
index a3dbad7c1e3..47015ff43cd 100644
--- a/be/src/format_v2/table/schema_history_util.cpp
+++ b/be/src/format_v2/table/schema_history_util.cpp
@@ -21,12 +21,21 @@
 #include <ranges>
 #include <string>
 
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_factory.hpp"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
 #include "core/field.h"
 #include "util/string_util.h"
 
 namespace doris::format {
 namespace {
 
+DataTypePtr nullable_like_original(const DataTypePtr& original, DataTypePtr 
nested) {
+    return original != nullptr && original->is_nullable() ? 
make_nullable(nested) : nested;
+}
+
 const schema::external::TField* find_child_field_by_name(
         const std::vector<schema::external::TFieldPtr>& fields, const 
std::string& name) {
     for (const auto& field_ptr : fields) {
@@ -120,6 +129,69 @@ const schema::external::TSchema* find_history_schema(const 
TFileScanRangeParams*
     return nullptr;
 }
 
+const schema::external::TSchema* find_current_schema(const 
TFileScanRangeParams* params) {
+    if (params == nullptr || !params->__isset.history_schema_info ||
+        params->history_schema_info.empty()) {
+        return nullptr;
+    }
+    if (params->__isset.current_schema_id) {
+        if (const auto* schema = find_history_schema(params, 
params->current_schema_id);
+            schema != nullptr) {
+            return schema;
+        }
+    }
+    return &params->history_schema_info.front();
+}
+
+ColumnDefinition build_schema_identity_from_external_field(const 
schema::external::TField& field) {
+    ColumnDefinition identity;
+    if (field.__isset.id) {
+        identity.identifier = Field::create_field<TYPE_INT>(field.id);
+    }
+    identity.name = field.__isset.name ? field.name : "";
+    identity.name_mapping =
+            field.__isset.name_mapping ? field.name_mapping : 
std::vector<std::string> {};
+    identity.has_name_mapping =
+            field.__isset.name_mapping_is_authoritative && 
field.name_mapping_is_authoritative;
+    identity.timestamp_is_adjusted_to_utc =
+            field.__isset.timestamp_is_adjusted_to_utc
+                    ? std::make_optional(field.timestamp_is_adjusted_to_utc)
+                    : std::nullopt;
+    if (!field.__isset.nestedField) {
+        return identity;
+    }
+    if (field.nestedField.__isset.struct_field && 
field.nestedField.struct_field.__isset.fields) {
+        for (const auto& child_ptr : field.nestedField.struct_field.fields) {
+            if (const auto* child = get_field_ptr(child_ptr); child != 
nullptr) {
+                
identity.children.push_back(build_schema_identity_from_external_field(*child));
+            }
+        }
+    } else if (field.nestedField.__isset.array_field &&
+               field.nestedField.array_field.__isset.item_field) {
+        if (const auto* child = 
get_field_ptr(field.nestedField.array_field.item_field);
+            child != nullptr) {
+            
identity.children.push_back(build_schema_identity_from_external_field(*child));
+            identity.children.back().name = "element";
+        }
+    } else if (field.nestedField.__isset.map_field) {
+        if (field.nestedField.map_field.__isset.key_field) {
+            if (const auto* child = 
get_field_ptr(field.nestedField.map_field.key_field);
+                child != nullptr) {
+                
identity.children.push_back(build_schema_identity_from_external_field(*child));
+                identity.children.back().name = "key";
+            }
+        }
+        if (field.nestedField.map_field.__isset.value_field) {
+            if (const auto* child = 
get_field_ptr(field.nestedField.map_field.value_field);
+                child != nullptr) {
+                
identity.children.push_back(build_schema_identity_from_external_field(*child));
+                identity.children.back().name = "value";
+            }
+        }
+    }
+    return identity;
+}
+
 bool can_map_by_history_schema(const TFileScanRangeParams* params, int64_t 
split_schema_id) {
     if (split_schema_id < 0 || params == nullptr || 
!params->__isset.current_schema_id ||
         !params->__isset.history_schema_info) {
@@ -146,4 +218,49 @@ Status annotate_file_schema_from_history(const 
TFileScanRangeParams* params,
     return Status::OK();
 }
 
+Status apply_file_timestamp_semantics(ColumnDefinition* column) {
+    DORIS_CHECK(column != nullptr);
+    DORIS_CHECK(column->type != nullptr);
+    const auto primitive = remove_nullable(column->type)->get_primitive_type();
+    // Validate before changing children: silently skipping a malformed parent 
leaves stale types.
+    if ((primitive == TYPE_ARRAY && column->children.size() != 1) ||
+        (primitive == TYPE_MAP && column->children.size() != 2)) {
+        return Status::InvalidArgument("Invalid {} column '{}': {} children",
+                                       column->type->get_name(), column->name,
+                                       column->children.size());
+    }
+    if (column->timestamp_is_adjusted_to_utc.has_value() &&
+        (primitive == TYPE_DATETIMEV2 || primitive == TYPE_TIMESTAMPTZ)) {
+        const auto target =
+                *column->timestamp_is_adjusted_to_utc ? TYPE_TIMESTAMPTZ : 
TYPE_DATETIMEV2;
+        column->type = DataTypeFactory::instance().create_data_type(
+                target, column->type->is_nullable(), 0, 
column->type->get_scale());
+        return Status::OK();
+    }
+
+    std::vector<DataTypePtr> child_types;
+    child_types.reserve(column->children.size());
+    for (auto& child : column->children) {
+        RETURN_IF_ERROR(apply_file_timestamp_semantics(&child));
+        child_types.push_back(child.type);
+    }
+    if (primitive == TYPE_ARRAY) {
+        column->type = nullable_like_original(column->type,
+                                              
std::make_shared<DataTypeArray>(child_types.front()));
+    } else if (primitive == TYPE_MAP) {
+        column->type = nullable_like_original(
+                column->type, 
std::make_shared<DataTypeMap>(make_nullable(child_types[0]),
+                                                            
make_nullable(child_types[1])));
+    } else if (primitive == TYPE_STRUCT) {
+        Strings child_names;
+        child_names.reserve(column->children.size());
+        for (const auto& child : column->children) {
+            child_names.push_back(child.name);
+        }
+        column->type = nullable_like_original(
+                column->type, std::make_shared<DataTypeStruct>(child_types, 
child_names));
+    }
+    return Status::OK();
+}
+
 } // namespace doris::format
diff --git a/be/src/format_v2/table/schema_history_util.h 
b/be/src/format_v2/table/schema_history_util.h
index 49704c5fb98..26abe338f4d 100644
--- a/be/src/format_v2/table/schema_history_util.h
+++ b/be/src/format_v2/table/schema_history_util.h
@@ -38,6 +38,14 @@ inline const schema::external::TField* get_field_ptr(const 
schema::external::TFi
 const schema::external::TSchema* find_history_schema(const 
TFileScanRangeParams* params,
                                                      int64_t schema_id);
 
+// The schema named by current_schema_id, or the first one when FE sends no 
id; nullptr when the
+// plan carries no schema dictionary.
+const schema::external::TSchema* find_current_schema(const 
TFileScanRangeParams* params);
+
+// Build the identity of a table field and its nested fields: field id, name, 
name mapping and
+// timestamp marker, but no type. Array items are named `element` and map 
children `key`/`value`.
+ColumnDefinition build_schema_identity_from_external_field(const 
schema::external::TField& field);
+
 bool can_map_by_history_schema(const TFileScanRangeParams* params, int64_t 
split_schema_id);
 
 // Annotate a file-local schema with the field ids and name mappings from the 
historical table
@@ -48,4 +56,14 @@ Status annotate_file_schema_from_history(const 
TFileScanRangeParams* params,
                                          int64_t split_schema_id,
                                          std::vector<ColumnDefinition>* 
file_schema);
 
+// Parquet INT96 carries no instant annotation, so the same physical column 
can hold a wall clock
+// (Paimon TIMESTAMP) or an instant (Paimon TIMESTAMP_LTZ, an Iceberg 
timestamptz written by Spark
+// or Hive). Rewrite each DATETIMEV2/TIMESTAMPTZ leaf that carries the table 
schema's marker
+// (`timestamp_is_adjusted_to_utc`) to TIMESTAMPTZ or DATETIMEV2, then rebuild 
its containers so a
+// parent type agrees with its children. Precision and nullability are 
preserved, and a leaf
+// without a marker keeps its file type. For example, an INT96 leaf read as 
DATETIMEV2(6) and marked
+// as an instant becomes TIMESTAMPTZ(6), and an ARRAY<DATETIMEV2(6)> around it 
becomes
+// ARRAY<TIMESTAMPTZ(6)>.
+Status apply_file_timestamp_semantics(ColumnDefinition* column);
+
 } // namespace doris::format
diff --git a/be/src/format_v2/table_reader.cpp 
b/be/src/format_v2/table_reader.cpp
index 146c7f2d9f2..fdbc5441f7f 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -265,51 +265,6 @@ bool find_column_identity_path_by_id(const 
std::vector<ColumnDefinition>& fields
     return false;
 }
 
-ColumnDefinition build_schema_identity_from_external_field(const 
schema::external::TField& field) {
-    ColumnDefinition identity;
-    if (field.__isset.id) {
-        identity.identifier = Field::create_field<TYPE_INT>(field.id);
-    }
-    identity.name = field.__isset.name ? field.name : "";
-    identity.name_mapping =
-            field.__isset.name_mapping ? field.name_mapping : 
std::vector<std::string> {};
-    identity.has_name_mapping =
-            field.__isset.name_mapping_is_authoritative && 
field.name_mapping_is_authoritative;
-    if (!field.__isset.nestedField) {
-        return identity;
-    }
-    if (field.nestedField.__isset.struct_field && 
field.nestedField.struct_field.__isset.fields) {
-        for (const auto& child_ptr : field.nestedField.struct_field.fields) {
-            if (const auto* child = get_field_ptr(child_ptr); child != 
nullptr) {
-                
identity.children.push_back(build_schema_identity_from_external_field(*child));
-            }
-        }
-    } else if (field.nestedField.__isset.array_field &&
-               field.nestedField.array_field.__isset.item_field) {
-        if (const auto* child = 
get_field_ptr(field.nestedField.array_field.item_field);
-            child != nullptr) {
-            
identity.children.push_back(build_schema_identity_from_external_field(*child));
-            identity.children.back().name = "element";
-        }
-    } else if (field.nestedField.__isset.map_field) {
-        if (field.nestedField.map_field.__isset.key_field) {
-            if (const auto* child = 
get_field_ptr(field.nestedField.map_field.key_field);
-                child != nullptr) {
-                
identity.children.push_back(build_schema_identity_from_external_field(*child));
-                identity.children.back().name = "key";
-            }
-        }
-        if (field.nestedField.map_field.__isset.value_field) {
-            if (const auto* child = 
get_field_ptr(field.nestedField.map_field.value_field);
-                child != nullptr) {
-                
identity.children.push_back(build_schema_identity_from_external_field(*child));
-                identity.children.back().name = "value";
-            }
-        }
-    }
-    return identity;
-}
-
 const ColumnDefinition* find_identity_child(const ColumnDefinition& 
projected_child,
                                             const ColumnDefinition& 
identity_parent) {
     const auto child_it = std::ranges::find_if(
@@ -517,21 +472,8 @@ ColumnDefinition 
build_schema_column_from_external_field(const schema::external:
 
 const schema::external::TField* find_external_root_field(const 
TFileScanRangeParams* params,
                                                          const 
ColumnDefinition& column) {
-    if (params == nullptr || !params->__isset.history_schema_info ||
-        params->history_schema_info.empty()) {
-        return nullptr;
-    }
-    const auto* schema = &params->history_schema_info.front();
-    if (params->__isset.current_schema_id) {
-        for (const auto& candidate_schema : params->history_schema_info) {
-            if (candidate_schema.__isset.schema_id &&
-                candidate_schema.schema_id == params->current_schema_id) {
-                schema = &candidate_schema;
-                break;
-            }
-        }
-    }
-    if (!schema->__isset.root_field || !schema->root_field.__isset.fields) {
+    const auto* schema = find_current_schema(params);
+    if (schema == nullptr || !schema->__isset.root_field || 
!schema->root_field.__isset.fields) {
         return nullptr;
     }
     if (!supports_iceberg_scan_semantics_v1(params)) {
@@ -771,16 +713,7 @@ std::optional<ColumnDefinition> 
TableReader::_find_table_column_by_field_id(
                                          : nullptr;
     };
 
-    const auto* current_schema = &_scan_params->history_schema_info.front();
-    if (_scan_params->__isset.current_schema_id) {
-        for (const auto& candidate_schema : _scan_params->history_schema_info) 
{
-            if (candidate_schema.__isset.schema_id &&
-                candidate_schema.schema_id == _scan_params->current_schema_id) 
{
-                current_schema = &candidate_schema;
-                break;
-            }
-        }
-    }
+    const auto* current_schema = find_current_schema(_scan_params);
     if (const auto* field = find_field(*current_schema); field != nullptr) {
         return build_schema_column_from_external_field(
                 *field, std::move(type), 
supports_iceberg_scan_semantics_v1(_scan_params));
@@ -858,16 +791,7 @@ std::optional<std::vector<ColumnDefinition>> 
TableReader::_find_table_column_pat
         return result;
     };
 
-    const auto* current_schema = &_scan_params->history_schema_info.front();
-    if (_scan_params->__isset.current_schema_id) {
-        for (const auto& candidate_schema : _scan_params->history_schema_info) 
{
-            if (candidate_schema.__isset.schema_id &&
-                candidate_schema.schema_id == _scan_params->current_schema_id) 
{
-                current_schema = &candidate_schema;
-                break;
-            }
-        }
-    }
+    const auto* current_schema = find_current_schema(_scan_params);
     if (auto path = build_path(*current_schema); path.has_value()) {
         return path;
     }
@@ -922,16 +846,7 @@ 
TableReader::_find_table_column_identity_path_by_field_id(int32_t field_id,
         return std::nullopt;
     };
 
-    const auto* current_schema = &_scan_params->history_schema_info.front();
-    if (_scan_params->__isset.current_schema_id) {
-        for (const auto& candidate_schema : _scan_params->history_schema_info) 
{
-            if (candidate_schema.__isset.schema_id &&
-                candidate_schema.schema_id == _scan_params->current_schema_id) 
{
-                current_schema = &candidate_schema;
-                break;
-            }
-        }
-    }
+    const auto* current_schema = find_current_schema(_scan_params);
     if (auto path = find_path(*current_schema); path.has_value()) {
         return path;
     }
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp 
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 8b41271b196..d5dcea52cde 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -259,6 +259,17 @@ public:
     }
 };
 
+class IcebergTableReaderAnnotateTestHelper final
+        : public doris::format::iceberg::IcebergTableReader {
+public:
+    Status annotate(std::vector<ColumnDefinition>* file_schema, 
TFileScanRangeParams* scan_params,
+                    FileFormat format = FileFormat::PARQUET) {
+        _scan_params = scan_params;
+        _format = format;
+        return annotate_file_schema(file_schema);
+    }
+};
+
 std::shared_ptr<arrow::Array> finish_array(arrow::ArrayBuilder* builder) {
     std::shared_ptr<arrow::Array> array;
     EXPECT_TRUE(builder->Finish(&array).ok());
@@ -1025,20 +1036,25 @@ void write_nested_key_value_orc_file(const std::string& 
file_path, const std::ve
 
 // 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.
+// Without `field_ids` the file looks like one Spark wrote and `add_files` 
attached to a table.
 void write_timestamp_int_parquet_file(const std::string& file_path,
                                       const std::vector<int64_t>& timestamps,
-                                      const std::vector<int32_t>& ids, bool 
int96 = false) {
+                                      const std::vector<int32_t>& ids, bool 
int96 = false,
+                                      bool field_ids = true) {
     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"});
     const auto timestamp_type = arrow::timestamp(arrow::TimeUnit::MICRO, 
"UTC");
     arrow::TimestampBuilder timestamp_builder(timestamp_type, 
arrow::default_memory_pool());
     ASSERT_TRUE(timestamp_builder.AppendValues(timestamps).ok());
     auto timestamp_result = timestamp_builder.Finish();
     ASSERT_TRUE(timestamp_result.ok()) << timestamp_result.status();
-    auto schema = arrow::schema(
-            {arrow::field("event_time", timestamp_type, 
false)->WithMetadata(timestamp_metadata),
-             arrow::field("id", arrow::int32(), 
false)->WithMetadata(id_metadata)});
+    auto timestamp_field = arrow::field("event_time", timestamp_type, false);
+    auto id_field = arrow::field("id", arrow::int32(), false);
+    if (field_ids) {
+        timestamp_field = timestamp_field->WithMetadata(
+                arrow::key_value_metadata({"PARQUET:field_id"}, {"0"}));
+        id_field = 
id_field->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"1"}));
+    }
+    auto schema = arrow::schema({timestamp_field, id_field});
     auto table = arrow::Table::Make(schema, {*timestamp_result, 
build_int32_array(ids)});
 
     auto file_result = arrow::io::FileOutputStream::Open(file_path);
@@ -1057,6 +1073,128 @@ void write_timestamp_int_parquet_file(const 
std::string& file_path,
                                                       builder.build(), 
arrow_builder.build()));
 }
 
+// Writes `id` (field id 1), `info` STRUCT<tz (3), wall (4)> (2) and `tzs` 
LIST<element (6)> (5)
+// with every timestamp stored as INT96, as Spark writes nested timestamps.
+void write_nested_int96_parquet_file(const std::string& file_path, int64_t 
timestamp) {
+    const auto field_id = [](int32_t id) {
+        return arrow::key_value_metadata({"PARQUET:field_id"}, 
{std::to_string(id)});
+    };
+    const auto timestamp_type = arrow::timestamp(arrow::TimeUnit::MICRO, 
"UTC");
+    arrow::TimestampBuilder timestamp_builder(timestamp_type, 
arrow::default_memory_pool());
+    ASSERT_TRUE(timestamp_builder.Append(timestamp).ok());
+    auto timestamps = timestamp_builder.Finish();
+    ASSERT_TRUE(timestamps.ok()) << timestamps.status();
+    auto tz_field = arrow::field("tz", 
timestamp_type)->WithMetadata(field_id(3));
+    auto wall_field = arrow::field("wall", 
timestamp_type)->WithMetadata(field_id(4));
+    auto info = arrow::StructArray::Make({*timestamps, *timestamps}, 
{tz_field, wall_field});
+    ASSERT_TRUE(info.ok()) << info.status();
+    auto tzs_type = arrow::list(arrow::field("element", 
timestamp_type)->WithMetadata(field_id(6)));
+    auto tzs = arrow::ListArray::FromArrays(tzs_type, *build_int32_array({0, 
1}), **timestamps);
+    ASSERT_TRUE(tzs.ok()) << tzs.status();
+    auto schema = arrow::schema({
+            arrow::field("id", arrow::int32(), 
false)->WithMetadata(field_id(1)),
+            arrow::field("info", (*info)->type())->WithMetadata(field_id(2)),
+            arrow::field("tzs", tzs_type)->WithMetadata(field_id(5)),
+    });
+    auto table = arrow::Table::Make(schema, {build_int32_array({1}), *info, 
*tzs});
+
+    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::ArrowWriterProperties::Builder arrow_builder;
+    arrow_builder.enable_force_write_int96_timestamps();
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(
+            *table, arrow::default_memory_pool(), out, 1,
+            ::parquet::WriterProperties::Builder().build(), 
arrow_builder.build()));
+}
+
+// Writes `id` (field id 1) and `outer` STRUCT (10) holding an ID-less `inner` 
STRUCT whose `ts` (30)
+// is an INT96 timestamp: the ID-less wrapper Iceberg's Parquet projection 
keeps for a selected
+// nested ID, holding a timestamp the way Spark writes it.
+void write_idless_wrapper_int96_parquet_file(const std::string& file_path,
+                                             const std::vector<int64_t>& 
timestamps,
+                                             const std::vector<int32_t>& ids) {
+    ASSERT_EQ(timestamps.size(), ids.size());
+    const auto field_id = [](int32_t id) {
+        return arrow::key_value_metadata({"PARQUET:field_id"}, 
{std::to_string(id)});
+    };
+    const auto timestamp_type = arrow::timestamp(arrow::TimeUnit::MICRO, 
"UTC");
+    arrow::TimestampBuilder timestamp_builder(timestamp_type, 
arrow::default_memory_pool());
+    ASSERT_TRUE(timestamp_builder.AppendValues(timestamps).ok());
+    auto timestamp_result = timestamp_builder.Finish();
+    ASSERT_TRUE(timestamp_result.ok()) << timestamp_result.status();
+    auto ts_field = arrow::field("ts", timestamp_type, 
false)->WithMetadata(field_id(30));
+    auto inner_result = arrow::StructArray::Make({*timestamp_result}, 
{ts_field});
+    ASSERT_TRUE(inner_result.ok()) << inner_result.status();
+    auto inner_field = arrow::field("inner", arrow::struct_({ts_field}), 
false);
+    auto outer_result = arrow::StructArray::Make({*inner_result}, 
{inner_field});
+    ASSERT_TRUE(outer_result.ok()) << outer_result.status();
+    auto schema = arrow::schema({
+            arrow::field("id", arrow::int32(), 
false)->WithMetadata(field_id(1)),
+            arrow::field("outer", arrow::struct_({inner_field}), 
false)->WithMetadata(field_id(10)),
+    });
+    auto table = arrow::Table::Make(schema, {build_int32_array(ids), 
*outer_result});
+
+    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::ArrowWriterProperties::Builder arrow_builder;
+    arrow_builder.enable_force_write_int96_timestamps();
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(
+            *table, arrow::default_memory_pool(), out, 
static_cast<int64_t>(ids.size()),
+            ::parquet::WriterProperties::Builder().build(), 
arrow_builder.build()));
+}
+
+// Writes `id` (field id 0) and a nullable `payload` STRUCT (1) of `existing` 
(2) and `ts` (4), with
+// `ts` stored as INT96 the way Spark writes a timestamptz. A true 
`parent_nulls` entry makes that
+// row's `payload` NULL.
+void write_nested_equality_int96_sibling_parquet_file(const std::string& 
file_path,
+                                                      const 
std::vector<int32_t>& ids,
+                                                      const 
std::vector<int32_t>& values,
+                                                      const 
std::vector<int64_t>& timestamps,
+                                                      const std::vector<bool>& 
parent_nulls) {
+    ASSERT_EQ(ids.size(), values.size());
+    ASSERT_EQ(ids.size(), timestamps.size());
+    ASSERT_EQ(ids.size(), parent_nulls.size());
+    const auto field_id = [](int32_t id) {
+        return arrow::key_value_metadata({"PARQUET:field_id"}, 
{std::to_string(id)});
+    };
+    const auto timestamp_type = arrow::timestamp(arrow::TimeUnit::MICRO, 
"UTC");
+    const auto payload_type = arrow::struct_(
+            {arrow::field("existing", arrow::int32(), 
false)->WithMetadata(field_id(2)),
+             arrow::field("ts", timestamp_type, 
false)->WithMetadata(field_id(4))});
+    arrow::StructBuilder payload_builder(payload_type, 
arrow::default_memory_pool(),
+                                         
{std::make_shared<arrow::Int32Builder>(),
+                                          
std::make_shared<arrow::TimestampBuilder>(
+                                                  timestamp_type, 
arrow::default_memory_pool())});
+    auto* existing_builder = 
assert_cast<arrow::Int32Builder*>(payload_builder.field_builder(0));
+    auto* ts_builder = 
assert_cast<arrow::TimestampBuilder*>(payload_builder.field_builder(1));
+    for (size_t row = 0; row < ids.size(); ++row) {
+        if (parent_nulls[row]) {
+            ASSERT_TRUE(payload_builder.AppendNull().ok());
+        } else {
+            ASSERT_TRUE(payload_builder.Append().ok());
+            ASSERT_TRUE(existing_builder->Append(values[row]).ok());
+            ASSERT_TRUE(ts_builder->Append(timestamps[row]).ok());
+        }
+    }
+    auto schema = arrow::schema({
+            arrow::field("id", arrow::int32(), 
false)->WithMetadata(field_id(0)),
+            arrow::field("payload", payload_type)->WithMetadata(field_id(1)),
+    });
+    auto table =
+            arrow::Table::Make(schema, {build_int32_array(ids), 
finish_array(&payload_builder)});
+
+    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::ArrowWriterProperties::Builder arrow_builder;
+    arrow_builder.enable_force_write_int96_timestamps();
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(
+            *table, arrow::default_memory_pool(), out, 
static_cast<int64_t>(ids.size()),
+            ::parquet::WriterProperties::Builder().build(), 
arrow_builder.build()));
+}
+
 void write_iceberg_equality_delete_bigint_parquet_file(const std::string& 
file_path,
                                                        int32_t field_id, 
int64_t value) {
     const auto metadata =
@@ -4645,6 +4783,156 @@ TEST(IcebergV2ReaderTest, 
IcebergMissingNestedEqualityKeyPreservesNullableParent
     }
 }
 
+// An older Parquet file holds a nullable payload {existing, ts}, `ts` an 
INT96 timestamptz as
+// Spark writes it, and the table later added payload.k with the default 7. An 
equality delete on k
+// reads the whole `payload` for its NULLs: rows under a non-NULL payload take 
the key 7, the row
+// under the NULL one keeps a NULL key. FE's marker turns `ts` into 
TIMESTAMPTZ in the annotated
+// file schema, so that ancestor read must carry the marker too, or Parquet 
still decodes `ts` as
+// DATETIMEV2 and the scan fails the reader's type check. A query projecting 
`payload` failed the
+// same way: reading the root as a predicate column replaced the projection 
its mapping had marked.
+// A plan without the marker keeps the file types and reads as before.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity): marker, 
projection, tombstone cases.
+TEST(IcebergV2ReaderTest, 
IcebergMissingNestedEqualityKeyAncestorKeepsInt96TimestampTzMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          "doris_v2_missing_nested_equality_key_int96_sibling";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    // 2024-01-01 00:00:00.123456 UTC, then one and two seconds later.
+    constexpr int64_t T1_MICROS = 1'704'067'200'123'456L;
+    write_nested_equality_int96_sibling_parquet_file(
+            file_path, {1, 2, 3}, {10, 20, 30},
+            {T1_MICROS, T1_MICROS + 1'000'000L, T1_MICROS + 2'000'000L}, 
{false, true, false});
+    auto physical_reader = ::parquet::ParquetFileReader::OpenFile(file_path, 
false);
+    ASSERT_EQ(::parquet::Type::INT96,
+              
physical_reader->metadata()->schema()->Column(2)->physical_type());
+    physical_reader->Close();
+    // The two delete files have the same size and may share an mtime second, 
so each tombstone
+    // gets its own path: one rewritten path could serve the first one from 
the Parquet caches.
+    const auto delete_file_path = [&test_dir](int32_t tombstone) {
+        return (test_dir / ("equality-delete-" + std::to_string(tombstone) + 
".parquet")).string();
+    };
+    for (const int32_t tombstone : {7, 8}) {
+        write_nested_equality_parquet_file(delete_file_path(tombstone), {}, 
{tombstone}, {false},
+                                           true, "k", 3);
+    }
+
+    const auto int_type = std::make_shared<DataTypeInt32>();
+    const auto tz_type = std::make_shared<DataTypeTimeStampTz>(6);
+    const auto nullable_tz_type = make_nullable(tz_type);
+    auto payload = make_table_column(1, "payload",
+                                     
std::make_shared<DataTypeStruct>(DataTypes {int_type, tz_type},
+                                                                      Strings 
{"existing", "ts"}));
+    payload.children = {make_table_column(2, "existing", int_type),
+                        make_table_column(4, "ts", tz_type)};
+
+    struct Case {
+        bool marked;
+        bool project_payload;
+        int32_t tombstone;
+        std::vector<int32_t> ids;
+        // `ts` of each surviving row when `payload` is projected; nullopt 
under a NULL payload.
+        std::vector<std::optional<std::string>> timestamps;
+    };
+    const std::vector<Case> cases = {
+            // A plan from an FE that sends no marker.
+            {false, false, 7, {2}, {}},
+            // The key is the default 7 under a non-NULL payload and NULL 
under the NULL one.
+            {true, false, 7, {2}, {}},
+            {true, true, 7, {2}, {std::nullopt}},
+            // Instants, not session wall clocks read eight hours early in 
Asia/Shanghai.
+            {true,
+             true,
+             8,
+             {1, 2, 3},
+             {"2024-01-01 00:00:00.123456+00:00", std::nullopt,
+              "2024-01-01 00:00:02.123456+00:00"}},
+    };
+    for (const auto& c : cases) {
+        SCOPED_TRACE("marked=" + std::to_string(c.marked) +
+                     " project_payload=" + std::to_string(c.project_payload) +
+                     " tombstone=" + std::to_string(c.tombstone));
+        std::vector<ColumnDefinition> projected_columns {make_table_column(0, 
"id", int_type)};
+        if (c.project_payload) {
+            projected_columns.push_back(payload);
+        }
+        auto ts_field =
+                external_schema_field("ts", 4, {}, std::nullopt,
+                                      
external_primitive_type(TPrimitiveType::TIMESTAMPTZ, -1, 6));
+        if (c.marked) {
+            ts_field.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+        }
+        auto payload_field = external_struct_schema_field(
+                "payload", 1,
+                {external_schema_field("existing", 2, {}, std::nullopt,
+                                       
external_primitive_type(TPrimitiveType::INT)),
+                 std::move(ts_field),
+                 external_schema_field("k", 3, {}, "7",
+                                       
external_primitive_type(TPrimitiveType::INT), false, true)},
+                true);
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        scan_params.__set_parquet_timestamp_semantics_version(1);
+        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(payload_field)})});
+
+        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(c.tombstone), {3})},
+                3));
+        ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+        std::vector<int32_t> ids;
+        std::vector<std::optional<std::string>> timestamps;
+        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 (!c.project_payload) {
+                continue;
+            }
+            const auto& payload_column =
+                    assert_cast<const 
ColumnNullable&>(*block.get_by_position(1).column);
+            const auto& payload_struct =
+                    assert_cast<const 
ColumnStruct&>(payload_column.get_nested_column());
+            for (size_t row = 0; row < block.rows(); ++row) {
+                if (payload_column.is_null_at(row)) {
+                    timestamps.emplace_back(std::nullopt);
+                } else {
+                    timestamps.emplace_back(
+                            
nullable_tz_type->to_string(payload_struct.get_column(1), row));
+                }
+            }
+        }
+        EXPECT_EQ(ids, c.ids);
+        EXPECT_EQ(timestamps, c.timestamps);
+        ASSERT_TRUE(reader.close().ok());
+    }
+    std::filesystem::remove_all(test_dir);
+}
+
 // Keep the shared Parquet/ORC reader setup together so both V2 paths 
materialize missing struct
 // literals before traversing their children.
 // 
NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size)
@@ -6391,6 +6679,571 @@ TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteComparesInt96DataKeyAsTimestampTz
     std::filesystem::remove_all(test_dir);
 }
 
+// FE marks every Iceberg timestamptz field with timestamp_is_adjusted_to_utc. 
The marker lands on
+// the file leaf the mapper selects for the field, by field id at every 
nesting level: a DATETIMEV2
+// leaf becomes TIMESTAMPTZ with its precision and nullability, its containers 
are rebuilt around
+// it, and an unmarked timestamp field stays a wall clock. A TIMESTAMP_NS leaf 
(INT64 NANOS without
+// the UTC annotation) keeps its type and takes no marker; the Parquet reader 
rewrites every marked
+// timestamp leaf, so a marker there would make it decode TIMESTAMPTZ under a 
TIMESTAMP_NS file
+// schema. ORC files keep their types, since ORC's file type already separates 
the two semantics.
+TEST(IcebergV2ReaderTest, 
IcebergAnnotatesTimestampTzMarkerOnMatchedParquetLeaves) {
+    const auto marked = [](schema::external::TFieldPtr field) {
+        field.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+        return field;
+    };
+    auto info = external_struct_schema_field(
+            "info", 4, {marked(external_schema_field("tz", 5)), 
external_schema_field("wall", 6)});
+    auto tzs = external_schema_field("tzs", 7);
+    schema::external::TArrayField array_field;
+    array_field.__set_item_field(marked(external_schema_field("element", 8)));
+    tzs.field_ptr->nestedField.__set_array_field(std::move(array_field));
+    tzs.field_ptr->__isset.nestedField = true;
+    auto by_tz = external_schema_field("by_tz", 9);
+    schema::external::TMapField map_field;
+    map_field.__set_key_field(marked(external_schema_field("key", 10)));
+    map_field.__set_value_field(external_schema_field("value", 11));
+    by_tz.field_ptr->nestedField.__set_map_field(std::move(map_field));
+    by_tz.field_ptr->__isset.nestedField = true;
+
+    TFileScanRangeParams scan_params;
+    
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, {marked(external_schema_field("tz", 1)), 
external_schema_field("wall", 2),
+                  marked(external_schema_field("ns", 3)), info, tzs, by_tz})});
+
+    const auto datetime = std::make_shared<DataTypeDateTimeV2>(6);
+    const auto build_file_schema = [&]() {
+        auto info_column =
+                make_file_column(4, "info",
+                                 std::make_shared<DataTypeStruct>(DataTypes 
{datetime, datetime},
+                                                                  Strings 
{"tz", "wall"}));
+        info_column.children = {make_file_column(5, "tz", datetime),
+                                make_file_column(6, "wall", datetime)};
+        auto tzs_column = make_file_column(7, "tzs", 
std::make_shared<DataTypeArray>(datetime));
+        tzs_column.children = {make_file_column(8, "element", datetime)};
+        auto by_tz_column =
+                make_file_column(9, "by_tz", 
std::make_shared<DataTypeMap>(datetime, datetime));
+        by_tz_column.children = {make_file_column(10, "key", datetime),
+                                 make_file_column(11, "value", datetime)};
+        return std::vector<ColumnDefinition> {
+                make_file_column(1, "tz", datetime),
+                make_file_column(2, "wall", datetime),
+                make_file_column(3, "ns", 
std::make_shared<DataTypeTimeStampNs>()),
+                std::move(info_column),
+                std::move(tzs_column),
+                std::move(by_tz_column)};
+    };
+    const auto primitive = [](const DataTypePtr& type) {
+        return remove_nullable(type)->get_primitive_type();
+    };
+
+    auto file_schema = build_file_schema();
+    IcebergTableReaderAnnotateTestHelper reader;
+    ASSERT_TRUE(reader.annotate(&file_schema, &scan_params).ok());
+    ASSERT_EQ(file_schema[0].timestamp_is_adjusted_to_utc, 
std::optional<bool>(true));
+    EXPECT_EQ(primitive(file_schema[0].type), TYPE_TIMESTAMPTZ);
+    EXPECT_EQ(file_schema[0].type->get_scale(), 6);
+    EXPECT_TRUE(file_schema[0].type->is_nullable());
+    EXPECT_FALSE(file_schema[1].timestamp_is_adjusted_to_utc.has_value());
+    EXPECT_EQ(primitive(file_schema[1].type), TYPE_DATETIMEV2);
+    EXPECT_FALSE(file_schema[2].timestamp_is_adjusted_to_utc.has_value());
+    EXPECT_EQ(primitive(file_schema[2].type), TYPE_TIMESTAMP_NS);
+
+    const auto& struct_type =
+            assert_cast<const 
DataTypeStruct&>(*remove_nullable(file_schema[3].type));
+    EXPECT_EQ(primitive(struct_type.get_element(0)), TYPE_TIMESTAMPTZ);
+    EXPECT_EQ(primitive(struct_type.get_element(1)), TYPE_DATETIMEV2);
+    EXPECT_EQ(file_schema[3].children[0].timestamp_is_adjusted_to_utc, 
std::optional<bool>(true));
+    
EXPECT_FALSE(file_schema[3].children[1].timestamp_is_adjusted_to_utc.has_value());
+
+    const auto& array_type =
+            assert_cast<const 
DataTypeArray&>(*remove_nullable(file_schema[4].type));
+    EXPECT_EQ(primitive(array_type.get_nested_type()), TYPE_TIMESTAMPTZ);
+    EXPECT_EQ(file_schema[4].children[0].timestamp_is_adjusted_to_utc, 
std::optional<bool>(true));
+
+    const auto& map_type = assert_cast<const 
DataTypeMap&>(*remove_nullable(file_schema[5].type));
+    EXPECT_EQ(primitive(map_type.get_key_type()), TYPE_TIMESTAMPTZ);
+    EXPECT_EQ(primitive(map_type.get_value_type()), TYPE_DATETIMEV2);
+    EXPECT_EQ(file_schema[5].children[0].timestamp_is_adjusted_to_utc, 
std::optional<bool>(true));
+    
EXPECT_FALSE(file_schema[5].children[1].timestamp_is_adjusted_to_utc.has_value());
+
+    auto orc_schema = build_file_schema();
+    ASSERT_TRUE(reader.annotate(&orc_schema, &scan_params, 
FileFormat::ORC).ok());
+    EXPECT_FALSE(orc_schema[0].timestamp_is_adjusted_to_utc.has_value());
+    EXPECT_EQ(primitive(orc_schema[0].type), TYPE_DATETIMEV2);
+    EXPECT_EQ(primitive(assert_cast<const 
DataTypeArray&>(*remove_nullable(orc_schema[4].type))
+                                .get_nested_type()),
+              TYPE_DATETIMEV2);
+}
+
+// Spark and Hive store an Iceberg timestamptz as INT96, which carries no 
instant annotation, and FE
+// always sends Parquet timestamp-semantics version 1, under which INT96 
decodes as a UTC wall
+// clock. Without FE's timestamptz marker the projection then casts that wall 
clock to TIMESTAMPTZ
+// in the session time zone, so in Asia/Shanghai 2024-01-01T00:00:00.123456Z 
reads as
+// 2023-12-31 16:00:00.123456 UTC, eight hours early. With the marker the 
column reads as the
+// instant whether the file has field ids, relies on the table's name mapping 
(an `add_files`
+// import), or falls back to positional ids, and whatever the catalog's 
timestamp mapping flag is.
+// A plan from an older FE carries no marker and keeps the previous result.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity): one file per 
match rule, two plans.
+TEST(IcebergV2ReaderTest, IcebergInt96TimestampTzReadsInstantWithTableMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_int96_timestamp_tz_marker_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    // 2024-01-01 00:00:00.123456 UTC and 2024-07-01 12:34:56.654321 UTC.
+    constexpr int64_t T1_MICROS = 1'704'067'200'123'456L;
+    constexpr int64_t T2_MICROS = 1'719'837'296'654'321L;
+
+    enum class Match { FIELD_ID, NAME_MAPPING, POSITION };
+    for (const auto match : {Match::FIELD_ID, Match::NAME_MAPPING, 
Match::POSITION}) {
+        const auto file_path =
+                (test_dir / ("split_" + 
std::to_string(static_cast<int>(match)) + ".parquet"))
+                        .string();
+        write_timestamp_int_parquet_file(file_path, {T1_MICROS, T2_MICROS}, 
{1, 2}, true,
+                                         match == Match::FIELD_ID);
+        // The positional fallback numbers the physical top-level columns from 
1.
+        const int32_t event_time_id = match == Match::POSITION ? 1 : 0;
+        const int32_t id_id = match == Match::POSITION ? 2 : 1;
+        for (const bool marked : {true, false}) {
+            for (const bool mapping_timestamp_tz : {true, false}) {
+                SCOPED_TRACE("match=" + 
std::to_string(static_cast<int>(match)) +
+                             " marked=" + std::to_string(marked) +
+                             " mapping_timestamp_tz=" + 
std::to_string(mapping_timestamp_tz));
+                auto event_time = external_schema_field(
+                        "event_time", event_time_id, {}, std::nullopt,
+                        external_primitive_type(TPrimitiveType::TIMESTAMPTZ, 
-1, 6));
+                auto id = external_schema_field("id", id_id);
+                if (marked) {
+                    
event_time.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+                }
+                std::vector<ColumnDefinition> projected_columns {
+                        make_table_column(id_id, "id", 
std::make_shared<DataTypeInt32>()),
+                        make_table_column(event_time_id, "event_time",
+                                          
std::make_shared<DataTypeTimeStampTz>(6))};
+                if (match == Match::NAME_MAPPING) {
+                    for (auto* field : {&event_time, &id}) {
+                        
field->field_ptr->__set_name_mapping({field->field_ptr->name});
+                        
field->field_ptr->__set_name_mapping_is_authoritative(true);
+                    }
+                    for (auto& column : projected_columns) {
+                        column.name_mapping = {column.name};
+                        column.has_name_mapping = true;
+                    }
+                }
+                auto scan_params = make_local_parquet_scan_params();
+                
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+                scan_params.__set_parquet_timestamp_semantics_version(1);
+                
scan_params.__set_enable_mapping_timestamp_tz(mapping_timestamp_tz);
+                scan_params.__set_current_schema_id(100);
+                scan_params.__set_history_schema_info({external_schema(100, 
{event_time, id})});
+
+                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, {}));
+                ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+                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;
+                    for (size_t row = 0; row < block.rows(); ++row) {
+                        
event_times.push_back(projected_columns[1].type->to_string(
+                                *block.get_by_position(1).column, row));
+                    }
+                }
+                EXPECT_EQ(event_times,
+                          marked ? std::vector<std::string>({"2024-01-01 
00:00:00.123456+00:00",
+                                                             "2024-07-01 
12:34:56.654321+00:00"})
+                                 : std::vector<std::string>({"2023-12-31 
16:00:00.123456+00:00",
+                                                             "2024-07-01 
04:34:56.654321+00:00"}));
+                ASSERT_TRUE(reader.close().ok());
+            }
+        }
+    }
+    std::filesystem::remove_all(test_dir);
+}
+
+// The marker also reaches INT96 leaves inside a STRUCT and a LIST, while a 
timestamp sibling in the
+// same struct keeps reading as the UTC wall clock Iceberg gives a timestamp 
field.
+TEST(IcebergV2ReaderTest, 
IcebergNestedInt96TimestampTzReadsInstantWithTableMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          
"doris_iceberg_nested_int96_timestamp_tz_marker_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    // 2024-01-01 00:00:00.123456 UTC.
+    write_nested_int96_parquet_file(file_path, 1'704'067'200'123'456L);
+    auto physical_reader = ::parquet::ParquetFileReader::OpenFile(file_path, 
false);
+    for (int leaf = 1; leaf < 4; ++leaf) {
+        ASSERT_EQ(::parquet::Type::INT96,
+                  
physical_reader->metadata()->schema()->Column(leaf)->physical_type());
+    }
+    physical_reader->Close();
+
+    const auto tz_type = std::make_shared<DataTypeTimeStampTz>(6);
+    const auto wall_type = std::make_shared<DataTypeDateTimeV2>(6);
+    auto info = make_table_column(2, "info",
+                                  std::make_shared<DataTypeStruct>(DataTypes 
{tz_type, wall_type},
+                                                                   Strings 
{"tz", "wall"}));
+    info.children = {make_table_column(3, "tz", tz_type), make_table_column(4, 
"wall", wall_type)};
+    auto tzs = make_table_column(5, "tzs", 
std::make_shared<DataTypeArray>(tz_type));
+    tzs.children = {make_table_column(6, "element", tz_type)};
+    std::vector<ColumnDefinition> projected_columns {
+            make_table_column(1, "id", std::make_shared<DataTypeInt32>()), 
info, tzs};
+
+    auto tz_field = external_schema_field("tz", 3);
+    tz_field.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+    auto element_field = external_schema_field("element", 6);
+    element_field.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+    auto tzs_field = external_schema_field("tzs", 5);
+    schema::external::TArrayField array_field;
+    array_field.__set_item_field(std::move(element_field));
+    tzs_field.field_ptr->nestedField.__set_array_field(std::move(array_field));
+    tzs_field.field_ptr->__isset.nestedField = true;
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    scan_params.__set_parquet_timestamp_semantics_version(1);
+    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", 1),
+                  external_struct_schema_field(
+                          "info", 2, {std::move(tz_field), 
external_schema_field("wall", 4)}),
+                  std::move(tzs_field)})});
+
+    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, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    // Table children are nullable, as in an external scan descriptor.
+    const auto nullable_tz_type = make_nullable(tz_type);
+    const auto nullable_wall_type = make_nullable(wall_type);
+    std::vector<std::string> values;
+    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;
+        }
+        ASSERT_EQ(block.rows(), 1);
+        const auto& info_column =
+                assert_cast<const 
ColumnStruct&>(expect_not_null_table_column(block, 1));
+        
values.push_back(nullable_tz_type->to_string(info_column.get_column(0), 0));
+        
values.push_back(nullable_wall_type->to_string(info_column.get_column(1), 0));
+        const auto& tzs_column =
+                assert_cast<const 
ColumnArray&>(expect_not_null_table_column(block, 2));
+        ASSERT_EQ(tzs_column.get_offsets()[0], 1);
+        values.push_back(nullable_tz_type->to_string(tzs_column.get_data(), 
0));
+    }
+    EXPECT_EQ(values, std::vector<std::string>({"2024-01-01 
00:00:00.123456+00:00",
+                                                "2024-01-01 00:00:00.123456",
+                                                "2024-01-01 
00:00:00.123456+00:00"}));
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// TableColumnMapper resolves a nested field ID through an ID-less STRUCT 
wrapper below the root as
+// well (ParquetRecursivelyRetainsIdlessWrapperForSelectedLeafId), so the 
table's timestamptz marker
+// has to reach the leaf the same way. Otherwise the INT96 leaf decodes as a 
UTC wall clock that the
+// projection reads in the session time zone, eight hours early in 
Asia/Shanghai, and an equality
+// key through the wrapper compares that wall clock with the adjusted-UTC 
tombstone an Iceberg
+// writer stores, so the tombstone misses its row.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity): read and 
equality-delete matrix.
+TEST(IcebergV2ReaderTest, 
IcebergInt96TimestampTzUnderIdlessWrapperReadsInstantWithTableMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          
"doris_iceberg_idless_wrapper_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_idless_wrapper_int96_parquet_file(file_path, {T1_MICROS, T2_MICROS}, 
{1, 2});
+    write_iceberg_timestamp_equality_delete_parquet_file(delete_file_path, 30, 
T1_MICROS, "ts",
+                                                         true);
+
+    const auto tz_type = std::make_shared<DataTypeTimeStampTz>(6);
+    const auto nullable_tz_type = make_nullable(tz_type);
+    auto inner_type = std::make_shared<DataTypeStruct>(DataTypes {tz_type}, 
Strings {"ts"});
+    auto inner = make_table_column(20, "inner", inner_type);
+    inner.children = {make_table_column(30, "ts", tz_type)};
+    auto outer = make_table_column(
+            10, "outer",
+            std::make_shared<DataTypeStruct>(DataTypes {inner_type}, Strings 
{"inner"}));
+    outer.children = {inner};
+
+    struct Case {
+        bool with_delete;
+        bool project_outer;
+        std::vector<int32_t> ids;
+        std::vector<std::string> timestamps;
+    };
+    const std::vector<Case> cases = {
+            {false,
+             true,
+             {1, 2},
+             {"2024-01-01 00:00:00.123456+00:00", "2024-01-01 
00:00:01.123456+00:00"}},
+            {true, true, {2}, {"2024-01-01 00:00:01.123456+00:00"}},
+            {true, false, {2}, {}},
+    };
+    for (const auto& c : cases) {
+        SCOPED_TRACE("with_delete=" + std::to_string(c.with_delete) +
+                     " project_outer=" + std::to_string(c.project_outer));
+        std::vector<ColumnDefinition> projected_columns {
+                make_table_column(1, "id", std::make_shared<DataTypeInt32>())};
+        if (c.project_outer) {
+            projected_columns.push_back(outer);
+        }
+        auto ts_field =
+                external_schema_field("ts", 30, {}, std::nullopt,
+                                      
external_primitive_type(TPrimitiveType::TIMESTAMPTZ, -1, 6));
+        ts_field.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+        auto inner_field = external_struct_schema_field("inner", 20, 
{std::move(ts_field)});
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        scan_params.__set_parquet_timestamp_semantics_version(1);
+        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", 1),
+                      external_struct_schema_field("outer", 10, 
{std::move(inner_field)})})});
+
+        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);
+        std::vector<TIcebergDeleteFileDesc> delete_files;
+        if (c.with_delete) {
+            
delete_files.push_back(make_iceberg_equality_delete_file(delete_file_path, 
{30}));
+        }
+        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, delete_files));
+        ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+        std::vector<int32_t> ids;
+        std::vector<std::string> timestamps;
+        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 (!c.project_outer) {
+                continue;
+            }
+            const auto& outer_column =
+                    assert_cast<const 
ColumnStruct&>(expect_not_null_table_column(block, 1));
+            const auto& inner_column = assert_cast<const ColumnStruct&>(
+                    
expect_not_null_nullable_nested_column(outer_column.get_column(0)));
+            for (size_t row = 0; row < block.rows(); ++row) {
+                
timestamps.push_back(nullable_tz_type->to_string(inner_column.get_column(0), 
row));
+            }
+        }
+        EXPECT_EQ(ids, c.ids);
+        EXPECT_EQ(timestamps, c.timestamps);
+        ASSERT_TRUE(reader.close().ok());
+    }
+    std::filesystem::remove_all(test_dir);
+}
+
+// A Spark INT96 key and the adjusted-UTC tombstone an Iceberg writer stores 
for the same
+// timestamptz instant decode to the same TIMESTAMPTZ once FE marks the field, 
in both the data file
+// and the delete file. Under timestamp-semantics version 1 the data key used 
to read as a UTC wall
+// clock and was converted back in the session time zone, so in Asia/Shanghai 
the tombstone T1 missed
+// the row holding T1 by eight hours; with enable_mapping_timestamp_tz=false 
the tombstone also read
+// as a Shanghai wall clock, and the two wall clocks never matched.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity): projection and 
mapping-flag matrix.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteMatchesInt96TimestampTzKeyWithTableMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          "doris_iceberg_equality_delete_int96_marker_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);
+    write_iceberg_timestamp_equality_delete_parquet_file(delete_file_path, 0, 
T1_MICROS,
+                                                         "event_time", true);
+
+    for (const bool project_key : {false, true}) {
+        for (const bool mapping_timestamp_tz : {true, false}) {
+            SCOPED_TRACE("project_key=" + std::to_string(project_key) +
+                         " mapping_timestamp_tz=" + 
std::to_string(mapping_timestamp_tz));
+            std::vector<ColumnDefinition> projected_columns {
+                    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 event_time = external_schema_field(
+                    "event_time", 0, {}, std::nullopt,
+                    external_primitive_type(TPrimitiveType::TIMESTAMPTZ, -1, 
6));
+            event_time.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+            auto scan_params = make_local_parquet_scan_params();
+            
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+            scan_params.__set_parquet_timestamp_semantics_version(1);
+            
scan_params.__set_enable_mapping_timestamp_tz(mapping_timestamp_tz);
+            scan_params.__set_current_schema_id(100);
+            scan_params.__set_history_schema_info(
+                    {external_schema(100, {event_time, 
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) {
+                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);
+}
+
+// A catalog with enable_mapping_timestamp_tz=false used to read both sides of 
a timestamptz equality
+// delete as session-local wall clocks. During the 2024 New York fall-back 
01:30 happens twice, so
+// either tombstone deleted both rows. Once FE marks the field, data keys and 
tombstones both decode
+// as instants and each tombstone deletes only its own row. Decoding only the 
data keys as instants
+// is not enough: the tombstone's wall clock maps back to one of the two 
instants, so one of these
+// two cases would delete the wrong row.
+TEST(IcebergV2ReaderTest, 
IcebergEqualityDeleteKeepsDstFoldInstantsApartWithTableMarker) {
+    TimezoneUtils::load_timezones_to_cache();
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          "doris_iceberg_equality_delete_dst_fold_marker_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    // These instants are the two occurrences of 01:30 during the 2024 New 
York DST fall-back.
+    constexpr int64_t FIRST_0130_MICROS = 1'730'611'800'000'000L;
+    constexpr int64_t SECOND_0130_MICROS = 1'730'615'400'000'000L;
+    write_timestamp_int_parquet_file(file_path, {FIRST_0130_MICROS, 
SECOND_0130_MICROS}, {1, 2});
+
+    for (const auto& [tombstone, remaining_id] :
+         {std::pair {FIRST_0130_MICROS, 2}, std::pair {SECOND_0130_MICROS, 
1}}) {
+        SCOPED_TRACE(tombstone);
+        // Same size and second-granularity mtime: one path for both files 
could serve the first
+        // tombstone from the Parquet caches.
+        const auto delete_file_path =
+                (test_dir / ("equality-delete-" + std::to_string(remaining_id) 
+ ".parquet"))
+                        .string();
+        write_iceberg_timestamp_equality_delete_parquet_file(delete_file_path, 
0, tombstone,
+                                                             "event_time", 
true);
+        std::vector<ColumnDefinition> projected_columns {
+                make_table_column(1, "id", std::make_shared<DataTypeInt32>())};
+        auto event_time =
+                external_schema_field("event_time", 0, {}, std::nullopt,
+                                      
external_primitive_type(TPrimitiveType::TIMESTAMPTZ, -1, 6));
+        event_time.field_ptr->__set_timestamp_is_adjusted_to_utc(true);
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        scan_params.__set_parquet_timestamp_semantics_version(1);
+        scan_params.__set_enable_mapping_timestamp_tz(false);
+        scan_params.__set_current_schema_id(100);
+        scan_params.__set_history_schema_info(
+                {external_schema(100, {event_time, external_schema_field("id", 
1)})});
+
+        RuntimeProfile profile("test_profile");
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        state.set_timezone("America/New_York");
+        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());
+        EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
+                  std::vector<int32_t>({remaining_id}));
+        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";
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
index 1be990d5554..02e0604c8ac 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergSchemaUtils.java
@@ -81,7 +81,9 @@ import java.util.Optional;
  * consumes — {@code id} / {@code name} / the mapped Doris column type / 
{@code name_mapping} — and, faithful
  * to legacy {@code ExternalUtil}, an {@code id}/{@code name} at EVERY nesting 
level (array element, map
  * key/value, struct child), unlike paimon which omits them on collection 
elements. The scalar type is also
- * required when BE parses an Iceberg initial default or resolves a promoted 
equality-delete field.</p>
+ * required when BE parses an Iceberg initial default or resolves a promoted 
equality-delete field. A
+ * timestamptz field also carries {@code timestamp_is_adjusted_to_utc}, which 
BE needs to read INT96 as an
+ * instant.</p>
  */
 public final class IcebergSchemaUtils {
 
@@ -393,6 +395,14 @@ public final class IcebergSchemaUtils {
         TColumnType columnType = new TColumnType();
         if (type.isPrimitiveType()) {
             tField.setType(buildPrimitiveColumnType(type, enableVarbinary, 
enableTimestampTz));
+            if (type.typeId() == TypeID.TIMESTAMP && ((Types.TimestampType) 
type).shouldAdjustToUTC()) {
+                // Iceberg decodes a Parquet timestamp by the table type, not 
by the file annotation. Spark and Hive
+                // write timestamptz as INT96, which has no annotation, so 
without this marker BE reads such a
+                // column as a UTC wall clock and shifts it by the session 
offset. timestamp fields stay unmarked:
+                // BE would then read an adjusted-UTC INT64 file as a UTC wall 
clock in its statistics but as a
+                // session-local wall clock in its rows.
+                tField.setTimestampIsAdjustedToUtc(true);
+            }
             return tField;
         }
         if (type.isVariantType()) {
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
index 832ed2149bc..d45eaccf68f 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
@@ -676,6 +676,48 @@ public class IcebergSchemaUtilsTest {
                 
props.getNestedField().getMapField().getValueField().getFieldPtr().getId());
     }
 
+    @Test
+    public void timestamptzFieldsCarryInstantMarkerAtEveryLevel() {
+        // Spark and Hive write timestamptz as INT96 without an instant 
annotation, so BE reads it as an instant only
+        // when the field says so. timestamp and timestamp_ns stay unmarked 
whatever the catalog mapping flag is.
+        // MUTATION: drop the marker -> red; also mark timestamp fields -> red.
+        Schema schema = new Schema(
+                Types.NestedField.optional(1, "tz", 
Types.TimestampType.withZone()),
+                Types.NestedField.optional(2, "wall", 
Types.TimestampType.withoutZone()),
+                Types.NestedField.optional(3, "wall_ns", 
Types.TimestampNanoType.withoutZone()),
+                Types.NestedField.optional(4, "info", Types.StructType.of(
+                        Types.NestedField.optional(5, "tz", 
Types.TimestampType.withZone()),
+                        Types.NestedField.optional(6, "wall", 
Types.TimestampType.withoutZone()))),
+                Types.NestedField.optional(7, "tzs",
+                        Types.ListType.ofOptional(8, 
Types.TimestampType.withZone())),
+                Types.NestedField.optional(9, "by_tz", 
Types.MapType.ofOptional(10, 11,
+                        Types.TimestampType.withZone(), 
Types.TimestampType.withoutZone())));
+
+        for (boolean enableTimestampTz : new boolean[] {false, true}) {
+            Map<String, TField> fields = 
topFields(IcebergSchemaUtils.buildCurrentSchema(schema,
+                    Arrays.asList("tz", "wall", "wall_ns", "info", "tzs", 
"by_tz"), Collections.emptyMap(),
+                    false, false, enableTimestampTz));
+
+            assertInstantMarker(fields.get("tz"));
+            
Assertions.assertFalse(fields.get("wall").isSetTimestampIsAdjustedToUtc());
+            
Assertions.assertFalse(fields.get("wall_ns").isSetTimestampIsAdjustedToUtc());
+            TField info = fields.get("info");
+            Assertions.assertFalse(info.isSetTimestampIsAdjustedToUtc());
+            assertInstantMarker(childByName(info, "tz"));
+            Assertions.assertFalse(childByName(info, 
"wall").isSetTimestampIsAdjustedToUtc());
+            
assertInstantMarker(fields.get("tzs").getNestedField().getArrayField().getItemField().getFieldPtr());
+            TField byTz = fields.get("by_tz");
+            
assertInstantMarker(byTz.getNestedField().getMapField().getKeyField().getFieldPtr());
+            
Assertions.assertFalse(byTz.getNestedField().getMapField().getValueField().getFieldPtr()
+                    .isSetTimestampIsAdjustedToUtc());
+        }
+    }
+
+    private static void assertInstantMarker(TField field) {
+        Assertions.assertTrue(field.isSetTimestampIsAdjustedToUtc(), 
field.getName());
+        Assertions.assertTrue(field.isTimestampIsAdjustedToUtc(), 
field.getName());
+    }
+
     @Test
     public void nestedStructChildNamesAreLowercased() {
         // The crash repro (test_iceberg_struct_schema_evolution / 
DROP_AND_ADD): a struct child whose iceberg


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

Reply via email to