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

Gabriel39 pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 9ffd818bec3 [fix](parquet) Share Variant layout compatibility across 
table formats (#69148)
9ffd818bec3 is described below

commit 9ffd818bec378f95e18a35c78955d9966d1d9467
Author: Gabriel <[email protected]>
AuthorDate: Fri Oct 16 15:57:18 2026 +0800

    [fix](parquet) Share Variant layout compatibility across table formats 
(#69148)
    
    ### What problem does this PR solve?
    
    Related PR: #69127 (merged into branch-4.1)
    
    Annotated Parquet Variant files may contain a required BYTE_ARRAY
    fallback in an array element without a typed carrier. The same physical
    layout was accepted through Paimon schema overrides but rejected for
    VARIANT(1)-annotated files, including files read through Iceberg.
    
    Port #69127: remove `allow_paimon_shredded_layout` and share the
    existing compatibility for fallback-only arrays, optional shredded
    metadata, and optional wrappers. Missing metadata and null array
    elements remain invalid at materialization; object fields and arrays
    with typed carriers still require optional fallbacks.
    
    The schema acceptance tests call NativeFieldDescriptor directly because
    diagnostic PR #69119 is still open. The file-reader regression still
    covers actual Parquet footers. The diagnostic rejection fixture in
    #69119 is updated separately to remain invalid after this compatibility
    fix, so the PRs can merge in either order.
    
    The port includes schema tests, a native file-reader regression, and two
    entirely synthetic six-row Parquet fixtures with a reproducible
    generator. No external data is included.
    
    ### Release note
    
    Fix reading annotated Parquet Variant columns with compatible shredded
    layouts previously accepted only through Paimon schema overrides.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test (ported coverage; target-branch execution pending CI)
    - [x] Manual test: regenerated both fixtures byte-for-byte and verified
    VARIANT(1), six rows and three row groups per file.
    
    Repository clang-format 16 and `git diff --check` passed. BE unit tests
    and full builds were not rerun locally for this port; `run buildall` is
    requested. The original PR contains the prior focused ASAN and native
    reader validation results.
    
    - Behavior changed:
    - [x] Yes. Annotated Variant columns share the existing shredded-layout
    compatibility rules.
    - 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/parquet/native_schema_desc.cpp    |  33 ++++----
 be/src/format_v2/parquet/native_schema_desc.h      |   3 +-
 be/src/format_v2/parquet/parquet_column_schema.cpp |   2 +-
 be/test/format_v2/parquet/parquet_reader_test.cpp  |  70 +++++++++++++++++
 be/test/format_v2/parquet/parquet_schema_test.cpp  |  41 ++++++++--
 .../iceberg/generate_variant_fallback_array.py     |  84 +++++++++++++++++++++
 .../variant_fallback_array_optional.parquet        | Bin 0 -> 2940 bytes
 .../variant_fallback_array_required.parquet        | Bin 0 -> 2924 bytes
 8 files changed, 204 insertions(+), 29 deletions(-)

diff --git a/be/src/format_v2/parquet/native_schema_desc.cpp 
b/be/src/format_v2/parquet/native_schema_desc.cpp
index bcb3abcf91f..6a21ce165cd 100644
--- a/be/src/format_v2/parquet/native_schema_desc.cpp
+++ b/be/src/format_v2/parquet/native_schema_desc.cpp
@@ -285,8 +285,7 @@ private:
 };
 
 Status validate_variant_layout(const NativeFieldSchema& group_field,
-                               std::optional<int8_t> specification_version,
-                               bool allow_paimon_shredded_layout) {
+                               std::optional<int8_t> specification_version) {
     if (specification_version.has_value() && *specification_version != 1) {
         return Status::NotSupported("Parquet Variant specification version {} 
is not supported",
                                     *specification_version);
@@ -328,11 +327,11 @@ Status validate_variant_layout(const NativeFieldSchema& 
group_field,
                                   group_field.name);
     }
     const auto metadata_repetition = metadata->parquet_schema.repetition_type;
-    // Paimon makes every field in its shredded carrier optional. Restrict 
that compatibility to
-    // unannotated overrides; row materialization still rejects null metadata 
for a non-null value.
+    // Shredded writers may declare metadata optional regardless of the 
VARIANT annotation.
+    // Row materialization still rejects null metadata for a non-null Variant.
     const bool valid_metadata_repetition =
             metadata_repetition == tparquet::FieldRepetitionType::REQUIRED ||
-            (allow_paimon_shredded_layout && typed_value != nullptr &&
+            (typed_value != nullptr &&
              metadata_repetition == tparquet::FieldRepetitionType::OPTIONAL);
     if (!metadata->children.empty() || metadata->physical_type != 
tparquet::Type::BYTE_ARRAY ||
         !valid_metadata_repetition) {
@@ -364,15 +363,13 @@ Status validate_variant_layout(const NativeFieldSchema& 
group_field,
                 wrapper.parquet_schema.__isset.repetition_type &&
                 (wrapper.parquet_schema.repetition_type ==
                          tparquet::FieldRepetitionType::REQUIRED ||
-                 (allow_paimon_shredded_layout && 
wrapper.parquet_schema.repetition_type ==
-                                                          
tparquet::FieldRepetitionType::OPTIONAL));
-        // The Parquet Variant specification requires wrapper groups. Paimon's 
unannotated
-        // physical carrier makes them optional, so accept that representation 
only through the
-        // table-format override. Materialization still rejects an actually 
null array element;
-        // an absent object wrapper represents a missing key.
+                 wrapper.parquet_schema.repetition_type == 
tparquet::FieldRepetitionType::OPTIONAL);
+        // An optional wrapper is a writer layout choice, not a table-format 
identifier. A null
+        // object wrapper means an absent key; materialization still rejects 
null array elements.
         if (!valid_wrapper_repetition) {
-            return Status::Corruption("Parquet Variant shredded wrapper {} 
must be required",
-                                      wrapper.name);
+            return Status::Corruption(
+                    "Parquet Variant shredded wrapper {} must be required or 
optional",
+                    wrapper.name);
         }
         const NativeFieldSchema* fallback = nullptr;
         const NativeFieldSchema* typed = nullptr;
@@ -408,11 +405,11 @@ Status validate_variant_layout(const NativeFieldSchema& 
group_field,
                     "Parquet Variant object wrapper {} requires an optional 
value child",
                     wrapper.name);
         }
-        // Paimon makes this leaf required because a fallback-only array 
element has no typed
-        // carrier; keep the exception scoped to that exact unannotated layout.
-        const bool allow_required_fallback = allow_paimon_shredded_layout &&
-                                             context == 
WrapperContext::ARRAY_ELEMENT &&
-                                             typed == nullptr;
+        // Both annotated and unannotated writers use a required fallback-only 
array carrier.
+        // Even a null element has Variant null bytes; object fields must 
still represent absence,
+        // and a sibling typed_value needs a nullable fallback to avoid 
conflicting carriers.
+        const bool allow_required_fallback =
+                context == WrapperContext::ARRAY_ELEMENT && typed == nullptr;
         const bool valid_fallback_repetition =
                 fallback != nullptr && 
fallback->parquet_schema.__isset.repetition_type &&
                 (fallback->parquet_schema.repetition_type ==
diff --git a/be/src/format_v2/parquet/native_schema_desc.h 
b/be/src/format_v2/parquet/native_schema_desc.h
index 80683cf0efe..1f788bef999 100644
--- a/be/src/format_v2/parquet/native_schema_desc.h
+++ b/be/src/format_v2/parquet/native_schema_desc.h
@@ -91,8 +91,7 @@ struct NativeFieldSchema {
 };
 
 Status validate_variant_layout(const NativeFieldSchema& group_field,
-                               std::optional<int8_t> specification_version = 
std::nullopt,
-                               bool allow_paimon_shredded_layout = false);
+                               std::optional<int8_t> specification_version = 
std::nullopt);
 
 // V2 owns this schema tree and parser so footer/schema planning never invokes 
the V1 reader path.
 class NativeFieldDescriptor {
diff --git a/be/src/format_v2/parquet/parquet_column_schema.cpp 
b/be/src/format_v2/parquet/parquet_column_schema.cpp
index 984e0259165..484dad564ef 100644
--- a/be/src/format_v2/parquet/parquet_column_schema.cpp
+++ b/be/src/format_v2/parquet/parquet_column_schema.cpp
@@ -284,7 +284,7 @@ Status apply_variant_schema_override(const 
NativeFieldSchema& native_schema,
             return Status::Corruption("Parquet Variant {} must use a group 
carrier",
                                       native_schema.name);
         }
-        RETURN_IF_ERROR(validate_variant_layout(native_schema, std::nullopt, 
true));
+        RETURN_IF_ERROR(validate_variant_layout(native_schema));
         field->variant_physical_type = field->type;
         DataTypePtr variant_type = std::make_shared<DataTypeVariantV2>();
         field->type = field->type->is_nullable() ? 
make_nullable(std::move(variant_type))
diff --git a/be/test/format_v2/parquet/parquet_reader_test.cpp 
b/be/test/format_v2/parquet/parquet_reader_test.cpp
index 5aa1aaf16d7..cf222bbe9b5 100644
--- a/be/test/format_v2/parquet/parquet_reader_test.cpp
+++ b/be/test/format_v2/parquet/parquet_reader_test.cpp
@@ -2879,6 +2879,76 @@ TEST(ParquetVariantProjectionTest, 
FinalizesNestedVariantProjectionPerRowGroup)
     EXPECT_TRUE(repeated.children[0].project_all_children);
 }
 
+TEST_F(NewParquetReaderTest, ReadsAnnotatedVariantFallbackArrayLayouts) {
+    const char* source_root = std::getenv("ROOT");
+    ASSERT_NE(source_root, nullptr);
+    for (const auto* layout : {"required", "optional"}) {
+        SCOPED_TRACE(layout);
+        // Synthetic fixtures preserve the required binary element carrier 
without external data.
+        _file_path = std::string(source_root) +
+                     
"/regression-test/data/external_table_p0/iceberg/variant_fallback_array_" +
+                     layout + ".parquet";
+        auto reader = create_reader();
+        reader->set_batch_size(2);
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        ASSERT_TRUE(reader->init(&state).ok());
+        std::vector<format::ColumnDefinition> schema;
+        ASSERT_TRUE(reader->get_schema(&schema).ok());
+        ASSERT_EQ(schema.size(), 2);
+        auto request = std::make_shared<format::FileScanRequest>();
+        request->non_predicate_columns.push_back(
+                format::LocalColumnIndex::local(schema[1].local_id));
+        
request->local_positions.emplace(format::LocalColumnId(schema[1].local_id),
+                                         format::LocalIndex(0));
+        ASSERT_TRUE(reader->open(request).ok());
+        size_t rows = 0;
+        bool eof = false;
+        while (!eof) {
+            Block block;
+            block.insert({schema[1].type->create_column(), schema[1].type, 
"payload"});
+            size_t batch_rows = 0;
+            ASSERT_TRUE(reader->get_block(&block, &batch_rows, &eof).ok());
+            const auto& nullable =
+                    assert_cast<const 
ColumnNullable&>(*block.get_by_position(0).column);
+            const auto& variants =
+                    assert_cast<const 
ColumnVariantV2&>(nullable.get_nested_column());
+            for (size_t row = 0; row < batch_rows; ++row, ++rows) {
+                if (rows == 2) {
+                    EXPECT_TRUE(nullable.is_null_at(row));
+                    continue;
+                }
+                ASSERT_FALSE(nullable.is_null_at(row));
+                const auto value = variants.get_value_ref(row);
+                if (rows == 3) {
+                    EXPECT_TRUE(value.is_null());
+                    continue;
+                }
+                VariantRef events;
+                if (rows == 5) {
+                    EXPECT_FALSE(value.object_find(StringRef("events"), 
&events));
+                    EXPECT_EQ(value.num_elements(), 0);
+                    continue;
+                }
+                ASSERT_TRUE(value.object_find(StringRef("events"), &events));
+                if (rows == 0) {
+                    ASSERT_EQ(events.num_elements(), 3);
+                    EXPECT_EQ(events.array_at(0).get_int(), 3);
+                    EXPECT_TRUE(events.array_at(1).is_null());
+                    EXPECT_EQ(events.array_at(2).get_int(), 4);
+                } else if (rows == 1) {
+                    EXPECT_EQ(events.num_elements(), 0);
+                } else {
+                    ASSERT_EQ(rows, 4);
+                    ASSERT_EQ(events.num_elements(), 1);
+                    EXPECT_TRUE(events.array_at(0).is_null());
+                }
+            }
+        }
+        EXPECT_EQ(rows, 6);
+        ASSERT_TRUE(reader->close().ok());
+    }
+}
+
 TEST_F(NewParquetReaderTest, ReadsFullyShreddedVariantTypedLeafProjection) {
     const char* source_root = std::getenv("ROOT");
     ASSERT_NE(source_root, nullptr);
diff --git a/be/test/format_v2/parquet/parquet_schema_test.cpp 
b/be/test/format_v2/parquet/parquet_schema_test.cpp
index f7f96ea2c7e..97a10c3bd69 100644
--- a/be/test/format_v2/parquet/parquet_schema_test.cpp
+++ b/be/test/format_v2/parquet/parquet_schema_test.cpp
@@ -337,7 +337,33 @@ TEST(ParquetSchemaTest, 
AppliesPaimonFallbackOnlyArrayWithRequiredValue) {
     EXPECT_EQ(fields[0]->kind, ParquetColumnSchemaKind::VARIANT);
 }
 
-TEST(ParquetSchemaTest, 
RejectsRequiredShreddedFallbackOutsidePaimonFallbackOnlyArray) {
+TEST(ParquetSchemaTest, AcceptsAnnotatedFallbackOnlyArrayWithRequiredValue) {
+    auto schema = shredded_array_variant_schema(true, false);
+    schema[7].__set_repetition_type(tparquet::FieldRepetitionType::REQUIRED);
+    NativeFieldDescriptor descriptor;
+    ASSERT_TRUE(descriptor.parse_from_thrift(schema).ok());
+}
+
+TEST(ParquetSchemaTest, AcceptsAnnotatedShreddedVariantWithOptionalMetadata) {
+    auto schema = shredded_object_variant_schema();
+    schema[2].__set_repetition_type(tparquet::FieldRepetitionType::OPTIONAL);
+    NativeFieldDescriptor descriptor;
+    ASSERT_TRUE(descriptor.parse_from_thrift(schema).ok());
+}
+
+TEST(ParquetSchemaTest, AcceptsAnnotatedShreddedVariantWithOptionalWrappers) {
+    auto object_schema = shredded_object_variant_schema();
+    
object_schema[5].__set_repetition_type(tparquet::FieldRepetitionType::OPTIONAL);
+    NativeFieldDescriptor descriptor;
+    ASSERT_TRUE(descriptor.parse_from_thrift(object_schema).ok());
+
+    auto array_schema = shredded_array_variant_schema(true, false);
+    
array_schema[6].__set_repetition_type(tparquet::FieldRepetitionType::OPTIONAL);
+    
array_schema[7].__set_repetition_type(tparquet::FieldRepetitionType::REQUIRED);
+    ASSERT_TRUE(descriptor.parse_from_thrift(array_schema).ok());
+}
+
+TEST(ParquetSchemaTest, 
RejectsRequiredShreddedFallbackOutsideFallbackOnlyArray) {
     const auto expect_override_corruption = 
[](std::vector<tparquet::SchemaElement> schema) {
         NativeFieldDescriptor descriptor;
         ASSERT_TRUE(descriptor.parse_from_thrift(schema).ok());
@@ -364,7 +390,7 @@ TEST(ParquetSchemaTest, 
RejectsRequiredShreddedFallbackOutsidePaimonFallbackOnly
     
paimon_array_with_typed_value[7].__set_repetition_type(tparquet::FieldRepetitionType::REQUIRED);
     expect_override_corruption(std::move(paimon_array_with_typed_value));
 
-    auto annotated_array = shredded_array_variant_schema(true, false);
+    auto annotated_array = shredded_array_variant_schema(true, true);
     
annotated_array[7].__set_repetition_type(tparquet::FieldRepetitionType::REQUIRED);
     NativeFieldDescriptor descriptor;
     const auto annotated_status = 
descriptor.parse_from_thrift(annotated_array);
@@ -386,11 +412,11 @@ TEST(ParquetSchemaTest, 
RejectsMalformedUnannotatedVariantOverride) {
     EXPECT_NE(status.to_string().find("unexpected child"), std::string::npos);
 }
 
-TEST(ParquetSchemaTest, RejectsOptionalMetadataOutsidePaimonShreddedOverride) {
-    auto annotated_shredded = shredded_object_variant_schema();
-    
annotated_shredded[2].__set_repetition_type(tparquet::FieldRepetitionType::OPTIONAL);
+TEST(ParquetSchemaTest, RejectsOptionalMetadataForUnshreddedVariant) {
+    auto annotated_unshredded = unshredded_variant_schema();
+    
annotated_unshredded[2].__set_repetition_type(tparquet::FieldRepetitionType::OPTIONAL);
     NativeFieldDescriptor descriptor;
-    const auto annotated_status = 
descriptor.parse_from_thrift(annotated_shredded);
+    const auto annotated_status = 
descriptor.parse_from_thrift(annotated_unshredded);
     EXPECT_TRUE(annotated_status.is<ErrorCode::CORRUPTION>()) << 
annotated_status;
 
     auto unannotated_unshredded = unshredded_variant_schema();
@@ -510,8 +536,7 @@ TEST(ParquetSchemaTest, 
NativeVariantValidatesEveryShreddedWrapperAndScalar) {
 
     const auto optional_wrapper_status =
             descriptor.parse_from_thrift(shredded_object_variant_schema(true, 
false));
-    EXPECT_TRUE(optional_wrapper_status.is<ErrorCode::CORRUPTION>()) << 
optional_wrapper_status;
-    EXPECT_NE(optional_wrapper_status.to_string().find("wrapper"), 
std::string::npos);
+    EXPECT_TRUE(optional_wrapper_status.ok()) << optional_wrapper_status;
 }
 
 TEST(ParquetSchemaTest, NativeVariantRejectsDuplicateObjectFieldNames) {
diff --git 
a/regression-test/data/external_table_p0/iceberg/generate_variant_fallback_array.py
 
b/regression-test/data/external_table_p0/iceberg/generate_variant_fallback_array.py
new file mode 100644
index 00000000000..7ebc80e80f3
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/generate_variant_fallback_array.py
@@ -0,0 +1,84 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Generate synthetic Variant fixtures; no external data is read.
+
+Run from the repository root with:
+  uv run --with pyarrow==24.0.0 --with thriftpy2==0.5.3 python \
+    
regression-test/data/external_table_p0/iceberg/generate_variant_fallback_array.py
+"""
+from pathlib import Path
+import struct
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import thriftpy2
+from thriftpy2.protocol import TCompactProtocolFactory
+from thriftpy2.transport import TMemoryBuffer
+
+ROOT = Path(__file__).resolve().parents[4]
+PARQUET = thriftpy2.load(str(ROOT / "gensrc/thrift/parquet.thrift"),
+                        module_name="fixture_parquet_thrift")
+# A one-key metadata dictionary containing only the synthetic key "events".
+METADATA = bytes([1, 1, 0, 6]) + b"events"
+
+
+def generate(optional_wrappers):
+    element = pa.struct([pa.field("value", pa.binary(), nullable=False)])
+    values = pa.list_(pa.field("element", element, nullable=optional_wrappers))
+    wrapper = pa.struct([pa.field("value", pa.binary()),
+                         pa.field("typed_value", values)])
+    typed = pa.struct([pa.field("events", wrapper, 
nullable=optional_wrappers)])
+    variant = pa.struct([pa.field("metadata", pa.binary(), 
nullable=optional_wrappers),
+                         pa.field("value", pa.binary()), 
pa.field("typed_value", typed)])
+    schema = pa.schema([pa.field("id", pa.int32(), nullable=False),
+                        pa.field("payload", variant)])
+
+    def array(items):
+        return {"metadata": METADATA, "value": None, "typed_value": {
+            "events": {"value": None, "typed_value": [{"value": v} for v in 
items]}}}
+
+    # 0x0c is the Variant int8 header. 0x00 is Variant null, not a Parquet 
null.
+    payloads = [array([b"\x0c\x03", b"\x00", b"\x0c\x04"]), array([]), None,
+                {"metadata": METADATA, "value": b"\x00", "typed_value": None},
+                array([b"\x00"]),
+                {"metadata": METADATA, "value": None, "typed_value": {
+                    "events": None if optional_wrappers else {"value": None, 
"typed_value": None}}}]
+    rows = [{"id": i + 1, "payload": value} for i, value in 
enumerate(payloads)]
+    suffix = "optional" if optional_wrappers else "required"
+    path = Path(__file__).with_name(f"variant_fallback_array_{suffix}.parquet")
+    pq.write_table(pa.Table.from_pylist(rows, schema), path, 
compression="zstd",
+                   row_group_size=2, store_schema=False)
+    data = path.read_bytes()
+    length = struct.unpack("<I", data[-8:-4])[0]
+    footer_start = len(data) - 8 - length
+    metadata = PARQUET.FileMetaData()
+    
metadata.read(TCompactProtocolFactory().get_protocol(TMemoryBuffer(data[footer_start:-8])))
+    # Arrow writes the physical carrier; add the standard logical annotation 
to its footer.
+    payload = next(field for field in metadata.schema if field.name == 
"payload")
+    payload.logicalType = 
PARQUET.LogicalType(VARIANT=PARQUET.VariantType(specification_version=1))
+    transport = TMemoryBuffer()
+    metadata.write(TCompactProtocolFactory().get_protocol(transport))
+    footer = transport.getvalue()
+    path.write_bytes(data[:footer_start] + footer + struct.pack("<I", 
len(footer)) + b"PAR1")
+    assert pq.read_table(path).num_rows == 6
+    print(path.name, path.stat().st_size)
+
+
+if __name__ == "__main__":
+    generate(False)
+    generate(True)
diff --git 
a/regression-test/data/external_table_p0/iceberg/variant_fallback_array_optional.parquet
 
b/regression-test/data/external_table_p0/iceberg/variant_fallback_array_optional.parquet
new file mode 100644
index 00000000000..c0703b34ce1
Binary files /dev/null and 
b/regression-test/data/external_table_p0/iceberg/variant_fallback_array_optional.parquet
 differ
diff --git 
a/regression-test/data/external_table_p0/iceberg/variant_fallback_array_required.parquet
 
b/regression-test/data/external_table_p0/iceberg/variant_fallback_array_required.parquet
new file mode 100644
index 00000000000..2a267c821a1
Binary files /dev/null and 
b/regression-test/data/external_table_p0/iceberg/variant_fallback_array_required.parquet
 differ


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

Reply via email to