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

morningman 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 2d530ac6a76 [fix](be) Hash VARBINARY values in shuffle and partition 
exchanges (#69142)
2d530ac6a76 is described below

commit 2d530ac6a76620b9f4bc64d7cac7473b6824a8be
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Oct 16 19:10:53 2026 +0800

    [fix](be) Hash VARBINARY values in shuffle and partition exchanges (#69142)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68921
    
    Problem Summary:
    
    **In short.** `ColumnVarbinary` has no CRC32 / CRC32C hash, so every
    exchange that partitions rows by a VARBINARY value fails. Since #68786
    reads Iceberg BINARY and FIXED as VARBINARY, MERGE INTO an Iceberg table
    with an `identity` or `truncate` partition on a binary column fails, and
    #68921 has to leave INSERT into such a table on random writes. This PR
    hashes VARBINARY values by their bytes, exactly as `ColumnString` does,
    and lets the Iceberg connector route binary partition values on backends
    that can hash them.
    
    **Background.** An exchange that partitions rows hashes the partition
    key of each row with CRC32 (`update_crcs_with_value`,
    `update_crc_with_value`) or CRC32C (`update_crc32c_batch`,
    `update_crc32c_single`): a shuffle join on its keys, an Iceberg INSERT
    routed by the transformed partition value (#68921), and MERGE INTO an
    Iceberg table, which routes the rows it writes through the same Iceberg
    partition function. Array, map and struct keys hash their elements
    through the same methods. `ColumnVarbinary` inherits these methods from
    `IColumn`, where they throw `NOT_IMPLEMENTED_ERROR`.
    
    **The problem, and what it cost.** On a local single-BE cluster with an
    Iceberg REST catalog (tables created by Spark,
    `parallel_pipeline_task_num = 8`):
    
    | Statement | master | with this PR |
    |---|---|---|
    | MERGE INTO a table partitioned by `identity(binary_key)` or
    `truncate(1, binary_key)`, with a `numbers()` source | fails:
    `update_crc32c_batch is not supported for ColumnVarbinary` | succeeds;
    the rows match what Spark reads back |
    | INSERT OVERWRITE of 10000 rows into four `identity(binary_key)`
    partitions from a `numbers()` source | random writes: 8 data files
    (measured with this PR at `be_exec_version` 18, the plan master makes) |
    routed: 4 data files, one per partition |
    | `SELECT ... JOIN [shuffle] ... ON l.a = r.a` on `ARRAY<VARBINARY>`
    keys | fails: `update_crc32c_single is not supported for
    ColumnVarbinary` | same rows as `JOIN [broadcast]` |
    
    A scalar VARBINARY comparison is rejected during analysis, so a scalar
    VARBINARY never becomes a join key; the shuffle join only reaches the
    exchange through a nested type.
    
    **How this PR fixes it.**
    
    - **BE.** `ColumnVarbinary` implements the four CRC methods by hashing
    each value's bytes, with and without a null map, as `ColumnString` does,
    so the same bytes get the same hash in both columns. SipHash and xxHash
    (`update_hash_with_value`, `update_hashes_with_value`,
    `update_xxHash_with_value`) stay unsupported, and so does tablet routing
    of VARBINARY.
    - **Mixed versions.** BE exec version 19 marks the capability and FE
    `max_be_exec_version` moves to 19.
    `IcebergWritePlanProvider.getWriteDistribution` routes a BINARY or FIXED
    transformed partition value only when the statement's exec version is at
    least 19; below it the INSERT keeps random writes, as on master today.
    `bucket(n, binary_key)` yields an INT and is routed from version 18 as
    before. MERGE needs no FE change.
    
    **Results.**
    
    - **BE UT.**
    `ColumnVarbinaryTest.ShuffleHashesMatchStringOfTheSameBytes` checks the
    four methods against `ColumnString` on empty, NUL, invalid UTF-8 and
    inline / out-of-line values, with and without a null map and on a
    sub-range; `ArrayOfVarbinaryHashesLikeArrayOfString` does the same
    through `ColumnArray` with a NULL element; `ValueHashesAreNotSupported`
    keeps SipHash and xxHash unsupported.
    
`ExternalTableSinkBinaryHashPartitionerTest.IcebergTransformHashesBinaryPartitionValues`
    routes `identity` and `truncate[1]` binary values with CRC32 and CRC32C,
    so equal partition values share a channel. On master the first two and
    the partitioner case fail with `NOT_IMPLEMENTED_ERROR`, since the
    methods are inherited from `IColumn`. The three suites (36 cases) pass
    under ASAN; clang-tidy reports nothing on the changed files.
    - **FE UT.**
    
`IcebergWritePlanProviderTest#getWriteDistributionRoutesBinaryPartitionValuesOnlyOnBackendsThatHashThem`:
    identity / truncate on BINARY, identity on FIXED and a mixed INT +
    BINARY spec keep random writes at exec version 18 and are routed at 19;
    `bucket(16, binary_key)` is routed at 18. All 1568 tests of
    fe-connector-iceberg pass.
    - **Regression.** `test_iceberg_write_binary_partitions` now checks the
    data files of the multi-writer INSERT and INSERT OVERWRITE (4 for
    identity, 1 for truncate(1), which folds the keys into one partition)
    and adds MERGE INTO identity / truncate binary partitions, compared with
    Spark. `test_varbinary_sql_support` adds a broadcast and a shuffle join
    on `ARRAY<VARBINARY>` keys with `parallel_pipeline_task_num = 4`; both
    return the same 12 rows, and the shuffle plan hash-partitions both sides
    by the array. Both suites pass on a local single-BE cluster against the
    Iceberg REST docker environment, where their `.out` files were
    generated; only the new blocks were added.
    - **Mixed versions.** On the same cluster, with `be_exec_version` set to
    18 the INSERT OVERWRITE above writes 8 data files (random writes), and
    with 19 it writes 4.
---
 be/src/agent/be_exec_version_manager.cpp           |   4 +-
 be/src/agent/be_exec_version_manager.h             |   1 +
 be/src/core/column/column_varbinary.cpp            |  70 +++++++++++++
 be/src/core/column/column_varbinary.h              |  18 +++-
 be/test/core/column/column_varbinary_test.cpp      | 110 +++++++++++++++++++--
 .../external_table_sink_hash_partitioner_test.cpp  |  72 +++++++++++++-
 .../main/java/org/apache/doris/common/Config.java  |   4 +-
 .../iceberg/IcebergWritePlanProvider.java          |  18 ++--
 .../iceberg/IcebergWritePlanProviderTest.java      |  50 +++++++---
 .../datatype_p0/test_varbinary_sql_support.out     |  28 ++++++
 .../write/test_iceberg_write_binary_partitions.out |  32 ++++++
 .../datatype_p0/test_varbinary_sql_support.groovy  |   9 ++
 .../test_iceberg_write_binary_partitions.groovy    |  37 ++++++-
 13 files changed, 415 insertions(+), 38 deletions(-)

diff --git a/be/src/agent/be_exec_version_manager.cpp 
b/be/src/agent/be_exec_version_manager.cpp
index 960159e8d15..c546960b25d 100644
--- a/be/src/agent/be_exec_version_manager.cpp
+++ b/be/src/agent/be_exec_version_manager.cpp
@@ -158,8 +158,10 @@ void 
BeExecVersionManager::check_function_compatibility(int current_be_exec_vers
 //   a. use exact logical frames for map_agg_v2 aggregate state serialization.
 // 18: start from master
 //   a. support Iceberg partition transform routing in the external sink 
exchange.
+// 19: start from master
+//   a. support hashing VARBINARY values in a shuffle or partition exchange.
 
-const int BeExecVersionManager::max_be_exec_version = 
SUPPORT_ICEBERG_TRANSFORM_ROUTING_VERSION;
+const int BeExecVersionManager::max_be_exec_version = 
SUPPORT_VARBINARY_SHUFFLE_HASH_VERSION;
 const int BeExecVersionManager::min_be_exec_version = 0;
 std::map<std::string, std::set<int>> 
BeExecVersionManager::_function_change_map {};
 std::map<std::string, std::set<int>> 
BeExecVersionManager::_function_alternative_map {};
diff --git a/be/src/agent/be_exec_version_manager.h 
b/be/src/agent/be_exec_version_manager.h
index 53564005f0e..b40933effdd 100644
--- a/be/src/agent/be_exec_version_manager.h
+++ b/be/src/agent/be_exec_version_manager.h
@@ -40,6 +40,7 @@ constexpr inline int SUPPORT_HIVE_OPEN_CSV_VERSION = 15;
 constexpr inline int SUPPORT_PAIMON_WRITE_VERSION = 16;
 constexpr inline int SUPPORT_MAP_AGG_V2_EXACT_FRAME_VERSION = 17;
 constexpr inline int SUPPORT_ICEBERG_TRANSFORM_ROUTING_VERSION = 18;
+constexpr inline int SUPPORT_VARBINARY_SHUFFLE_HASH_VERSION = 19;
 
 class BeExecVersionManager {
 public:
diff --git a/be/src/core/column/column_varbinary.cpp 
b/be/src/core/column/column_varbinary.cpp
index 71dd2f91e6b..d393a6f3d91 100644
--- a/be/src/core/column/column_varbinary.cpp
+++ b/be/src/core/column/column_varbinary.cpp
@@ -17,6 +17,7 @@
 
 #include "core/column/column_varbinary.h"
 
+#include <crc32c/crc32c.h>
 #include <glog/logging.h>
 
 #include <cstddef>
@@ -29,6 +30,7 @@
 #include "core/column/columns_common.h"
 #include "core/data_type/primitive_type.h"
 #include "exec/sort/sort_block.h"
+#include "util/hash_util.hpp"
 
 namespace doris {
 
@@ -192,6 +194,74 @@ size_t ColumnVarbinary::get_max_row_byte_size() const {
     return max_size + sizeof(uint32_t);
 }
 
+void ColumnVarbinary::update_crcs_with_value(uint32_t* __restrict hashes, 
PrimitiveType /*type*/,
+                                             uint32_t rows, uint32_t 
/*offset*/,
+                                             const uint8_t* __restrict 
null_data) const {
+    DCHECK(rows == size());
+    if (null_data == nullptr) {
+        for (size_t i = 0; i < rows; ++i) {
+            hashes[i] = HashUtil::zlib_crc_hash(_data[i].data(), 
_data[i].size(), hashes[i]);
+        }
+    } else {
+        for (size_t i = 0; i < rows; ++i) {
+            if (null_data[i] == 0) {
+                hashes[i] = HashUtil::zlib_crc_hash(_data[i].data(), 
_data[i].size(), hashes[i]);
+            }
+        }
+    }
+}
+
+void ColumnVarbinary::update_crc_with_value(size_t start, size_t end, 
uint32_t& hash,
+                                            const uint8_t* __restrict 
null_data) const {
+    if (null_data == nullptr) {
+        for (size_t i = start; i < end; ++i) {
+            hash = HashUtil::zlib_crc_hash(_data[i].data(), _data[i].size(), 
hash);
+        }
+    } else {
+        for (size_t i = start; i < end; ++i) {
+            if (null_data[i] == 0) {
+                hash = HashUtil::zlib_crc_hash(_data[i].data(), 
_data[i].size(), hash);
+            }
+        }
+    }
+}
+
+void ColumnVarbinary::update_crc32c_batch(uint32_t* __restrict hashes,
+                                          const uint8_t* __restrict null_map) 
const {
+    const size_t rows = size();
+    if (null_map == nullptr) {
+        for (size_t i = 0; i < rows; ++i) {
+            hashes[i] = crc32c_extend(hashes[i], reinterpret_cast<const 
uint8_t*>(_data[i].data()),
+                                      _data[i].size());
+        }
+    } else {
+        for (size_t i = 0; i < rows; ++i) {
+            if (null_map[i] == 0) {
+                hashes[i] =
+                        crc32c_extend(hashes[i], reinterpret_cast<const 
uint8_t*>(_data[i].data()),
+                                      _data[i].size());
+            }
+        }
+    }
+}
+
+void ColumnVarbinary::update_crc32c_single(size_t start, size_t end, uint32_t& 
hash,
+                                           const uint8_t* __restrict null_map) 
const {
+    if (null_map == nullptr) {
+        for (size_t i = start; i < end; ++i) {
+            hash = crc32c_extend(hash, reinterpret_cast<const 
uint8_t*>(_data[i].data()),
+                                 _data[i].size());
+        }
+    } else {
+        for (size_t i = start; i < end; ++i) {
+            if (null_map[i] == 0) {
+                hash = crc32c_extend(hash, reinterpret_cast<const 
uint8_t*>(_data[i].data()),
+                                     _data[i].size());
+            }
+        }
+    }
+}
+
 void ColumnVarbinary::serialize(StringRef* keys, size_t num_rows) const {
     for (size_t i = 0; i < num_rows; ++i) {
         keys[i].size += serialize_impl(const_cast<char*>(keys[i].data + 
keys[i].size), i);
diff --git a/be/src/core/column/column_varbinary.h 
b/be/src/core/column/column_varbinary.h
index 2efc8da06fd..455c04eb632 100644
--- a/be/src/core/column/column_varbinary.h
+++ b/be/src/core/column/column_varbinary.h
@@ -30,7 +30,10 @@
 #include "core/string_view.h"
 
 namespace doris {
-// Binary IO does not enable hash computation; inherit IColumn's unsupported 
methods.
+// An exchange that partitions rows by a VARBINARY value, such as a shuffle 
join on ARRAY<VARBINARY>
+// keys or an Iceberg write routed by identity(binary_col), hashes the value's 
bytes with the
+// CRC32 / CRC32C methods, exactly as ColumnString does: X'616263' and 'abc' 
get the same hash.
+// The value hashes (SipHash, xxHash) are inherited from IColumn and stay 
unsupported.
 class ColumnVarbinary final : public COWHelper<IColumn, ColumnVarbinary> {
 private:
     using Self = ColumnVarbinary;
@@ -123,6 +126,19 @@ public:
 
     size_t get_max_row_byte_size() const override;
 
+    void update_crcs_with_value(uint32_t* __restrict hashes, PrimitiveType 
type, uint32_t rows,
+                                uint32_t offset,
+                                const uint8_t* __restrict null_data) const 
override;
+
+    void update_crc_with_value(size_t start, size_t end, uint32_t& hash,
+                               const uint8_t* __restrict null_data) const 
override;
+
+    void update_crc32c_batch(uint32_t* __restrict hashes,
+                             const uint8_t* __restrict null_map) const 
override;
+
+    void update_crc32c_single(size_t start, size_t end, uint32_t& hash,
+                              const uint8_t* __restrict null_map) const 
override;
+
     void deserialize(StringRef* keys, const size_t num_rows) override;
 
     void serialize(StringRef* keys, const size_t num_rows) const override;
diff --git a/be/test/core/column/column_varbinary_test.cpp 
b/be/test/core/column/column_varbinary_test.cpp
index 20e07dfa8af..1aae5ec57ad 100644
--- a/be/test/core/column/column_varbinary_test.cpp
+++ b/be/test/core/column/column_varbinary_test.cpp
@@ -26,12 +26,16 @@
 #include <cstring>
 #include <iostream>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include "common/exception.h"
 #include "core/assert_cast.h"
 #include "core/column/column.h"
+#include "core/column/column_array.h"
+#include "core/column/column_nullable.h"
 #include "core/column/column_string.h"
+#include "core/column/column_vector.h"
 #include "core/data_type/primitive_type.h"
 #include "core/string_ref.h"
 #include "core/string_view.h"
@@ -250,21 +254,113 @@ TEST_F(ColumnVarbinaryTest, 
TabletRoutingHashIsNotSupported) {
     }
 }
 
-TEST_F(ColumnVarbinaryTest, HashingIsNotSupported) {
+TEST_F(ColumnVarbinaryTest, ShuffleHashesMatchStringOfTheSameBytes) {
+    // An exchange routes a VARBINARY value by its bytes, as it routes a 
STRING. Empty, NUL and
+    // invalid UTF-8 values, and values on both sides of the inline size, must 
hash like the same
+    // bytes in a ColumnString.
+    const std::vector<std::string> values = {"",
+                                             std::string("\0\xff", 2),
+                                             "abc",
+                                             
make_bytes(doris::StringView::kInlineSize, 0x11),
+                                             
make_bytes(doris::StringView::kInlineSize + 1, 0x22),
+                                             make_bytes(40, 0x80)};
+    const std::vector<uint8_t> null_map = {0, 1, 0, 0, 1, 0};
+    auto binary = ColumnVarbinary::create();
+    auto string = ColumnString::create();
+    for (const auto& value : values) {
+        binary->insert_data(value.data(), value.size());
+        string->insert_data(value.data(), value.size());
+    }
+    const auto rows = static_cast<uint32_t>(values.size());
+    // Distinct seeds, so a method that ignored the bytes or the row would not 
match by accident.
+    auto seeds = [&]() {
+        std::vector<uint32_t> hashes(rows);
+        for (uint32_t i = 0; i < rows; ++i) {
+            hashes[i] = 7 * i + 1;
+        }
+        return hashes;
+    };
+
+    for (const uint8_t* nulls : {static_cast<const uint8_t*>(nullptr), 
null_map.data()}) {
+        SCOPED_TRACE(nulls == nullptr ? "without null map" : "with null map");
+        auto binary_crcs = seeds();
+        auto string_crcs = seeds();
+        binary->update_crcs_with_value(binary_crcs.data(), TYPE_VARBINARY, 
rows, 0, nulls);
+        string->update_crcs_with_value(string_crcs.data(), TYPE_STRING, rows, 
0, nulls);
+        EXPECT_EQ(binary_crcs, string_crcs);
+        EXPECT_NE(binary_crcs, seeds());
+
+        auto binary_crc32cs = seeds();
+        auto string_crc32cs = seeds();
+        binary->update_crc32c_batch(binary_crc32cs.data(), nulls);
+        string->update_crc32c_batch(string_crc32cs.data(), nulls);
+        EXPECT_EQ(binary_crc32cs, string_crc32cs);
+        EXPECT_NE(binary_crc32cs, seeds());
+
+        for (const auto& [start, end] :
+             std::vector<std::pair<size_t, size_t>> {{0, rows}, {1, rows - 
1}}) {
+            uint32_t binary_crc = 23;
+            uint32_t string_crc = 23;
+            binary->update_crc_with_value(start, end, binary_crc, nulls);
+            string->update_crc_with_value(start, end, string_crc, nulls);
+            EXPECT_EQ(binary_crc, string_crc);
+            EXPECT_NE(binary_crc, 23);
+
+            uint32_t binary_crc32c = 29;
+            uint32_t string_crc32c = 29;
+            binary->update_crc32c_single(start, end, binary_crc32c, nulls);
+            string->update_crc32c_single(start, end, string_crc32c, nulls);
+            EXPECT_EQ(binary_crc32c, string_crc32c);
+            EXPECT_NE(binary_crc32c, 29);
+        }
+    }
+}
+
+TEST_F(ColumnVarbinaryTest, ArrayOfVarbinaryHashesLikeArrayOfString) {
+    // A shuffle join on ARRAY<VARBINARY> keys hashes the array elements 
through
+    // update_crc32c_single.
+    // Rows: [X'01', X'0203'], [], [NULL, X'01'].
+    auto make_array = [](MutableColumnPtr nested) {
+        nested->insert_data("\x01", 1);
+        nested->insert_data("\x02\x03", 2);
+        nested->insert_default();
+        nested->insert_data("\x01", 1);
+        auto null_map = ColumnUInt8::create();
+        for (uint8_t is_null : {0, 0, 1, 0}) {
+            null_map->insert_value(is_null);
+        }
+        auto offsets = ColumnArray::ColumnOffsets::create();
+        for (uint64_t offset : {2, 2, 4}) {
+            offsets->insert_value(offset);
+        }
+        return ColumnArray::create(ColumnNullable::create(std::move(nested), 
std::move(null_map)),
+                                   std::move(offsets));
+    };
+    auto binary = make_array(ColumnVarbinary::create());
+    auto string = make_array(ColumnString::create());
+
+    std::vector<uint32_t> binary_crc32cs(3, 5);
+    std::vector<uint32_t> string_crc32cs(3, 5);
+    binary->update_crc32c_batch(binary_crc32cs.data(), nullptr);
+    string->update_crc32c_batch(string_crc32cs.data(), nullptr);
+    EXPECT_EQ(binary_crc32cs, string_crc32cs);
+
+    std::vector<uint32_t> binary_crcs(3, 5);
+    std::vector<uint32_t> string_crcs(3, 5);
+    binary->update_crcs_with_value(binary_crcs.data(), TYPE_ARRAY, 3, 0, 
nullptr);
+    string->update_crcs_with_value(string_crcs.data(), TYPE_ARRAY, 3, 0, 
nullptr);
+    EXPECT_EQ(binary_crcs, string_crcs);
+}
+
+TEST_F(ColumnVarbinaryTest, ValueHashesAreNotSupported) {
     auto binary = ColumnVarbinary::create();
     binary->insert_data("\0\xff", 2);
     SipHash sip;
     uint64_t hash64 = 17;
-    uint32_t hash32 = 23;
     EXPECT_THROW(binary->update_hash_with_value(0, sip), Exception);
     EXPECT_THROW(binary->update_hashes_with_value(&hash64, nullptr), 
Exception);
     EXPECT_THROW(binary->update_xxHash_with_value(0, 1, hash64, nullptr), 
Exception);
-    EXPECT_THROW(binary->update_crcs_with_value(&hash32, TYPE_VARBINARY, 1, 0, 
nullptr), Exception);
-    EXPECT_THROW(binary->update_crc_with_value(0, 1, hash32, nullptr), 
Exception);
-    EXPECT_THROW(binary->update_crc32c_batch(&hash32, nullptr), Exception);
-    EXPECT_THROW(binary->update_crc32c_single(0, 1, hash32, nullptr), 
Exception);
     EXPECT_EQ(hash64, 17);
-    EXPECT_EQ(hash32, 23);
 }
 
 TEST_F(ColumnVarbinaryTest, InsertFromAndRanges) {
diff --git 
a/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp 
b/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
index c28e9a31eda..fdfb6592c1e 100644
--- a/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
+++ b/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
@@ -29,8 +29,10 @@
 
 #include "common/object_pool.h"
 #include "core/block/block.h"
+#include "core/column/column_varbinary.h"
 #include "core/column/column_vector.h"
 #include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_varbinary.h"
 #include "exec/partitioner/external/paimon_fixed_bucket_partition_function.h"
 #include "runtime/descriptor_helper.h"
 #include "runtime/descriptors.h"
@@ -40,11 +42,13 @@ namespace doris {
 
 class ExternalTableSinkHashPartitionerTest : public testing::Test {
 protected:
+    virtual PrimitiveType key_type() const { return TYPE_INT; }
+
     void SetUp() override {
         TDescriptorTableBuilder table_builder;
         TTupleDescriptorBuilder tuple_builder;
         tuple_builder.add_slot(TSlotDescriptorBuilder()
-                                       .type(TYPE_INT)
+                                       .type(key_type())
                                        .nullable(false)
                                        .column_name("key")
                                        .column_pos(1)
@@ -65,7 +69,7 @@ protected:
         TExprNode node;
         node.__set_node_type(TExprNodeType::SLOT_REF);
         node.__set_num_children(0);
-        node.__set_type(create_type_desc(TYPE_INT));
+        node.__set_type(create_type_desc(key_type()));
         node.__set_is_nullable(false);
 
         TSlotRef slot_ref;
@@ -236,6 +240,70 @@ TEST_F(ExternalTableSinkHashPartitionerTest, 
IcebergUnsupportedTransformFailsClo
               std::string::npos);
 }
 
+class ExternalTableSinkBinaryHashPartitionerTest : public 
ExternalTableSinkHashPartitionerTest {
+protected:
+    PrimitiveType key_type() const override { return TYPE_VARBINARY; }
+
+    Block binary_block(const std::vector<std::string>& values) const {
+        auto column = ColumnVarbinary::create();
+        for (const auto& value : values) {
+            column->insert_data(value.data(), value.size());
+        }
+        Block block;
+        block.insert(ColumnWithTypeAndName(std::move(column), 
std::make_shared<DataTypeVarbinary>(),
+                                           "key"));
+        return block;
+    }
+
+    std::vector<PartitionerBase::HashValType> channels(const std::string& 
transform,
+                                                       ShuffleHashMethod 
method,
+                                                       const 
std::vector<std::string>& values) {
+        TExternalTableSinkHashPartitionInfo info;
+        info.__set_partition_function("iceberg_transform");
+        info.__set_partition_function_options({{"transforms", transform}});
+        
info.__set_writer_assignment(TExternalTableSinkWriterAssignment::IDENTITY);
+        ExternalTableSinkHashPartitioner partitioner(64, method, info);
+        EXPECT_TRUE(partitioner.init({slot_ref()}).ok());
+        EXPECT_TRUE(partitioner.prepare(&_state, *_row_descriptor).ok());
+        EXPECT_TRUE(partitioner.open(&_state).ok());
+        Block input = binary_block(values);
+        Status status = partitioner.do_partitioning(&_state, &input);
+        EXPECT_TRUE(status.ok()) << status.to_string();
+        std::vector<PartitionerBase::HashValType> 
result(partitioner.get_channel_ids().begin(),
+                                                         
partitioner.get_channel_ids().end());
+        EXPECT_TRUE(partitioner.close(&_state).ok());
+        return result;
+    }
+};
+
+TEST_F(ExternalTableSinkBinaryHashPartitionerTest, 
IcebergTransformHashesBinaryPartitionValues) {
+    // identity(binary_key) and truncate(1, binary_key) route each row by a 
VARBINARY partition
+    // value, which the exchange hashes by its bytes. A backend without 
VARBINARY shuffle hashes
+    // failed the write with "update_crcs_with_value is not supported for 
ColumnVarbinary".
+    const std::string long_value(20, '\x7f');
+    for (ShuffleHashMethod method : {ShuffleHashMethod::CRC32, 
ShuffleHashMethod::CRC32C}) {
+        SCOPED_TRACE(method == ShuffleHashMethod::CRC32 ? "CRC32" : "CRC32C");
+        // identity: equal bytes share a channel, including the empty value 
and a value past the
+        // inline size.
+        auto identity = channels("identity", method,
+                                 {std::string("\x00\xff", 2), "", long_value,
+                                  std::string("\x00\xff", 2), "", long_value});
+        ASSERT_EQ(6, identity.size());
+        EXPECT_EQ(identity[0], identity[3]);
+        EXPECT_EQ(identity[1], identity[4]);
+        EXPECT_EQ(identity[2], identity[5]);
+
+        // truncate[1]: X'00FF' and X'0001' share the partition X'00', 'a' and 
'ab' the
+        // partition 'a'.
+        auto truncated =
+                channels("truncate[1]", method,
+                         {std::string("\x00\xff", 2), std::string("\x00\x01", 
2), "a", "ab"});
+        ASSERT_EQ(4, truncated.size());
+        EXPECT_EQ(truncated[0], truncated[1]);
+        EXPECT_EQ(truncated[2], truncated[3]);
+    }
+}
+
 TEST_F(ExternalTableSinkHashPartitionerTest, 
PaimonFixedBucketRejectsMissingMetadata) {
     TExternalTableSinkHashPartitionInfo info;
     info.__set_partition_function("paimon_fixed_bucket");
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java 
b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 7a2df0152c4..aea436e5505 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -2030,9 +2030,11 @@ public class Config extends ConfigBase {
     public static final int MAP_AGG_V2_EXACT_FRAME_MIN_BE_EXEC_VERSION = 17;
     // Older backends reject the iceberg_transform external sink partition 
function.
     public static final int ICEBERG_TRANSFORM_ROUTING_MIN_BE_EXEC_VERSION = 18;
+    // Older backends cannot hash VARBINARY values in a shuffle or partition 
exchange.
+    public static final int VARBINARY_SHUFFLE_HASH_MIN_BE_EXEC_VERSION = 19;
 
     @ConfField(mutable = false)
-    public static int max_be_exec_version = 
ICEBERG_TRANSFORM_ROUTING_MIN_BE_EXEC_VERSION;
+    public static int max_be_exec_version = 
VARBINARY_SHUFFLE_HASH_MIN_BE_EXEC_VERSION;
 
     /**
      * Min data version of backends serialize block.
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index 5a06993830c..d929c5938cd 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -114,6 +114,8 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
     private static final int SUPPORT_NESTED_PARTITION_WRITE_EXEC_VERSION = 12;
     // Older backends reject the iceberg_transform external sink partition 
function.
     private static final int SUPPORT_TRANSFORM_ROUTING_EXEC_VERSION = 18;
+    // Older backends cannot hash a BINARY or FIXED value (ColumnVarbinary) in 
the exchange.
+    private static final int SUPPORT_BINARY_ROUTING_EXEC_VERSION = 19;
     private static final String TRANSFORM_PARTITION_FUNCTION = 
"iceberg_transform";
 
     // Legacy IcebergUtils compression-codec property keys (connector-local 
copies; iceberg SDK has no
@@ -626,8 +628,8 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
      * {@code table_sink_partition_write_max_partition_nums_per_writer} open 
partitions.
      *
      * <p>{@code null} keeps the engine's random parallel write: an 
unpartitioned table, a partition field on a nested
-     * source (the route columns are top-level sink outputs), a partition 
value the backend cannot hash (see the
-     * loop), or a backend older than the routing function.</p>
+     * source (the route columns are top-level sink outputs), a BINARY or 
FIXED partition value on a backend that
+     * cannot hash it (see the loop), or a backend older than the routing 
function.</p>
      */
     @Override
     public ConnectorWriteDistribution getWriteDistribution(ConnectorSession 
session,
@@ -654,12 +656,14 @@ public class IcebergWritePlanProvider implements 
ConnectorWritePlanProvider {
             if (sourceColumnName == null) {
                 return null;
             }
-            // The exchange hashes the transformed value, and the BE column of 
a BINARY or FIXED value
-            // (ColumnVarbinary) has no shuffle hash: identity(binary_key) or 
truncate(1, binary_key) would fail the
-            // INSERT with "update_crc32c_batch is not supported for 
ColumnVarbinary" as soon as it has two writers
-            // to route to. bucket(16, binary_key) yields an INT and is routed.
+            // The exchange hashes the transformed value. A backend older than 
SUPPORT_BINARY_ROUTING_EXEC_VERSION
+            // cannot hash the BE column of a BINARY or FIXED value 
(ColumnVarbinary): identity(binary_key) or
+            // truncate(1, binary_key) would fail the INSERT with 
"update_crc32c_batch is not supported for
+            // ColumnVarbinary" as soon as it has two writers to route to. 
bucket(16, binary_key) yields an INT and
+            // is routed by any backend that routes transforms.
             Type.TypeID transformedType = 
field.transform().getResultType(schema.findType(field.sourceId())).typeId();
-            if (transformedType == Type.TypeID.BINARY || transformedType == 
Type.TypeID.FIXED) {
+            if ((transformedType == Type.TypeID.BINARY || transformedType == 
Type.TypeID.FIXED)
+                    && session.getBeExecVersion() < 
SUPPORT_BINARY_ROUTING_EXEC_VERSION) {
                 return null;
             }
             routeColumns.add(sourceColumnName);
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index b265391bb17..621ef24ef79 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -1582,34 +1582,52 @@ public class IcebergWritePlanProviderTest {
     }
 
     @Test
-    public void 
getWriteDistributionKeepsRandomWritesForBinaryPartitionValues() {
-        // The exchange hashes the transformed value, and the BE column of a 
BINARY or FIXED value has no shuffle
-        // hash: routing identity(binary_key) fails the INSERT once it has two 
writers. bucket() turns the bytes
-        // into an INT, so that one is routed. MUTATION: dropping the 
transformed-type check -> the first four
-        // tables route -> red.
+    public void 
getWriteDistributionRoutesBinaryPartitionValuesOnlyOnBackendsThatHashThem() {
+        // The exchange hashes the transformed value. A backend below exec 
version 19 cannot hash the BE column of a
+        // BINARY or FIXED value, so routing identity(binary_key) there fails 
the INSERT once it has two writers;
+        // such a write keeps random writes. bucket() turns the bytes into an 
INT, so it is routed from version 18.
+        // MUTATION: dropping the version check -> identity_binary routes at 
18 -> red; skipping BINARY and FIXED
+        // unconditionally -> nothing routes at 19 -> red.
         InMemoryCatalog catalog = freshCatalog();
         Schema schema = new Schema(
                 Types.NestedField.required(1, "id", Types.IntegerType.get()),
                 Types.NestedField.optional(2, "binary_key", 
Types.BinaryType.get()),
                 Types.NestedField.optional(3, "fixed_key", 
Types.FixedType.ofLength(4)));
-        Map<String, PartitionSpec> unroutable = new LinkedHashMap<>();
-        unroutable.put("identity_binary", 
PartitionSpec.builderFor(schema).identity("binary_key").build());
-        unroutable.put("truncate_binary", 
PartitionSpec.builderFor(schema).truncate("binary_key", 1).build());
-        unroutable.put("identity_fixed", 
PartitionSpec.builderFor(schema).identity("fixed_key").build());
-        // One unhashable field is enough: the route hashes every field of the 
spec together.
-        unroutable.put("int_and_binary",
+        Map<String, PartitionSpec> binaryValued = new LinkedHashMap<>();
+        binaryValued.put("identity_binary", 
PartitionSpec.builderFor(schema).identity("binary_key").build());
+        binaryValued.put("truncate_binary", 
PartitionSpec.builderFor(schema).truncate("binary_key", 1).build());
+        binaryValued.put("identity_fixed", 
PartitionSpec.builderFor(schema).identity("fixed_key").build());
+        // One binary-valued field is enough: the route hashes every field of 
the spec together.
+        binaryValued.put("int_and_binary",
                 
PartitionSpec.builderFor(schema).identity("id").identity("binary_key").build());
-        for (Map.Entry<String, PartitionSpec> entry : unroutable.entrySet()) {
-            Table table = catalog.createTable(TableIdentifier.of("db1", 
entry.getKey()), schema, entry.getValue());
+        Map<String, List<String>> routeColumns = new LinkedHashMap<>();
+        routeColumns.put("identity_binary", 
Collections.singletonList("binary_key"));
+        routeColumns.put("truncate_binary", 
Collections.singletonList("binary_key"));
+        routeColumns.put("identity_fixed", 
Collections.singletonList("fixed_key"));
+        routeColumns.put("int_and_binary", Arrays.asList("id", "binary_key"));
+        Map<String, String> transforms = new LinkedHashMap<>();
+        transforms.put("identity_binary", "identity");
+        transforms.put("truncate_binary", "truncate[1]");
+        transforms.put("identity_fixed", "identity");
+        transforms.put("int_and_binary", "identity,identity");
+        for (Map.Entry<String, PartitionSpec> entry : binaryValued.entrySet()) 
{
+            String name = entry.getKey();
+            Table table = catalog.createTable(TableIdentifier.of("db1", name), 
schema, entry.getValue());
+            IcebergTableHandle handle = new IcebergTableHandle("db1", name);
             Assertions.assertNull(providerFor(table, 
contextWithStorage()).getWriteDistribution(
-                    sessionFor(table, contextWithStorage()), new 
IcebergTableHandle("db1", entry.getKey())),
-                    entry.getKey());
+                    sessionFor(table, contextWithStorage()).beExecVersion(18), 
handle), name);
+            ConnectorWriteDistribution distribution = providerFor(table, 
contextWithStorage()).getWriteDistribution(
+                    sessionFor(table, contextWithStorage()).beExecVersion(19), 
handle);
+            Assertions.assertEquals(routeColumns.get(name), 
distribution.getRouteColumns(), name);
+            Assertions.assertEquals(Collections.singletonMap("transforms", 
transforms.get(name)),
+                    distribution.getPartitionFunctionOptions(), name);
         }
 
         Table bucketed = catalog.createTable(TableIdentifier.of("db1", 
"bucket_binary"), schema,
                 PartitionSpec.builderFor(schema).bucket("binary_key", 
16).build());
         ConnectorWriteDistribution distribution = providerFor(bucketed, 
contextWithStorage()).getWriteDistribution(
-                sessionFor(bucketed, contextWithStorage()), new 
IcebergTableHandle("db1", "bucket_binary"));
+                sessionFor(bucketed, contextWithStorage()).beExecVersion(18),
+                new IcebergTableHandle("db1", "bucket_binary"));
         Assertions.assertEquals(Collections.singletonList("binary_key"), 
distribution.getRouteColumns());
         Assertions.assertEquals(Collections.singletonMap("transforms", 
"bucket[16]"),
                 distribution.getPartitionFunctionOptions());
diff --git a/regression-test/data/datatype_p0/test_varbinary_sql_support.out 
b/regression-test/data/datatype_p0/test_varbinary_sql_support.out
index a14762b7895..6ce2ab246ad 100644
--- a/regression-test/data/datatype_p0/test_varbinary_sql_support.out
+++ b/regression-test/data/datatype_p0/test_varbinary_sql_support.out
@@ -50,6 +50,34 @@ payload      varbinary(2147483647)   Yes     false   \N
 8      AB      AB      AB
 9      FF      FF      FF
 
+-- !array_key_join_broadcast --
+0      0
+1      1
+2      2
+3      3
+4      4
+5      5
+5      8
+6      6
+7      7
+8      5
+8      8
+9      9
+
+-- !array_key_join_shuffle --
+0      0
+1      1
+2      2
+3      3
+4      4
+5      5
+5      8
+6      6
+7      7
+8      5
+8      8
+9      9
+
 -- !long_bytes --
 2048   100     2147    2048
 
diff --git 
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
index e60f362fd80..3f1f26616e0 100644
--- 
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
+++ 
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
@@ -281,21 +281,53 @@
 3132   2500    2       9998
 3133   2500    3       9999
 
+-- !binary_multi_writer_identity_INSERT_INTO_files --
+4
+
 -- !binary_multi_writer_identity_INSERT_OVERWRITE_TABLE --
 3130   2500    0       9996
 3131   2500    1       9997
 3132   2500    2       9998
 3133   2500    3       9999
 
+-- !binary_multi_writer_identity_INSERT_OVERWRITE_TABLE_files --
+4
+
 -- !binary_multi_writer_truncate_INSERT_INTO --
 3130   2500    0       9996
 3131   2500    1       9997
 3132   2500    2       9998
 3133   2500    3       9999
 
+-- !binary_multi_writer_truncate_INSERT_INTO_files --
+1
+
 -- !binary_multi_writer_truncate_INSERT_OVERWRITE_TABLE --
 3130   2500    0       9996
 3131   2500    1       9997
 3132   2500    2       9998
 3133   2500    3       9999
 
+-- !binary_multi_writer_truncate_INSERT_OVERWRITE_TABLE_files --
+1
+
+-- !binary_merge_identity --
+3130   inserted        2475    100     9996
+3131   inserted        2475    101     9997
+3132   inserted        2475    102     9998
+3133   inserted        2475    103     9999
+3230   updated 25      0       96
+3231   updated 25      1       97
+3232   updated 25      2       98
+3233   updated 25      3       99
+
+-- !binary_merge_truncate --
+3130   inserted        2475    100     9996
+3131   inserted        2475    101     9997
+3132   inserted        2475    102     9998
+3133   inserted        2475    103     9999
+3230   updated 25      0       96
+3231   updated 25      1       97
+3232   updated 25      2       98
+3233   updated 25      3       99
+
diff --git 
a/regression-test/suites/datatype_p0/test_varbinary_sql_support.groovy 
b/regression-test/suites/datatype_p0/test_varbinary_sql_support.groovy
index 7715690cd70..86463e59e7d 100644
--- a/regression-test/suites/datatype_p0/test_varbinary_sql_support.groovy
+++ b/regression-test/suites/datatype_p0/test_varbinary_sql_support.groovy
@@ -60,6 +60,15 @@ suite("test_varbinary_sql_support") {
     order_qt_nested_bytes """SELECT id, from_binary(a[1]), 
from_binary(m['key']), from_binary(s.b)
                              FROM binary_sql_nested"""
 
+    // A shuffle join on ARRAY<VARBINARY> keys hashes the elements' bytes in 
the exchange, and must match the same
+    // rows as a broadcast join, which hashes no key in an exchange: ids 5 and 
8 hold the same payload. Several
+    // instances keep the exchange hashing; with one destination it sends 
every row there without hashing.
+    sql "set parallel_pipeline_task_num = 4"
+    for (String distribution : ["broadcast", "shuffle"]) {
+        "order_qt_array_key_join_${distribution}" """SELECT l.id, r.id FROM 
binary_sql_nested l
+                JOIN [${distribution}] binary_sql_nested r ON l.a = r.a"""
+    }
+
     // Compare every long value while keeping generated output compact across 
source batches.
     sql """INSERT INTO binary_sql_input SELECT number + 100,
            concat(repeat('FF', 700), '00AB') FROM numbers('number'='2048')"""
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
index 79995932ee4..88e9f47102a 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
@@ -94,9 +94,11 @@ suite("test_iceberg_write_binary_partitions", 
"p0,external,iceberg,external_dock
         }
 
         // The VALUES writes above have a single-instance source, so their 
sink has one writer and its exchange
-        // does not hash. A multi-instance source reaches several writers 
through a hashing exchange, which must
-        // not route by a binary partition value: the BE cannot hash it 
("update_crc32c_batch is not supported
-        // for ColumnVarbinary"). Pin the writers, since a fuzzy session can 
pick one.
+        // does not hash. A multi-instance source reaches several writers 
through an exchange that routes each row
+        // by its binary partition value, hashing the value's bytes, so one 
writer owns each partition and writes
+        // its only data file: four files for identity, one for truncate(1), 
which folds the keys '10' to '13' into
+        // the partition '1'. Without routing, each of the eight writers 
writes a file for every partition it
+        // receives. Pin the writers, since a fuzzy session can pick one.
         sql "set parallel_pipeline_task_num = 8"
         for (String transform : ["identity", "truncate"]) {
             String table = "binary_multi_writer_${transform}"
@@ -110,8 +112,37 @@ suite("test_iceberg_write_binary_partitions", 
"p0,external,iceberg,external_dock
                     SELECT number, CAST(CAST(number % 4 + 10 AS STRING) AS 
VARBINARY) FROM numbers("number" = "10000")"""
                 "order_qt_${table}_${operation.replace(' ', '_')}" """
                     SELECT HEX(binary_key), count(*), min(id), max(id) FROM 
${table} GROUP BY HEX(binary_key) ORDER BY 1"""
+                "qt_${table}_${operation.replace(' ', '_')}_files" "SELECT 
count(*) FROM ${table}\$files"
             }
         }
+
+        // MERGE routes the rows it writes by their binary partition value 
through the same exchange hash, so it
+        // failed with "update_crc32c_batch is not supported for 
ColumnVarbinary" before VARBINARY could be hashed.
+        // Rows 0 to 99 move from the keys '10'..'13' to '20'..'23', and rows 
100 to 9999 are inserted.
+        for (String transform : ["identity", "truncate"]) {
+            String table = "binary_merge_${transform}"
+            String expression = transform == "identity" ? "binary_key" : 
"truncate(1, binary_key)"
+            sql "DROP TABLE IF EXISTS ${table}"
+            spark_iceberg """CREATE TABLE demo.${dbName}.${table} (id BIGINT, 
binary_key BINARY, v STRING)
+                USING iceberg PARTITIONED BY (${expression})
+                TBLPROPERTIES ('format-version'='2', 
'write.format.default'='parquet',
+                    'write.delete.mode'='merge-on-read', 
'write.update.mode'='merge-on-read',
+                    'write.merge.mode'='merge-on-read')"""
+            sql """INSERT INTO ${table}
+                SELECT number, CAST(CAST(number % 4 + 10 AS STRING) AS 
VARBINARY), 'old' FROM numbers("number" = "100")"""
+            sql """MERGE INTO ${table} t
+                USING (SELECT number AS id, CAST(CAST(number % 4 + 10 AS 
STRING) AS VARBINARY) AS binary_key
+                       FROM numbers("number" = "10000")) s
+                ON t.id = s.id
+                WHEN MATCHED THEN UPDATE SET binary_key = CAST(CAST(s.id % 4 + 
20 AS STRING) AS VARBINARY), v = 'updated'
+                WHEN NOT MATCHED THEN INSERT (id, binary_key, v) VALUES (s.id, 
s.binary_key, 'inserted')"""
+            String summary = "SELECT HEX(binary_key), v, count(*), min(id), 
max(id) FROM ${table} GROUP BY HEX(binary_key), v"
+            "order_qt_${table}" summary
+            spark_iceberg "REFRESH TABLE demo.${dbName}.${table}"
+            assertSparkDorisResultEquals(spark_iceberg("""
+                    SELECT hex(binary_key), v, count(*), min(id), max(id) FROM 
demo.${dbName}.${table}
+                    GROUP BY hex(binary_key), v ORDER BY 1, 2"""), 
sql("${summary} ORDER BY 1, 2"))
+        }
     } finally {
         sql "SWITCH internal"
     }


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

Reply via email to