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 20ee7a3ec7a [fix](be) Match Iceberg BINARY and FIXED equality delete
keys by bytes (#69503)
20ee7a3ec7a is described below
commit 20ee7a3ec7a011f01622110698b1297651fbbcf9
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Oct 25 08:07:01 2026 +0000
[fix](be) Match Iceberg BINARY and FIXED equality delete keys by bytes
(#69503)
### What problem does this PR solve?
Issue Number: None
Related PR: #66538, #68786
Problem Summary:
**In short.** With the file scanner V1, an Iceberg equality delete whose
key is a BINARY or FIXED column fails the read, and in one case aborts
the BE. This PR matches string and VARBINARY delete keys by their bytes,
as branch-4.1 and branch-4.2 do (#66538).
**Background.** Since #68786 the BE reads Iceberg BINARY and FIXED
columns as VARBINARY. V1 (`enable_file_scanner_v2 = false`) applies
equality deletes in `be/src/format/table/equality_delete.cpp`. For one
key column, `SimpleEqualityDelete` builds a `HybridSet` with
`create_set` on the key type and probes it with the data column. For
several key columns, `MultiEqualityDelete` hashes every row with
`update_hashes_with_value` and confirms a hash hit with `compare_at`.
Neither handles VARBINARY: `create_set` has no VARBINARY set, and
`ColumnVarbinary` does not implement `update_hashes_with_value`. The
delete file and the data file can also carry the same key as STRING on
one side and VARBINARY on the other. `MultiEqualityDelete` rejects that
as a type change, and `SimpleEqualityDelete` probes the set with a
column of the wrong class. branch-4.1 and branch-4.2 (#66538) store,
hash and compare these keys as bytes.
**The problem, and what it cost.** From the new unit tests, run against
master's `equality_delete.cpp`:
| Equality delete key | Master today (V1) | After this PR |
|---|---|---|
| one VARBINARY key | `VARBINARY predicates are not supported` | the
rows with matching bytes are deleted |
| one key, VARBINARY in the delete file and STRING in the data file |
`VARBINARY predicates are not supported` | deleted by bytes |
| one key, STRING in the delete file and VARBINARY in the data file |
the BE aborts: `Bad cast from type:doris::ColumnVarbinary to
doris::ColumnStr<unsigned int>` | deleted by bytes |
| VARBINARY key with an INT key | `Method update_hashes_with_value is
not supported for ColumnVarbinary` | deleted by bytes |
| composite key, STRING in the delete file and VARBINARY in the data
file | `Not support type change` | deleted by bytes |
| composite key whose data column is a const NULL STRING | `Method
update_hashes_with_value is not supported for Const(Nullable(String))` |
the row matching the NULL delete key is deleted |
The const column comes from
`IcebergReaderMixin::_create_missing_equality_delete_value`: when the FE
does not send Iceberg scan semantics v2 and the data file lacks the key
column, the column is filled from `create_column_const(1, Field())`.
**How this PR fixes it.**
- Keys of a string type or VARBINARY are byte keys. A single byte key
goes into a STRING `HybridSet` as bytes, and data rows probe it with
their bytes. A byte delete key against a non-byte data column is still
rejected as a type change.
- For composite keys, a byte key is hashed with xxhash over its bytes
(`HashUtil::xxHash64WithSeed`, and `xxHash64NullWithSeed` for NULL) on
both the delete side and the data side, and a hash hit compares the
bytes. STRING and VARBINARY keys count as the same type.
- Unlike branch-4.2, the byte path also reads const key columns, so a
const NULL key matches a NULL delete key instead of being read as an
empty string.
- Non-byte keys keep the current code path.
**Results.**
- BE unit tests: the new `be/test/format/table/equality_delete_test.cpp`
has the 5 tests of branch-4.2 and one for a const NULL key. With
master's `equality_delete.cpp` all 6 fail (one aborts the test process);
with this PR all 6 pass. The other BE tests around V1 Iceberg deletes
pass too: `IcebergReaderTest`, `ParquetReadLinesTest`,
`IcebergPositionDeleteSysTableReaderTest`,
`IcebergPositionDeleteSysTableV2ReaderTest` and
`VIcebergDeleteSinkTest`.
- No regression test: Spark SQL cannot write equality delete files, and
no docker fixture has an equality delete with a BINARY or FIXED key.
`test_iceberg_uuid_binary_compatibility` covers UUID keys, on the V2
scanner only.
---
be/src/format/table/equality_delete.cpp | 147 ++++++++++++++++++--
be/test/format/table/equality_delete_test.cpp | 193 ++++++++++++++++++++++++++
2 files changed, 328 insertions(+), 12 deletions(-)
diff --git a/be/src/format/table/equality_delete.cpp
b/be/src/format/table/equality_delete.cpp
index 2386e8c1bc1..21071a89114 100644
--- a/be/src/format/table/equality_delete.cpp
+++ b/be/src/format/table/equality_delete.cpp
@@ -17,11 +17,111 @@
#include "format/table/equality_delete.h"
+#include "core/column/column_const.h"
#include "core/column/column_nullable.h"
#include "exprs/create_predicate_function.h"
+#include "util/hash_util.hpp"
namespace doris {
+namespace {
+
+// Iceberg BINARY / FIXED keys are read as VARBINARY and string keys as
STRING, and the delete file and
+// the data file may carry the same key in different ones of these classes (a
delete block holding
+// STRING bytes for a VARBINARY data column, say). Keys of these types
therefore match by their bytes.
+bool is_equality_delete_byte_type(const DataTypePtr& type) {
+ const auto primitive_type = remove_nullable(type)->get_primitive_type();
+ return is_string_type(primitive_type) || is_varbinary(primitive_type);
+}
+
+// The rows of a byte-key column. A const column repeats its single value,
which can be NULL: when the FE does
+// not send Iceberg scan semantics v2, a key column that the data file lacks
is filled from
+// Const(Nullable(String) NULL), and its rows must match a NULL delete key
rather than an empty string.
+class ByteKeyColumn {
+public:
+ explicit ByteKeyColumn(const IColumn& column) : _values(&column) {
+ if (const auto* const_column =
check_and_get_column<ColumnConst>(_values)) {
+ _values = &const_column->get_data_column();
+ _is_const = true;
+ }
+ if (const auto* nullable =
check_and_get_column<ColumnNullable>(_values)) {
+ _null_map = &nullable->get_null_map_data();
+ _values = &nullable->get_nested_column();
+ }
+ }
+
+ bool is_null(size_t row) const {
+ return _null_map != nullptr && (*_null_map)[_is_const ? 0 : row] != 0;
+ }
+
+ StringRef value(size_t row) const { return _values->get_data_at(_is_const
? 0 : row); }
+
+private:
+ const IColumn* _values;
+ const NullMap* _null_map = nullptr;
+ bool _is_const = false;
+};
+
+void insert_byte_values(const IColumn& column, HybridSetBase* byte_set) {
+ const ByteKeyColumn keys(column);
+ for (size_t row = 0; row < column.size(); ++row) {
+ if (keys.is_null(row)) {
+ byte_set->insert(static_cast<const void*>(nullptr));
+ continue;
+ }
+ const StringRef value = keys.value(row);
+ byte_set->insert(&value);
+ }
+}
+
+void find_byte_values(const IColumn& column, const HybridSetBase& byte_set,
+ IColumn::Filter* matches) {
+ const ByteKeyColumn keys(column);
+ for (size_t row = 0; row < column.size(); ++row) {
+ if (keys.is_null(row)) {
+ (*matches)[row] = byte_set.contain_null();
+ continue;
+ }
+ const StringRef value = keys.value(row);
+ (*matches)[row] = byte_set.find(&value);
+ }
+}
+
+void update_equality_delete_hashes(const ColumnWithTypeAndName&
column_and_type,
+ std::vector<uint64_t>* hashes) {
+ if (!is_equality_delete_byte_type(column_and_type.type)) {
+ column_and_type.column->update_hashes_with_value(hashes->data(),
nullptr);
+ return;
+ }
+ // Hash the bytes rather than call update_hashes_with_value: VARBINARY
does not implement it, and a
+ // STRING delete key must land on the same hash as the equal VARBINARY
data key.
+ const ByteKeyColumn keys(*column_and_type.column);
+ for (size_t row = 0; row < column_and_type.column->size(); ++row) {
+ if (keys.is_null(row)) {
+ (*hashes)[row] = HashUtil::xxHash64NullWithSeed((*hashes)[row]);
+ continue;
+ }
+ const StringRef value = keys.value(row);
+ (*hashes)[row] = HashUtil::xxHash64WithSeed(value.data, value.size,
(*hashes)[row]);
+ }
+}
+
+bool equality_delete_values_equal(const ColumnWithTypeAndName& data_column,
size_t data_row,
+ const ColumnWithTypeAndName& delete_column,
size_t delete_row) {
+ if (!is_equality_delete_byte_type(data_column.type) ||
+ !is_equality_delete_byte_type(delete_column.type)) {
+ return data_column.column->compare_at(data_row, delete_row,
*delete_column.column, -1) == 0;
+ }
+ const ByteKeyColumn data_keys(*data_column.column);
+ const ByteKeyColumn delete_keys(*delete_column.column);
+ if (data_keys.is_null(data_row) || delete_keys.is_null(delete_row)) {
+ return data_keys.is_null(data_row) && delete_keys.is_null(delete_row);
+ }
+ return data_keys.value(data_row) == delete_keys.value(delete_row);
+}
+
+} // namespace
+
std::unique_ptr<EqualityDeleteBase> EqualityDeleteBase::get_delete_impl(
const Block* delete_block, const std::vector<int>& delete_col_ids) {
DCHECK_EQ(delete_block->columns(), delete_col_ids.size());
@@ -37,15 +137,22 @@ Status SimpleEqualityDelete::_build_set() {
if (_delete_block->columns() != 1) [[unlikely]] {
return Status::InternalError("Simple equality delete can be only
applied with one column");
}
- auto& column_and_type = _delete_block->get_by_position(0);
+ const auto& column_and_type = _delete_block->get_by_position(0);
auto delete_column_type =
remove_nullable(column_and_type.type)->get_primitive_type();
size_t non_null_rows = _delete_block->rows();
if (const auto* nullable =
check_and_get_column<ColumnNullable>(column_and_type.column.get());
nullable != nullptr) {
non_null_rows = std::ranges::count(nullable->get_null_map_data(),
UInt8(0));
}
- _hybrid_set.reset(create_set(delete_column_type, non_null_rows, true));
- _hybrid_set->insert_fixed_len(column_and_type.column, 0);
+ if (is_equality_delete_byte_type(column_and_type.type)) {
+ // create_set has no VARBINARY set (it throws NOT_IMPLEMENTED), so
every byte key goes into a
+ // STRING set as bytes, whichever of STRING and VARBINARY the delete
file was read as.
+ _hybrid_set.reset(create_set(TYPE_STRING, non_null_rows, true));
+ insert_byte_values(*column_and_type.column, _hybrid_set.get());
+ } else {
+ _hybrid_set.reset(create_set(delete_column_type, non_null_rows, true));
+ _hybrid_set->insert_fixed_len(column_and_type.column, 0);
+ }
return Status::OK();
}
@@ -61,6 +168,17 @@ Status SimpleEqualityDelete::filter_data_block(
auto block_idx = col_name_to_block_idx->at(block_col_name);
auto column_and_type = data_block->get_by_position(block_idx);
+ const bool delete_is_byte =
+
is_equality_delete_byte_type(_delete_block->get_by_position(0).type);
+ const bool byte_compatible =
+ delete_is_byte &&
is_equality_delete_byte_type(column_and_type.type);
+ // The byte set can only be probed with bytes: an INT data column would be
read as StringRef values.
+ if (delete_is_byte && !byte_compatible) [[unlikely]] {
+ return Status::InternalError(
+ "Not support type change in column '{}', src type: {}, target
type: {}",
+ column_and_type.name,
_delete_block->get_by_position(0).type->get_name(),
+ column_and_type.type->get_name());
+ }
size_t rows = data_block->rows();
// _filter: 1 => in _hybrid_set; 0 => not in _hybrid_set
@@ -71,7 +189,9 @@ Status SimpleEqualityDelete::filter_data_block(
_single_filter->assign(rows, UInt8(0));
}
auto column = column_and_type.column->convert_to_full_column_if_const();
- if (const auto* nullable =
check_and_get_column<ColumnNullable>(column.get())) {
+ if (byte_compatible) {
+ find_byte_values(*column, *_hybrid_set, _single_filter.get());
+ } else if (const auto* nullable =
check_and_get_column<ColumnNullable>(column.get())) {
const NullMap& null_map = nullable->get_null_map_data();
_hybrid_set->find_batch_nullable(*remove_nullable(column), rows,
null_map, *_single_filter);
if (_hybrid_set->contain_null()) {
@@ -96,8 +216,8 @@ Status MultiEqualityDelete::_build_set() {
size_t rows = _delete_block->rows();
_delete_hashes.clear();
_delete_hashes.resize(rows, 0);
- for (ColumnPtr column : _delete_block->get_columns()) {
- column->update_hashes_with_value(_delete_hashes.data(), nullptr);
+ for (const auto& column : _delete_block->get_columns_with_type_and_name())
{
+ update_equality_delete_hashes(column, &_delete_hashes);
}
for (size_t i = 0; i < rows; ++i) {
_delete_hash_map.insert({_delete_hashes[i], i});
@@ -126,7 +246,11 @@ Status MultiEqualityDelete::filter_data_block(
}
auto column_and_type =
data_block->safe_get_by_position(col_name_to_block_idx->at(block_column_name));
- if (!delete_col.type->equals(*column_and_type.type)) [[unlikely]] {
+ // A STRING delete key may meet a VARBINARY data column for the same
Iceberg BINARY field: both are
+ // hashed and compared as bytes, so only a change to or from a
non-byte type is rejected.
+ const bool byte_compatible =
is_equality_delete_byte_type(delete_col.type) &&
+
is_equality_delete_byte_type(column_and_type.type);
+ if (!delete_col.type->equals(*column_and_type.type) &&
!byte_compatible) [[unlikely]] {
return Status::InternalError(
"Not support type change in column '{}', src type: {},
target type: {}",
block_column_name, delete_col.type->get_name(),
@@ -138,8 +262,7 @@ Status MultiEqualityDelete::filter_data_block(
_data_hashes.clear();
_data_hashes.resize(rows, 0);
for (size_t index : _data_column_index) {
-
data_block->get_by_position(index).column->update_hashes_with_value(_data_hashes.data(),
-
nullptr);
+ update_equality_delete_hashes(data_block->get_by_position(index),
&_data_hashes);
}
auto* filter_data = filter.data();
for (size_t i = 0; i < rows; ++i) {
@@ -159,9 +282,9 @@ Status MultiEqualityDelete::filter_data_block(
bool MultiEqualityDelete::_equal(Block* data_block, size_t data_row_index,
size_t delete_row_index) {
for (size_t i = 0; i < _delete_block->columns(); ++i) {
- ColumnPtr data_col =
data_block->get_by_position(_data_column_index[i]).column;
- ColumnPtr delete_col = _delete_block->get_by_position(i).column;
- if (data_col->compare_at(data_row_index, delete_row_index,
*delete_col, -1) != 0) {
+ if
(!equality_delete_values_equal(data_block->get_by_position(_data_column_index[i]),
+ data_row_index,
_delete_block->get_by_position(i),
+ delete_row_index)) {
return false;
}
}
diff --git a/be/test/format/table/equality_delete_test.cpp
b/be/test/format/table/equality_delete_test.cpp
new file mode 100644
index 00000000000..8e4233cb98e
--- /dev/null
+++ b/be/test/format/table/equality_delete_test.cpp
@@ -0,0 +1,193 @@
+// 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.
+
+#include "format/table/equality_delete.h"
+
+#include <gtest/gtest.h>
+
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "core/column/column_const.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_string.h"
+#include "core/column/column_varbinary.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "core/data_type/data_type_varbinary.h"
+
+namespace doris {
+
+namespace {
+
+ColumnWithTypeAndName nullable_varbinary_column(
+ const std::string& name, const
std::vector<std::optional<std::string>>& values) {
+ auto data = ColumnVarbinary::create();
+ auto null_map = ColumnUInt8::create();
+ for (const auto& value : values) {
+ const std::string bytes = value.value_or("");
+ data->insert_data(bytes.data(), bytes.size());
+ null_map->insert_value(!value.has_value());
+ }
+ return {ColumnNullable::create(std::move(data), std::move(null_map)),
+ make_nullable(std::make_shared<DataTypeVarbinary>()), name};
+}
+
+ColumnWithTypeAndName varbinary_column(const std::string& name,
+ const std::vector<std::string>& values)
{
+ auto data = ColumnVarbinary::create();
+ for (const auto& value : values) {
+ data->insert_data(value.data(), value.size());
+ }
+ return {std::move(data), std::make_shared<DataTypeVarbinary>(), name};
+}
+
+ColumnWithTypeAndName nullable_string_column(
+ const std::string& name, const
std::vector<std::optional<std::string>>& values) {
+ auto data = ColumnString::create();
+ auto null_map = ColumnUInt8::create();
+ for (const auto& value : values) {
+ const std::string bytes = value.value_or("");
+ data->insert_data(bytes.data(), bytes.size());
+ null_map->insert_value(!value.has_value());
+ }
+ return {ColumnNullable::create(std::move(data), std::move(null_map)),
+ make_nullable(std::make_shared<DataTypeString>()), name};
+}
+
+ColumnWithTypeAndName string_column(const std::string& name,
+ const std::vector<std::string>& values) {
+ auto data = ColumnString::create();
+ for (const auto& value : values) {
+ data->insert_data(value.data(), value.size());
+ }
+ return {std::move(data), std::make_shared<DataTypeString>(), name};
+}
+
+ColumnWithTypeAndName int_column(const std::string& name, const
std::vector<int32_t>& values) {
+ auto data = ColumnInt32::create();
+ data->get_data().assign(values.begin(), values.end());
+ return {std::move(data), std::make_shared<DataTypeInt32>(), name};
+}
+
+std::vector<UInt8> apply_equality_delete(
+ const Block& delete_block, const std::vector<int>& field_ids, Block*
data_block,
+ const std::unordered_map<std::string, uint32_t>& column_indexes,
+ const std::unordered_map<int, std::string>& field_names) {
+ RuntimeProfile profile("equality_delete_varbinary");
+ auto equality_delete = EqualityDeleteBase::get_delete_impl(&delete_block,
field_ids);
+ EXPECT_TRUE(equality_delete->init(&profile).ok());
+ IColumn::Filter filter(data_block->rows(), 1);
+ EXPECT_TRUE(equality_delete->filter_data_block(data_block,
&column_indexes, field_names, filter)
+ .ok());
+ return {filter.begin(), filter.end()};
+}
+
+} // namespace
+
+TEST(EqualityDeleteTest, NullableSingleVarbinaryKeyUsesByteHashing) {
+ Block delete_block;
+ delete_block.insert(nullable_varbinary_column(
+ "binary_key", {std::nullopt, std::string("delete\0value", 12)}));
+ Block data_block;
+ data_block.insert(nullable_varbinary_column(
+ "binary_key",
+ {std::nullopt, std::string("keep\0value", 10),
std::string("delete\0value", 12)}));
+
+ EXPECT_EQ((std::vector<UInt8> {0, 1, 0}),
+ apply_equality_delete(delete_block, {7}, &data_block,
{{"binary_key", 0}},
+ {{7, "binary_key"}}));
+}
+
+TEST(EqualityDeleteTest, NullableStringDeleteMatchesVarbinaryData) {
+ Block delete_block;
+ delete_block.insert(
+ nullable_string_column("binary_key", {std::nullopt,
std::string("delete\0value", 12)}));
+ Block data_block;
+ data_block.insert(nullable_varbinary_column(
+ "binary_key",
+ {std::nullopt, std::string("keep\0value", 10),
std::string("delete\0value", 12)}));
+
+ EXPECT_EQ((std::vector<UInt8> {0, 1, 0}),
+ apply_equality_delete(delete_block, {7}, &data_block,
{{"binary_key", 0}},
+ {{7, "binary_key"}}));
+}
+
+TEST(EqualityDeleteTest, NullableVarbinaryDeleteMatchesStringData) {
+ Block delete_block;
+ delete_block.insert(nullable_varbinary_column(
+ "binary_key", {std::nullopt, std::string("delete\0value", 12)}));
+ Block data_block;
+ data_block.insert(nullable_string_column(
+ "binary_key",
+ {std::nullopt, std::string("keep\0value", 10),
std::string("delete\0value", 12)}));
+
+ EXPECT_EQ((std::vector<UInt8> {0, 1, 0}),
+ apply_equality_delete(delete_block, {7}, &data_block,
{{"binary_key", 0}},
+ {{7, "binary_key"}}));
+}
+
+TEST(EqualityDeleteTest, CompositeVarbinaryKeyUsesByteHashing) {
+ Block delete_block;
+ delete_block.insert(varbinary_column("binary_key", {"same", "other"}));
+ delete_block.insert(int_column("version", {1, 2}));
+ Block data_block;
+ data_block.insert(varbinary_column("binary_key", {"same", "other",
"same"}));
+ data_block.insert(int_column("version", {2, 2, 3}));
+
+ EXPECT_EQ((std::vector<UInt8> {1, 0, 1}),
+ apply_equality_delete(delete_block, {7, 8}, &data_block,
+ {{"binary_key", 0}, {"version", 1}},
+ {{7, "binary_key"}, {8, "version"}}));
+}
+
+TEST(EqualityDeleteTest, CompositeStringDeleteMatchesVarbinaryData) {
+ Block delete_block;
+ delete_block.insert(string_column("binary_key",
{std::string("same\0bytes", 10), "other"}));
+ delete_block.insert(int_column("version", {1, 2}));
+ Block data_block;
+ data_block.insert(
+ varbinary_column("binary_key", {std::string("same\0bytes", 10),
"other", "other"}));
+ data_block.insert(int_column("version", {2, 2, 3}));
+
+ EXPECT_EQ((std::vector<UInt8> {1, 0, 1}),
+ apply_equality_delete(delete_block, {7, 8}, &data_block,
+ {{"binary_key", 0}, {"version", 1}},
+ {{7, "binary_key"}, {8, "version"}}));
+}
+
+TEST(EqualityDeleteTest, CompositeConstNullStringKeyMatchesNullDeleteKey) {
+ Block delete_block;
+ delete_block.insert(nullable_string_column("string_key", {std::nullopt,
std::string()}));
+ delete_block.insert(int_column("version", {2, 3}));
+ // A key column that the data file lacks can arrive as one const NULL for
all rows.
+ auto const_null = nullable_string_column("string_key", {std::nullopt});
+ Block data_block;
+ data_block.insert({ColumnConst::create(const_null.column, 3),
const_null.type, "string_key"});
+ data_block.insert(int_column("version", {1, 2, 3}));
+
+ EXPECT_EQ((std::vector<UInt8> {1, 0, 1}),
+ apply_equality_delete(delete_block, {7, 8}, &data_block,
+ {{"string_key", 0}, {"version", 1}},
+ {{7, "string_key"}, {8, "version"}}));
+}
+
+} // namespace doris
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]