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 1bb35373ee0 [fix](be) Prepare Parquet null maps before logical 
conversion (#69448)
1bb35373ee0 is described below

commit 1bb35373ee076af701e9070d472f7ee47073b565
Author: Socrates <[email protected]>
AuthorDate: Fri Oct 23 21:27:29 2026 +0800

    [fix](be) Prepare Parquet null maps before logical conversion (#69448)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #63001
    
    Problem Summary:
    
    A Parquet schema conversion can write past an empty destination null map
    and crash the BE. For example, a TVF infers `BIGINT` from one file and
    then reads `STRING` value `"xx"` from another file.
    `CastStringConverter` marks the failed cast as NULL, but
    `PhysicalToLogicalConverter` currently copies the source null map only
    after that conversion returns.
    
    Prepare the appended destination null-map slice before logical
    conversion. This preserves source NULLs and the existing destination
    prefix, lets converters mark additional failed casts, and retains COW
    isolation. The same boundary also covers other logical conversions that
    produce NULL, such as numeric overflow. Non-nullable conversion errors
    remain unchanged.
    
    Coverage added:
    - Converter unit tests for BYTE_ARRAY and FIXED_LEN_BYTE_ARRAY, nullable
    and non-nullable sources, invalid and valid strings, source NULLs, empty
    destinations, successive append batches, COW aliases, non-nullable
    errors, and numeric overflow.
    - Extend the existing `test_s3_tvf` suite with a mixed-schema Parquet
    regression: generate a BIGINT schema seed plus 4,096 STRING rows
    containing invalid values, numeric strings and source NULLs, then
    materialize all rows with both File Scanner V1 and V2 using
    `batch_size=1024` and non-strict casts. Compare their complete ordered
    results, including NULL positions. This differential check reuses the
    same S3 fixtures and needs no new golden output; fixed expected
    conversion values and null maps remain covered by the converter unit
    tests. Remove the separate crash-only Groovy suite.
    
    ### Release note
    
    Fix a BE crash when Parquet schema conversion produces NULL for an
    invalid value.
    
    ### Check List (For Author)
    
    - Test:
        - [x] Unit tests added; not executed locally.
    - [x] V1/V2 end-to-end differential regression added to the existing S3
    TVF suite; not executed locally.
    - Passed: build hygiene, clang-format 16 formatting/check, `git diff
    --check`, Groovy syntax compilation.
    - `bash run-be-ut.sh --run --filter='ParquetColumnConvertTest.*' -j 4`
    was stopped while downloading the apache-orc submodule. The local
    third-party installation also lacks `libhs.a`; no test executable was
    produced.
    - `./run-regression-test.sh --run -d external_table_p0/tvf -s
    test_s3_tvf` failed before executing any suite: Maven 3.10 rejects
    existing duplicate Gson declarations in the regression framework POM.
    - `run-clang-tidy.sh` could not run because no compilation database was
    generated. Runtime and sanitizer validation remain for CI.
    - Behavior changed:
    - [x] Yes. Invalid nullable conversions produce NULL without accessing
    an unallocated null map.
    - Does this need documentation?
        - [x] No.
    
    <details>
    <summary>Self-review checkpoints</summary>
    
    - Goal: prepares the null map before any logical-converter failure
    write; the new unit cases reproduce that boundary. Runtime validation is
    pending.
    - Scope: one conversion boundary changed, with focused unit and crash
    regression tests.
    - Concurrency and locks: no new shared state or locking; columns belong
    to the reader, and destination aliases are detached using existing COW
    helpers.
    - Lifecycle and static initialization: no new lifetime mechanism or
    static state; source null maps remain independently owned.
    - Configuration: no new configuration; the regression restores all three
    changed session settings (`enable_file_scanner_v2`, `batch_size`,
    `enable_strict_cast`).
    - Compatibility: no symbol, protocol or storage-format changes.
    - Parallel paths: ORC already aligns destination nulls before logical
    conversion; File Scanner V2 uses a separate CAST implementation with
    allocated null maps and is now covered by the same end-to-end
    differential test. Consistent Parquet conversion paths are unchanged.
    - Conditions: no new production conditional branch; existing nullable
    handling is reused.
    - Coverage: source and conversion NULLs, valid values, two physical
    representations, successive batches, COW and non-nullable errors are
    covered; runtime execution is explicitly unverified.
    - Expected results: checked directly in converter unit tests; the
    end-to-end test checks successful materialization and compares complete
    V1/V2 results, including values and NULL positions, over multiple
    batches.
    - Observability: existing non-nullable conversion errors remain
    sufficient; no additional per-row logging is needed.
    - Transactions/persistence: not involved in the production change.
    - Data writes/atomicity: no production write-path changes; the
    regression writes only its unique temporary S3 prefix.
    - FE/BE variables: none added.
    - Performance: the existing null-map copy is moved before conversion,
    retaining linear cost and avoiding a second merge pass.
    - Other issues: no additional source-level issue found in the modified
    path; CI compilation, UT/sanitizer and external regression execution
    remain required.
    
    </details>
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/format/parquet/parquet_column_convert.h     |  10 +-
 .../format/parquet/parquet_column_convert_test.cpp | 114 +++++++++++++++++++++
 .../external_table_p0/tvf/test_s3_tvf.groovy       |  42 ++++++++
 3 files changed, 162 insertions(+), 4 deletions(-)

diff --git a/be/src/format/parquet/parquet_column_convert.h 
b/be/src/format/parquet/parquet_column_convert.h
index 083711a5ce3..3374a3e2dc9 100644
--- a/be/src/format/parquet/parquet_column_convert.h
+++ b/be/src/format/parquet/parquet_column_convert.h
@@ -385,14 +385,16 @@ public:
                        get_appended_null_map_start(src_physical_col, 
src_new_rows));
 
         dst_logical_col = IColumn::mutate(std::move(dst_logical_col));
-        const size_t dst_old_rows = 
get_mutable_inner_column_size(dst_logical_col);
         const size_t dst_old_null_map_size =
                 get_null_map_size_or_inner_column_size(dst_logical_col);
+        const size_t rows_to_convert = 
get_mutable_inner_column_size(src_logical_column);
+        // Logical converters append all source rows and may mark failed casts 
as NULL (e.g.
+        // "xx" -> BIGINT). Copy source nulls first so those writes have 
allocated entries and
+        // are not overwritten after conversion. Keep any existing destination 
prefix intact.
+        align_null_map(src_logical_column, dst_logical_col, 
dst_old_null_map_size, rows_to_convert,
+                       get_appended_null_map_start(src_logical_column, 
rows_to_convert));
         auto converted_column = dst_logical_col->assert_mutable();
         RETURN_IF_ERROR(_logical_converter->convert(src_logical_column, 
converted_column));
-        const size_t dst_new_rows = 
get_mutable_inner_column_size(dst_logical_col) - dst_old_rows;
-        align_null_map(src_logical_column, dst_logical_col, 
dst_old_null_map_size, dst_new_rows,
-                       get_appended_null_map_start(src_logical_column, 
dst_new_rows));
         return Status::OK();
     }
 
diff --git a/be/test/format/parquet/parquet_column_convert_test.cpp 
b/be/test/format/parquet/parquet_column_convert_test.cpp
index 0adc906ea9f..4b1db1c208a 100644
--- a/be/test/format/parquet/parquet_column_convert_test.cpp
+++ b/be/test/format/parquet/parquet_column_convert_test.cpp
@@ -424,6 +424,120 @@ TEST(ParquetColumnConvertTest,
     EXPECT_EQ(original_dst->get_data_at(0).to_string(), "zz");
 }
 
+// Exercise both a direct BYTE_ARRAY source and a physical-to-logical 
temporary for
+// FIXED_LEN_BYTE_ARRAY. Both must prepare destination nulls before string 
casts.
+TEST(ParquetColumnConvertTest, StringCastFailuresPreserveNullsAcrossBatches) {
+    for (const auto physical_type :
+         {tparquet::Type::BYTE_ARRAY, tparquet::Type::FIXED_LEN_BYTE_ARRAY}) {
+        for (const bool nullable_source : {false, true}) {
+            SCOPED_TRACE(testing::Message() << "physical_type=" << 
physical_type
+                                            << ", nullable_source=" << 
nullable_source);
+            FieldSchema field;
+            field.parquet_schema.__set_type(physical_type);
+            field.parquet_schema.__set_type_length(2);
+            field.data_type =
+                    DataTypeFactory::instance().create_data_type(TYPE_STRING, 
nullable_source);
+            const auto dst_type = 
DataTypeFactory::instance().create_data_type(TYPE_BIGINT, true);
+            auto converter = PhysicalToLogicalConverter::get_converter(&field, 
field.data_type,
+                                                                       
dst_type, nullptr);
+            ASSERT_TRUE(converter->support()) << converter->get_error_msg();
+            ColumnPtr dst = dst_type->create_column();
+            const std::vector<std::string> values {"xx", "05", "07", "09"};
+            const std::vector<uint8_t> expected_nulls {1, 0, 0, 0};
+            for (size_t batch = 0; batch < 2; ++batch) {
+                // Retain an alias to ensure preparing the null map respects 
COW and does not
+                // modify an existing batch through another owner.
+                ColumnPtr dst_alias = dst;
+                MutableColumnPtr nested;
+                if (physical_type == tparquet::Type::BYTE_ARRAY) {
+                    nested = ColumnString::create();
+                } else {
+                    nested = ColumnFixedLengthObject::create(2);
+                }
+                auto nulls = ColumnUInt8::create();
+                for (size_t i = 0; i < values.size(); ++i) {
+                    nested->insert_data(values[i].data(), values[i].size());
+                    nulls->insert_value(i == 2);
+                }
+                ColumnPtr src;
+                if (nullable_source) {
+                    src = ColumnNullable::create(std::move(nested), 
std::move(nulls));
+                } else {
+                    src = std::move(nested);
+                }
+                const ColumnPtr src_alias = src;
+                ASSERT_TRUE(converter->convert(src, field.data_type, dst_type, 
dst, false).ok());
+                const auto& nullable = assert_cast<const 
ColumnNullable&>(*dst);
+                const auto& numbers = assert_cast<const 
ColumnInt64&>(nullable.get_nested_column());
+                ASSERT_EQ(nullable.get_null_map_data().size(), (batch + 1) * 
values.size());
+                ASSERT_EQ(numbers.size(), nullable.get_null_map_data().size());
+                for (size_t i = 0; i < numbers.size(); ++i) {
+                    const size_t value_index = i % values.size();
+                    EXPECT_EQ(nullable.get_null_map_data()[i],
+                              expected_nulls[value_index] || (nullable_source 
&& value_index == 2));
+                    if (!nullable.get_null_map_data()[i]) {
+                        EXPECT_EQ(numbers.get_data()[i], 
std::stoi(values[value_index]));
+                    }
+                }
+                EXPECT_EQ(dst_alias->size(), batch * values.size());
+                const auto& original = assert_cast<const 
ColumnNullable&>(*dst_alias);
+                EXPECT_EQ(original.get_nested_column().size(), batch * 
values.size());
+                if (nullable_source) {
+                    const auto& source = assert_cast<const 
ColumnNullable&>(*src_alias);
+                    EXPECT_EQ(source.get_null_map_data()[0], 0);
+                    EXPECT_EQ(source.get_null_map_data()[2], 1);
+                }
+            }
+        }
+    }
+}
+
+TEST(ParquetColumnConvertTest, InvalidStringToNonNullableBigIntReturnsError) {
+    FieldSchema field;
+    field.parquet_schema.__set_type(tparquet::Type::BYTE_ARRAY);
+    field.data_type = 
DataTypeFactory::instance().create_data_type(TYPE_STRING, false);
+    const auto dst_type = 
DataTypeFactory::instance().create_data_type(TYPE_BIGINT, false);
+    auto converter =
+            PhysicalToLogicalConverter::get_converter(&field, field.data_type, 
dst_type, nullptr);
+    ASSERT_TRUE(converter->support()) << converter->get_error_msg();
+    auto strings = ColumnString::create();
+    strings->insert_data("xx", 2);
+    ColumnPtr src = std::move(strings);
+    ColumnPtr dst = dst_type->create_column();
+    auto status = converter->convert(src, field.data_type, dst_type, dst, 
false);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("Failed to cast"), std::string::npos);
+}
+
+TEST(ParquetColumnConvertTest, NumericOverflowMarksNullableDestination) {
+    FieldSchema field;
+    field.parquet_schema.__set_type(tparquet::Type::INT64);
+    field.data_type = 
DataTypeFactory::instance().create_data_type(TYPE_BIGINT, true);
+    const auto dst_type = 
DataTypeFactory::instance().create_data_type(TYPE_FLOAT, true);
+    auto converter =
+            PhysicalToLogicalConverter::get_converter(&field, field.data_type, 
dst_type, nullptr);
+    ASSERT_TRUE(converter->support()) << converter->get_error_msg();
+    auto values = ColumnInt64::create();
+    values->insert_value(1LL << 40);
+    values->insert_value(7);
+    values->insert_value(9);
+    auto nulls = ColumnUInt8::create();
+    nulls->insert_value(0);
+    nulls->insert_value(0);
+    nulls->insert_value(1);
+    ColumnPtr src = ColumnNullable::create(std::move(values), 
std::move(nulls));
+    ColumnPtr dst = dst_type->create_column();
+    ASSERT_TRUE(converter->convert(src, field.data_type, dst_type, dst, 
false).ok());
+    const auto& nullable = assert_cast<const ColumnNullable&>(*dst);
+    ASSERT_EQ(nullable.get_null_map_data().size(), 3);
+    ASSERT_EQ(nullable.get_nested_column().size(), 3);
+    EXPECT_EQ(nullable.get_null_map_data()[0], 1);
+    EXPECT_EQ(nullable.get_null_map_data()[1], 0);
+    EXPECT_EQ(nullable.get_null_map_data()[2], 1);
+    EXPECT_FLOAT_EQ(assert_cast<const 
ColumnFloat32&>(nullable.get_nested_column()).get_data()[1],
+                    7.0F);
+}
+
 TEST(ParquetColumnConvertTest, RejectInvalidUuidPhysicalWidth) {
     FieldSchema field;
     field.parquet_schema.__set_type(tparquet::Type::FIXED_LEN_BYTE_ARRAY);
diff --git a/regression-test/suites/external_table_p0/tvf/test_s3_tvf.groovy 
b/regression-test/suites/external_table_p0/tvf/test_s3_tvf.groovy
index 8ab9793c5d3..5848c396705 100644
--- a/regression-test/suites/external_table_p0/tvf/test_s3_tvf.groovy
+++ b/regression-test/suites/external_table_p0/tvf/test_s3_tvf.groovy
@@ -169,4 +169,46 @@ suite("test_s3_tvf", "p0,external") {
     } finally {
     }
 
+    // Exercise schema conversion with both readers, including failed casts, 
source NULLs and
+    // multiple batches. The BIGINT seed must be listed before the STRING file 
for TVF inference.
+    String castPrefix = 
"s3://${getS3BucketName()}/regression/parquet_invalid_string_cast/${UUID.randomUUID()}"
+    String castStorageProperties = """
+        "s3.endpoint" = "${s3_endpoint}",
+        "s3.region" = "${region}",
+        "s3.access_key" = "${ak}",
+        "s3.secret_key" = "${sk}"
+    """
+    sql """SELECT CAST(1 AS BIGINT) AS id, CAST('seed' AS STRING) AS name
+           INTO OUTFILE "${castPrefix}/00_seed/part_" FORMAT AS parquet
+           PROPERTIES (${castStorageProperties})"""
+    sql """SELECT CAST(CASE number % 4
+                          WHEN 0 THEN 'xx'
+                          WHEN 1 THEN '5'
+                          WHEN 2 THEN NULL
+                          ELSE '9' END AS STRING) AS id,
+                  CAST(number AS STRING) AS name
+           FROM numbers("number" = "4096")
+           INTO OUTFILE "${castPrefix}/01_strings/part_" FORMAT AS parquet
+           PROPERTIES (${castStorageProperties})"""
+
+    def previousCastSettings = sql "SELECT @@enable_file_scanner_v2, 
@@batch_size, @@enable_strict_cast"
+    try {
+        sql "SET batch_size=1024"
+        sql "SET enable_strict_cast=false"
+        // Materialize every value; COUNT(*) could use footer statistics and 
skip conversion.
+        // Compare the actual reader outputs rather than hardcoding expected 
rows in Groovy.
+        def castResults = [false, true].collect { scannerV2 ->
+            sql "SET enable_file_scanner_v2=${scannerV2}"
+            sql """SELECT id, name FROM S3(
+                       "uri" = "${castPrefix}/{00_seed,01_strings}/*.parquet",
+                       "format" = "parquet",
+                       ${castStorageProperties}) ORDER BY name"""
+        }
+        assertEquals(castResults[0], castResults[1],
+                "V1 and V2 must preserve the same values and NULLs across 
mixed-schema batches")
+    } finally {
+        sql "SET enable_file_scanner_v2=${previousCastSettings[0][0]}"
+        sql "SET batch_size=${previousCastSettings[0][1]}"
+        sql "SET enable_strict_cast=${previousCastSettings[0][2]}"
+    }
 }


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

Reply via email to