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 00273504b2f [fix](iceberg) Read ID-less Parquet files by positional 
field ids (#68962)
00273504b2f is described below

commit 00273504b2fdf50fd6443ed8fac04a03d1a573c8
Author: daidai <[email protected]>
AuthorDate: Thu Oct 15 11:13:51 2026 +0800

    [fix](iceberg) Read ID-less Parquet files by positional field ids (#68962)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A (tracked as DORIS-29685)
    
    Related PR: N/A
    
    Problem Summary:
    A Parquet file without Iceberg field ids (`add_files` / migrated file)
    was read as an all-NULL row, while Iceberg/Spark reads it by position
    (`ParquetSchemaUtil.addFallbackIds`,
    `iceberg.netflix.unsafe-parquet-id-fallback.enabled`). Fix: when a
    split's file has no field id at all, the plan uses v2 scan semantics and
    the table exposes no usable name mapping, stamp ordinal ids 1..N on the
    file's top-level fields so the existing `BY_FIELD_ID` path resolves
    them. Nested children stay NULL (same as Iceberg); ORC untouched.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
        - [x] Unit Test
    - Behavior changed:
        - [x] Yes.
    - Does this need documentation?
        - [x] No.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/format_v2/column_mapper.cpp                 |  26 ++
 be/src/format_v2/table/iceberg_reader.cpp          |  48 +++
 be/src/format_v2/table/iceberg_reader.h            |  27 ++
 be/src/format_v2/table_reader.h                    |   7 +-
 be/test/format_v2/column_mapper_test.cpp           |  32 ++
 be/test/format_v2/table/iceberg_reader_test.cpp    | 424 ++++++++++++++++++++-
 ...ceberg_migrated_nested_without_name_mapping.out |   7 +-
 .../iceberg/test_gen_iceberg_by_api.groovy         |  18 +-
 ...erg_migrated_nested_without_name_mapping.groovy |  16 +-
 9 files changed, 592 insertions(+), 13 deletions(-)

diff --git a/be/src/format_v2/column_mapper.cpp 
b/be/src/format_v2/column_mapper.cpp
index c991b2fd940..0420f765b2e 100644
--- a/be/src/format_v2/column_mapper.cpp
+++ b/be/src/format_v2/column_mapper.cpp
@@ -1246,6 +1246,10 @@ static bool rewrite_in_struct_literal_predicate(
     return true;
 }
 
+// Defined with the other mapping-shape helpers below; needed when rewriting 
table expressions to
+// file-local expressions (a slot with unmapped children cannot stand in for 
the table value).
+static bool mapping_slot_has_unmapped_child(const ColumnMapping& mapping);
+
 static VExprSPtr rewrite_struct_or_slot_expr_to_file_expr(
         const VExprSPtr& expr,
         const std::map<GlobalIndex, FileSlotRewriteInfo>& global_to_file_slot,
@@ -1288,6 +1292,15 @@ static VExprSPtr 
rewrite_struct_or_slot_expr_to_file_expr(
         return expr;
     }
     const auto& rewrite_info = rewrite_it->second;
+    DORIS_CHECK(rewrite_info.root_mapping != nullptr);
+    if (mapping_slot_has_unmapped_child(*rewrite_info.root_mapping)) {
+        // Final materialization substitutes a default/NULL for every child 
the projection left
+        // unmapped, so the file slot carries only a partial value: any 
file-local evaluation under
+        // this slot (whole-root casts included) would use a different value 
than the user sees.
+        // Keep such predicates above TableReader instead of filtering on the 
physical row.
+        *can_localize = false;
+        return expr;
+    }
     auto file_slot = create_file_slot_ref(*slot_ref, rewrite_info, 
rewrite_context);
     if (rewrite_info.file_type->equals(*rewrite_info.table_type)) {
         return file_slot;
@@ -1500,6 +1513,16 @@ static bool needs_complex_rematerialize(const 
ColumnMapping& mapping) {
     return false;
 }
 
+// The physical slot is only a partial carrier of the table value when the 
projection left at least
+// one descendant without a file mapping: final materialization substitutes a 
default/NULL for that
+// child, so a predicate rooted at this slot must not be evaluated on the file 
value (DORIS-29685).
+// Scalar type promotion and struct renaming keep their existing localization 
paths.
+static bool mapping_slot_has_unmapped_child(const ColumnMapping& mapping) {
+    return std::ranges::any_of(mapping.child_mappings, [](const ColumnMapping& 
child) {
+        return !child.file_local_id.has_value() || 
mapping_slot_has_unmapped_child(child);
+    });
+}
+
 static bool mapping_can_use_file_column_directly(const ColumnMapping& mapping) 
{
     if (mapping.table_type == nullptr || mapping.file_type == nullptr) {
         return false;
@@ -1512,6 +1535,9 @@ static bool mapping_can_use_file_column_directly(const 
ColumnMapping& mapping) {
     if (!mapping.table_type->equals(*mapping.file_type) && 
!same_timestamptz_with_different_scale) {
         return false;
     }
+    if (mapping_slot_has_unmapped_child(mapping)) {
+        return false;
+    }
     return !needs_complex_rematerialize(mapping);
 }
 
diff --git a/be/src/format_v2/table/iceberg_reader.cpp 
b/be/src/format_v2/table/iceberg_reader.cpp
index 1ac44f41766..67e35b110c5 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -199,6 +199,50 @@ Status IcebergTableReader::validate_variant_file_mappings(
             file_format_name(format));
 }
 
+Status IcebergTableReader::annotate_file_schema(
+        std::vector<format::ColumnDefinition>* file_schema) {
+    if (file_schema == nullptr || file_schema->empty()) {
+        return Status::OK();
+    }
+    // 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.
+    if (_format != FileFormat::PARQUET) {
+        return Status::OK();
+    }
+    // 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();
+    }
+    // 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();
+    }
+    // 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
+    // carriers (row number / global row id) are not file fields and must not 
consume an ordinal.
+    int32_t ordinal = 0;
+    for (auto& field : *file_schema) {
+        if (field.column_type != format::ColumnType::DATA_COLUMN) {
+            continue;
+        }
+        ++ordinal;
+        // ID-less Parquet fields carry their physical name as a STRING 
identifier
+        // (ParquetReader::_fill_column_definition), so only a real integer 
field id may skip
+        // stamping; overwriting the string identifier is what lets 
FieldIdMatcher resolve the file.
+        if (field.has_identifier_field_id()) {
+            continue;
+        }
+        field.identifier = Field::create_field<TYPE_INT>(ordinal);
+    }
+    _positional_id_fallback = true;
+    return Status::OK();
+}
+
 Status IcebergTableReader::validate_file_mapping(const 
format::TableColumnMapper& mapper) const {
     const bool metadata_only_count = _push_down_agg_type == 
TPushAggOp::type::COUNT &&
                                      _push_down_count_columns.has_value() &&
@@ -1053,6 +1097,9 @@ Status IcebergTableReader::prepare_split(const 
format::SplitReadOptions& options
         _row_lineage_columns = {};
         _iceberg_params.reset();
         _delete_predicates_initialized = false;
+        // Per-file diagnostic: the reader instance is reused across splits, 
so a previous ID-less
+        // file must not leave this set for the next (possibly ID-bearing) 
split.
+        _positional_id_fallback = false;
         _position_delete_rows_storage.clear();
         _equality_delete_filters.clear();
         _split_cache = options.cache;
@@ -1145,6 +1192,7 @@ std::string IcebergTableReader::debug_string() const {
         << ", deletion_vector_file_count=" << deletion_vector_file_count
         << ", position_delete_rows_storage_count=" << 
_position_delete_rows_storage.size()
         << ", equality_delete_filter_count=" << _equality_delete_filters.size()
+        << ", positional_id_fallback=" << _positional_id_fallback
         << ", equality_delete_filters=" << equality_filters.str() << "}";
     return out.str();
 }
diff --git a/be/src/format_v2/table/iceberg_reader.h 
b/be/src/format_v2/table/iceberg_reader.h
index f20207d6e2c..1a4292d0994 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -89,6 +89,29 @@ protected:
 
     Status validate_file_mapping(const format::TableColumnMapper& mapper) 
const override;
 
+    // Iceberg's Parquet reader resolves a data file that carries no field ids 
at all through the
+    // table's `schema.name-mapping.default`; when that mapping is absent it 
falls back to
+    // positional ids (ParquetSchemaUtil.addFallbackIds + 
pruneColumnsFallback, gated by
+    // `iceberg.netflix.unsafe-parquet-id-fallback.enabled`, default true 
through 1.12 and
+    // scheduled for removal in 2.0). Mirror that decision for Parquet by 
stamping ordinal ids
+    // (1..N) on the file's top-level fields, so the shared BY_FIELD_ID path 
resolves those files
+    // instead of silently materializing NULL. Only top-level fields are 
stamped: Iceberg assigns
+    // no ids to nested fields, so struct children of an id-less file stay 
unreadable without a
+    // name mapping (or a rewrite that persists field ids).
+    Status annotate_file_schema(std::vector<format::ColumnDefinition>* 
file_schema) override;
+
+    // Iceberg's positional fallback matches an ID-less Parquet file by 
physical ordinals, so the
+    // same table column can bind a different file column when the table's
+    // `schema.name-mapping.default` is added or removed without touching the 
data file. The
+    // condition cache key covers the file identity and the table-level 
predicate digest only, so it
+    // cannot tell those bindings apart; keep such splits out of the cache 
(neither read nor
+    // written) instead of reusing a verdict produced by the other binding.
+    bool _should_enable_condition_cache(
+            const format::FileScanRequest& file_request) const override {
+        return !_positional_id_fallback &&
+               
format::TableReader::_should_enable_condition_cache(file_request);
+    }
+
     void configure_mapper_options(format::TableColumnMapperOptions* options) 
const override {
         options->enable_row_lineage_virtual_columns = true;
         options->enable_iceberg_metadata_virtual_columns = true;
@@ -205,6 +228,10 @@ private:
     size_t _row_position_block_position = 0;
     std::optional<TIcebergFileDesc> _iceberg_params;
     bool _delete_predicates_initialized = false;
+    // Set when this split's Parquet file carried no field ids and the table 
exposed no usable name
+    // mapping, so the reader stamped Iceberg's positional fallback ids on the 
file's top-level
+    // fields. Observability only; the decision itself lives in 
annotate_file_schema().
+    bool _positional_id_fallback = false;
     format::DeleteRows _position_delete_rows_storage;
     struct EqualityDeleteFilter {
         std::vector<int> field_ids;
diff --git a/be/src/format_v2/table_reader.h b/be/src/format_v2/table_reader.h
index 7aa4400320f..800d254de83 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -555,7 +555,12 @@ protected:
     Status _open_local_filter_exprs(const FileScanRequest& file_request);
     Status _init_reader_condition_cache(const FileScanRequest& file_request);
     void _finalize_reader_condition_cache();
-    bool _should_enable_condition_cache(const FileScanRequest& file_request) 
const;
+    // The condition cache key covers the file identity plus the table-level 
predicate/session
+    // digest only. A table format whose file-column binding can change 
without changing that digest
+    // (Iceberg's positional fallback resolves an ID-less file by physical 
ordinals) must override
+    // this to false: a stale all-false granule would silently drop matching 
rows, because the
+    // cached verdict was produced by a different physical binding.
+    virtual bool _should_enable_condition_cache(const FileScanRequest& 
file_request) const;
 
     Status _evaluate_constant_filters(bool* can_filter_all) {
         DORIS_CHECK(can_filter_all != nullptr);
diff --git a/be/test/format_v2/column_mapper_test.cpp 
b/be/test/format_v2/column_mapper_test.cpp
index cf6e3cc4688..249c6683673 100644
--- a/be/test/format_v2/column_mapper_test.cpp
+++ b/be/test/format_v2/column_mapper_test.cpp
@@ -2172,6 +2172,38 @@ TEST(ColumnMapperLocalizeFiltersTest, 
VisibleLocalFilterAddsPredicateColumnAndCo
     EXPECT_TRUE(localized_slot->data_type()->equals(*int_type));
 }
 
+// DORIS-29685: an ID-less Parquet file is matched by positional ids, so a 
struct root can be
+// mapped while its children stay unmatched. Final materialization then 
substitutes NULL for those
+// children, which means the physical slot no longer carries the table value: 
a whole-root predicate
+// such as LIKE(CAST(s AS STRING), '%null%') must stay above TableReader 
instead of being evaluated
+// on the raw file slot (which would still see the child's physical value and 
drop the row).
+TEST(ColumnMapperLocalizeFiltersTest, 
PartiallyMappedStructRootPredicateStaysAboveFileReader) {
+    auto table_struct = struct_col("s", 1, {field_id_col("inner", 2, i32())});
+    // Positional fallback stamped the root only; the child keeps its physical 
name identifier.
+    auto file_struct = struct_col("s", 1, {name_col("inner", i32(), 0)}, 0);
+
+    TableColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
+    ASSERT_TRUE(mapper.create_mapping({table_struct}, {}, {file_struct}).ok());
+
+    ASSERT_EQ(mapper.mappings().size(), 1);
+    ASSERT_TRUE(mapper.mappings()[0].file_local_id.has_value());
+    ASSERT_EQ(mapper.mappings()[0].child_mappings.size(), 1);
+    
EXPECT_FALSE(mapper.mappings()[0].child_mappings[0].file_local_id.has_value());
+    EXPECT_FALSE(mapper.mappings()[0].is_trivial);
+
+    auto cast_to_string = cast_expr(table_slot(11, 0, table_struct.type, "s"), 
str());
+    TableFilter filter {
+            .conjunct = VExprContext::create_shared(like_expr(cast_to_string, 
"%null%")),
+            .global_indices = {GlobalIndex(0)}};
+
+    FileScanRequest request;
+    ASSERT_TRUE(mapper.localize_filters({filter}, &request).ok());
+
+    // No file-local copy of the predicate: the row must be filtered on the 
materialized
+    // {"inner": null} value, not on the physical {"inner": <file value>}.
+    EXPECT_TRUE(request.conjuncts.empty());
+}
+
 TEST(ColumnMapperLocalizeFiltersTest, VarbinaryFilterStaysAboveFileReader) {
     const auto binary_type = varbinary();
     const auto table_column = name_col("partition_key", binary_type);
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp 
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 8ee7788a40e..9a657dfbab6 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -644,6 +644,55 @@ void write_two_int_parquet_file(const std::string& 
file_path, const std::string&
                                                       builder.build()));
 }
 
+// Parquet files written by non-Iceberg writers carry no `field_id` at all. 
Iceberg reads them by
+// file ordinal through ParquetSchemaUtil.addFallbackIds/pruneColumnsFallback 
whenever the table has
+// no `schema.name-mapping.default`; these helpers produce exactly that 
physical layout.
+void write_idless_int_columns_parquet_file(const std::string& file_path,
+                                           const std::vector<std::string>& 
names,
+                                           const 
std::vector<std::vector<int32_t>>& column_values) {
+    ASSERT_EQ(names.size(), column_values.size());
+    std::vector<std::shared_ptr<arrow::Field>> fields;
+    std::vector<std::shared_ptr<arrow::Array>> arrays;
+    for (size_t idx = 0; idx < names.size(); ++idx) {
+        fields.push_back(arrow::field(names[idx], arrow::int32(), false));
+        arrays.push_back(build_int32_array(column_values[idx]));
+    }
+    auto table = arrow::Table::Make(arrow::schema(fields), arrays);
+
+    auto file_result = arrow::io::FileOutputStream::Open(file_path);
+    ASSERT_TRUE(file_result.ok()) << file_result.status();
+    std::shared_ptr<arrow::io::FileOutputStream> out = *file_result;
+    ::parquet::WriterProperties::Builder builder;
+    builder.version(::parquet::ParquetVersion::PARQUET_2_6);
+    builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    builder.compression(::parquet::Compression::UNCOMPRESSED);
+    const auto rows = column_values.empty() ? static_cast<int64_t>(0)
+                                            : 
static_cast<int64_t>(column_values[0].size());
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
+                                                      rows, builder.build()));
+}
+
+// A fully id-less nested file: `s STRUCT<inner INT>` with no `field_id` at 
any level.
+void write_idless_struct_parquet_file(const std::string& file_path,
+                                      const std::vector<int32_t>& values) {
+    auto inner_field = arrow::field("inner", arrow::int32(), false);
+    auto struct_result = arrow::StructArray::Make({build_int32_array(values)}, 
{inner_field});
+    ASSERT_TRUE(struct_result.ok()) << struct_result.status();
+    auto struct_field = arrow::field("s", arrow::struct_({inner_field}), 
false);
+    auto table = arrow::Table::Make(arrow::schema({struct_field}), 
{*struct_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::WriterProperties::Builder builder;
+    builder.version(::parquet::ParquetVersion::PARQUET_2_6);
+    builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    builder.compression(::parquet::Compression::UNCOMPRESSED);
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
+                                                      
static_cast<int64_t>(values.size()),
+                                                      builder.build()));
+}
+
 void write_two_int_orc_file(const std::string& file_path, const std::string& 
first_name,
                             const std::vector<int64_t>& first_values,
                             std::optional<int32_t> first_field_id, const 
std::string& second_name,
@@ -1327,6 +1376,15 @@ void expect_int32_column_values(const IColumn& column,
     }
 }
 
+void expect_nullable_int32_column_is_null(const IColumn& column, size_t rows) {
+    const auto full_column = column.convert_to_full_column_if_const();
+    const auto& nullable_column = assert_cast<const 
ColumnNullable&>(*full_column);
+    ASSERT_EQ(nullable_column.size(), rows);
+    for (size_t row = 0; row < rows; ++row) {
+        EXPECT_EQ(nullable_column.get_null_map_data()[row], 1);
+    }
+}
+
 void expect_string_column_values(const IColumn& column,
                                  const std::vector<std::string>& 
expected_values) {
     const auto full_column = column.convert_to_full_column_if_const();
@@ -2952,7 +3010,10 @@ TEST(IcebergV2ReaderTest, 
IcebergLegacyPlanKeepsAllFieldIdsMappingRule) {
               TableColumnMappingMode::BY_NAME);
 }
 
-TEST(IcebergV2ReaderTest, IcebergV2IdlessFileRequiresAuthoritativeNameMapping) 
{
+// Mapping-mode contract for an id-less file under v2 semantics: without a 
table-level name mapping
+// the file stays on the BY_FIELD_ID path (DORIS-29685 stamps Iceberg's 
positional fallback ids on
+// its top-level fields), and an authoritative table-level mapping switches 
the split to BY_NAME.
+TEST(IcebergV2ReaderTest, IcebergV2IdlessFileMappingModeSelection) {
     IcebergTableReaderMappingModeTestHelper reader;
     TFileScanRangeParams scan_params;
     
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
@@ -2971,6 +3032,367 @@ TEST(IcebergV2ReaderTest, 
IcebergV2IdlessFileRequiresAuthoritativeNameMapping) {
               TableColumnMappingMode::BY_NAME);
 }
 
+// Exposes the condition-cache decision of the current split so a test can 
assert that the
+// positional fallback keeps its splits out of the cache.
+class IcebergConditionCacheProbeReader final : public 
doris::format::iceberg::IcebergTableReader {
+public:
+    void TEST_force_condition_cache_digest(uint64_t digest) {
+        _condition_cache_digest = digest;
+        _condition_cache_digest_covers_current_split = true;
+    }
+    bool TEST_condition_cache_enabled_for_current_split() const {
+        DORIS_CHECK(_file_scan_request != nullptr);
+        return _should_enable_condition_cache(*_file_scan_request);
+    }
+};
+
+// DORIS-29685: the condition cache key covers the file identity plus the 
table-level
+// predicate/session digest only, so it cannot tell two physical bindings of 
the same table column
+// apart. Iceberg's positional fallback can change that binding when
+// `schema.name-mapping.default` is added or removed without touching the data 
file, so those
+// splits must stay out of the condition cache entirely (neither read nor 
written).
+TEST(IcebergV2ReaderTest, PositionalIdFallbackSplitsDisableConditionCache) {
+    const auto run_case = [](bool idless_file, int32_t table_field_id) {
+        const auto test_dir = std::filesystem::temp_directory_path() /
+                              (idless_file ? 
"doris_iceberg_cache_fallback_test"
+                                           : 
"doris_iceberg_cache_field_id_test");
+        std::filesystem::remove_all(test_dir);
+        std::filesystem::create_directories(test_dir);
+        const auto file_path = (test_dir / "split.parquet").string();
+        if (idless_file) {
+            write_idless_int_columns_parquet_file(file_path, {"legacy_id"}, 
{{7}});
+        } else {
+            write_single_int_parquet_file(file_path, "id", {7}, 0);
+        }
+
+        std::vector<ColumnDefinition> projected_columns;
+        projected_columns.push_back(
+                make_table_column(table_field_id, "id", 
std::make_shared<DataTypeInt32>()));
+
+        RuntimeProfile profile("test_profile");
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        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);
+        IcebergConditionCacheProbeReader reader;
+        ASSERT_TRUE(reader.init({.projected_columns = projected_columns,
+                                 .conjuncts = {prepared_conjunct(
+                                         &state, 
table_int32_greater_than_expr(0, 0, 0))},
+                                 .format = FileFormat::PARQUET,
+                                 .scan_params = &scan_params,
+                                 .io_ctx = io_ctx,
+                                 .runtime_state = &state,
+                                 .scanner_profile = &profile})
+                            .ok());
+
+        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());
+
+        Block block = build_table_block(projected_columns);
+        bool eos = false;
+        ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+        ASSERT_FALSE(eos);
+        // Force a digest so the base decision is not disabled for unrelated 
reasons: without the
+        // override an ID-less split would be cacheable here.
+        reader.TEST_force_condition_cache_digest(0x5f5e100);
+        EXPECT_EQ(reader.TEST_condition_cache_enabled_for_current_split(), 
!idless_file);
+
+        ASSERT_TRUE(reader.close().ok());
+        std::filesystem::remove_all(test_dir);
+    };
+
+    // A file carrying real field ids keeps the table column bound to the same 
physical column, so
+    // the condition cache stays available.
+    run_case(false, 0);
+    // The positional fallback binds by physical ordinal, which the cache key 
cannot represent.
+    run_case(true, 1);
+}
+
+// DORIS-29685: an add_files split (no field ids) cherry-picked back into an 
evolved table read as a
+// whole all-NULL row because the table no longer carried a usable name 
mapping. Iceberg's Parquet
+// reader binds such files by position (`ParquetSchemaUtil.addFallbackIds` + 
`pruneColumnsFallback`,
+// `iceberg.netflix.unsafe-parquet-id-fallback.enabled` defaults to true), so 
Doris must do the same
+// instead of silently materializing NULL. The physical names deliberately 
differ from the table
+// names: the fallback is "file ordinal k == Iceberg field id k", never name 
based.
+TEST(IcebergV2ReaderTest, 
IcebergIdlessParquetWithoutNameMappingUsesPositionalIds) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_fallback_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"legacy_id", 
"legacy_value"},
+                                          {{1, 2, 3}, {10, 20, 30}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(2, "value", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=1"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 3);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2, 3});
+    expect_int32_column_values(*block.get_by_position(1).column, {10, 20, 30});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg's fallback matches a table field with id k to the k-th physical 
top-level field, so a
+// dropped (or re-added) field does not shift the remaining bindings the way a 
table-position rule
+// would. Table ids 1 and 3 must therefore read file ordinals 1 and 3.
+TEST(IcebergV2ReaderTest, 
IcebergPositionalFallbackMatchesFieldIdNotColumnPosition) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_field_id_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"first", "second", 
"third"},
+                                          {{1, 2}, {10, 20}, {100, 200}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "first", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(3, "third", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_int32_column_values(*block.get_by_position(1).column, {100, 200});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// The positional fallback only applies while the table exposes no usable name 
mapping. When an
+// authoritative mapping is present, Iceberg applies it and prunes by id: the 
covered alias binds,
+// and a field whose mapping says "absent from this file" stays NULL even 
though a physical column
+// exists at that ordinal.
+TEST(IcebergV2ReaderTest, 
IcebergIdlessParquetKeepsAuthoritativeNameMappingPath) {
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          
"doris_iceberg_positional_id_mapping_precedence_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"legacy_id", 
"legacy_value"},
+                                          {{1, 2}, {10, 20}});
+
+    auto mapped_column = make_table_column(1, "id", 
std::make_shared<DataTypeInt32>());
+    mapped_column.has_name_mapping = true;
+    mapped_column.name_mapping = {"legacy_id"};
+    auto unmapped_column = make_table_column(2, "value", 
std::make_shared<DataTypeInt32>());
+    unmapped_column.has_name_mapping = true;
+    std::vector<ColumnDefinition> projected_columns {mapped_column, 
unmapped_column};
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_nullable_int32_column_is_null(*block.get_by_position(1).column, 2);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// An old-FE plan (scan semantics version 1) keeps the historical name 
fallback for id-less files;
+// the positional fallback must stay opt-in for version 2 so a rolling upgrade 
cannot change it.
+TEST(IcebergV2ReaderTest, IcebergLegacyPlanKeepsNameFallbackForIdlessParquet) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_legacy_fe_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"id", "value"}, {{1, 2}, 
{10, 20}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(2, "value", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_int32_column_values(*block.get_by_position(1).column, {10, 20});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg stamps ordinal ids on top-level fields only, so an id-less nested 
file still materializes
+// the struct root by position while its children stay NULL until a name 
mapping (or a rewrite that
+// persists field ids) resolves them. Doris must not invent nested positions 
either.
+TEST(IcebergV2ReaderTest, IcebergIdlessParquetStructChildStaysNull) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_nested_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_struct_parquet_file(file_path, {7});
+
+    const auto int_type = std::make_shared<DataTypeInt32>();
+    auto inner_type = std::make_shared<DataTypeStruct>(DataTypes {int_type}, 
Strings {"inner"});
+    auto outer = make_table_column(1, "s", inner_type);
+    outer.children = {make_table_column(2, "inner", int_type)};
+    std::vector<ColumnDefinition> projected_columns {outer};
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=1"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 1);
+    const auto& struct_column =
+            assert_cast<const 
ColumnStruct&>(expect_not_null_table_column(block, 0));
+    expect_nullable_int32_column_is_null(struct_column.get_column(0), 1);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg's hasIds contract is existential: one real field id keeps the file 
on the id path, so
+// neither the name mapping nor the positional fallback may rescue the 
remaining id-less fields.
+TEST(IcebergV2ReaderTest, IcebergPartiallyIdedParquetKeepsIdOnlyMatching) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_partial_ids_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_two_int_parquet_file(file_path, "id", {1, 2}, 0, "extra", {9, 9}, 
std::nullopt);
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(0, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(1, "extra", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    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());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_nullable_int32_column_is_null(*block.get_by_position(1).column, 2);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
 TEST(IcebergV2ReaderTest, VariantFormatGateUsesPhysicalFileMappings) {
     ColumnMapping missing_variant;
     missing_variant.table_type = 
make_nullable(std::make_shared<DataTypeVariantV2>());
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
index 9aea1c252d0..df25da67760 100644
--- 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
@@ -1,3 +1,6 @@
 -- This file is automatically generated. You should know what you did if you 
want to edit this
--- !idless_nested_fields_are_missing --
-\N     \N      \N
+-- !idless_top_level_field_uses_positional_id --
+1      \N      \N
+
+-- !idless_top_level_field_filter_uses_positional_id --
+1
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
index 0227dbf95eb..9ae1107a1d9 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
@@ -48,12 +48,18 @@ suite("test_gen_iceberg_by_api", "p0,external") {
     def q01 = {
         qt_q01 """ select * from multi_partition2 order by val """
 
-        test {
-            sql """ select count(*) from table_with_append_file where MAN_ID 
is not null """
-            // This fixture has no field IDs or authoritative name mapping, so 
its required
-            // columns must be treated as missing instead of being matched by 
their current names.
-            exception "Missing required field: MAN_ID"
-        }
+        // This fixture has no field IDs and no authoritative name mapping. 
Iceberg's Parquet reader
+        // binds such a file by physical ordinals 
(ParquetSchemaUtil.addFallbackIds +
+        // pruneColumnsFallback), so a required column is no longer reported 
as missing and the
+        // appended record must stay readable. Exact values of ID-less 
fixtures are pinned by
+        // test_iceberg_migrated_nested_without_name_mapping.
+        def appendedRows = sql """ select count(*) from table_with_append_file 
"""
+        assertTrue(appendedRows.size() == 1 && 
appendedRows[0][0].toString().toInteger() >= 1,
+                "ID-less append fixture must stay readable, got 
${appendedRows}")
+
+        // The positional binding resolves MAN_ID to one of the file's 
physical columns; the query
+        // must no longer fail with "Missing required field: MAN_ID".
+        sql """ select count(*) from table_with_append_file where MAN_ID is 
not null """
     }
 
     q01()
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
index 8f1ceec0906..546d98d8f68 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
@@ -43,13 +43,23 @@ suite("test_iceberg_migrated_nested_without_name_mapping", 
"p0,external") {
     sql """use multi_catalog"""
     sql """set enable_fallback_to_original_planner=false"""
 
-    // The physical Parquet fields predate migration and have no Iceberg IDs. 
Once the explicit
-    // default name mapping is removed, neither the root nor nested values may 
match by current name.
-    qt_idless_nested_fields_are_missing """
+    // The physical Parquet fields predate migration and carry no Iceberg IDs, 
and this registered
+    // metadata has no `schema.name-mapping.default`. Iceberg's Parquet reader 
binds such a file by
+    // position (ParquetSchemaUtil.addFallbackIds + pruneColumnsFallback, see 
DORIS-29685): the k-th
+    // physical top-level field is field id k, so `id` (field id 1) must read 
its real value instead
+    // of NULL. Fallback ids are stamped on top-level fields only, so the 
nested children stay NULL
+    // until a name mapping is restored.
+    qt_idless_top_level_field_uses_positional_id """
         SELECT id, nested_struct.value_a, nested_struct.value_b
         FROM migrated_nested_without_name_mapping
         ORDER BY id
     """
 
+    // The same positional binding must feed predicate/row-group filtering, 
otherwise the row would
+    // be readable but unfilterable.
+    qt_idless_top_level_field_filter_uses_positional_id """
+        SELECT count(*) FROM migrated_nested_without_name_mapping WHERE id = 1
+    """
+
     sql """drop catalog if exists ${catalog_name}"""
 }


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

Reply via email to