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 2760a7663b9 [fix](be) Compare Iceberg V1 equality-delete keys in the
promoted type (#69691)
2760a7663b9 is described below
commit 2760a7663b95558b142e4457383a8f7ec7a2d2bb
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 04:58:57 2026 +0000
[fix](be) Compare Iceberg V1 equality-delete keys in the promoted type
(#69691)
### What problem does this PR solve?
Issue Number: None
Related PR: #66538 (branch-4.1), #68267
Problem Summary:
**In short.** This brings the V1-reader part of branch-4.1 #66538
(Iceberg V3 default values) to master: Iceberg equality deletes keep
working after the type of their key column is promoted (INT to BIGINT,
FLOAT to DOUBLE, a wider DECIMAL precision). It does not copy
branch-4.1's approach, which compares a key in the delete file's older
type; it compares in the scan schema's type, as #68267 already does in
the V2 reader.
**Background.** Iceberg allows widening a column's type, and files keep
the type they were written with: an equality delete written while `id`
was `int` stores its key as INT32, and so does a data file written then.
The V1 reader (`enable_file_scanner_v2 = false`) loads each delete file
into a delete block and compares it with the data rows, which the
Parquet and ORC readers already return in the table's current type.
**The problem, and what it cost.** The V1 reader compared every key in
the type of the delete file it came from. After a promotion, any of
these fails:
| Case (V1 reader, `id` or `payload.existing` promoted from INT to
BIGINT) | master |
|---|---|
| `SELECT id`, delete written before the promotion | Query fails: `Bad
cast from type ColumnVector<BIGINT> to ColumnVector<INT>` |
| `SELECT id`, one delete written before the promotion and one after |
Query fails: the INT and BIGINT keys are merged into one delete block
(debug builds abort on the block's type check) |
| `SELECT v` (key not projected), same two deletes | Same |
| `SELECT id`, delete on `payload.existing` written after the promotion,
data file written before it | The INT values of the old file are copied
into a BIGINT column: debug builds abort with `Bad cast`, release builds
read the wrong bytes and keep or delete the wrong rows |
branch-4.1 / branch-4.2 handle these by casting the data key down to the
delete file's type. That narrowing is what #68267 fixed in the V2
reader: a data file written after the promotion can hold 4294967296,
which becomes NULL as INT and is then deleted by the NULL key of a
delete file written before the promotion (equality deletes use null-safe
equality); with `enable_strict_cast` the whole scan fails instead.
**How this PR fixes it.**
- The type a key is compared in is the field's type in the scan schema
(`equality_delete_key_type`, the same rule as V2's
`restore_current_primitive_type`). A plan without a scan schema keeps
the delete file's type, as before, and STRING / VARBINARY keys keep
theirs too, since they match by bytes.
- Delete keys, and nested keys extracted from the physical root of an
older data file, are widened to that type with
`converter::ColumnTypeConverter`, the converter the V1 Parquet and ORC
readers already apply to the data column of a promoted field, so both
sides hold the same value (`widen_equality_delete_key`).
`NestedEqualityDeleteColumn` records the leaf's source type for that.
- Delete blocks and hidden key carriers are built in the comparison
type, so deletes written before and after a promotion share one block.
**Results.**
| Case (V1 reader) | master | This PR |
|---|---|---|
| `SELECT id`, INT delete `id = 2`, data 1, 2, 3 | `Bad cast` | 1, 3 |
| `SELECT id`, INT delete `id = 2` + BIGINT delete `id = 3` | aborts
(debug) / fails | 1 |
| `SELECT v`, same two deletes | aborts (debug) / fails | 10 |
| `SELECT id`, BIGINT delete `payload.existing = 20` on an old INT file
| `Bad cast` (debug) / wrong rows (release) | 1, 3 |
| `SELECT id`, data `NULL, 0, 1, 4294967296`, INT delete holding NULL |
`Bad cast` (branch-4.x: 0, 1) | 0, 1, 4294967296 |
---
be/src/format/table/equality_delete.cpp | 67 +++-
be/src/format/table/equality_delete.h | 28 ++
be/src/format/table/iceberg_reader.cpp | 22 +-
be/src/format/table/iceberg_reader_mixin.h | 26 +-
.../format/table/iceberg/iceberg_reader_test.cpp | 395 ++++++++++++++++++++-
5 files changed, 517 insertions(+), 21 deletions(-)
diff --git a/be/src/format/table/equality_delete.cpp
b/be/src/format/table/equality_delete.cpp
index 21071a89114..193526e1266 100644
--- a/be/src/format/table/equality_delete.cpp
+++ b/be/src/format/table/equality_delete.cpp
@@ -17,23 +17,82 @@
#include "format/table/equality_delete.h"
+#include <gen_cpp/ExternalTableSchema_types.h>
+
+#include "core/assert_cast.h"
#include "core/column/column_const.h"
#include "core/column/column_nullable.h"
+#include "core/data_type/data_type_factory.hpp"
#include "exprs/create_predicate_function.h"
+#include "format/column_type_convert.h"
#include "util/hash_util.hpp"
namespace doris {
-namespace {
+DataTypePtr equality_delete_key_type(const schema::external::TField* field,
+ const DataTypePtr& delete_key_type) {
+ DORIS_CHECK(delete_key_type->is_nullable());
+ // A plan from an FE that sends no scan schema, or no type for the field,
leaves only the delete
+ // file's own type, which is how keys were compared before.
+ if (field == nullptr || !field->__isset.type ||
is_equality_delete_byte_type(delete_key_type)) {
+ return delete_key_type;
+ }
+ const auto primitive_type = thrift_to_type(field->type.type);
+ DORIS_CHECK(!is_complex_type(primitive_type));
+ return DataTypeFactory::instance().create_data_type(
+ primitive_type, true, field->type.__isset.precision ?
field->type.precision : 0,
+ field->type.__isset.scale ? field->type.scale : 0,
+ field->type.__isset.len ? field->type.len : -1);
+}
-// 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);
}
+Status widen_equality_delete_key(ColumnPtr column, const DataTypePtr&
column_type,
+ const DataTypePtr& key_type, ColumnPtr*
result) {
+ DORIS_CHECK(result != nullptr);
+ DORIS_CHECK(column_type->is_nullable());
+ DORIS_CHECK(key_type->is_nullable());
+ if (column_type->equals(*key_type)) {
+ *result = std::move(column);
+ return Status::OK();
+ }
+ // Byte keys keep the type they were read in and match by their bytes, and
the converters below
+ // would turn VARBINARY into its text form.
+ if (is_equality_delete_byte_type(column_type) ||
is_equality_delete_byte_type(key_type))
+ [[unlikely]] {
+ return Status::InternalError("Unexpected type change of equality
delete key from {} to {}",
+ column_type->get_name(),
key_type->get_name());
+ }
+ // The Parquet and ORC readers widen the data column of a promoted field
with the same
+ // converters, so a widened delete key holds exactly the value of the data
key it must match.
+ auto converter =
+ converter::ColumnTypeConverter::get_converter(column_type,
key_type, converter::COMMON);
+ if (!converter->support()) {
+ return Status::InternalError(
+ "Not support type change of equality delete key from {} to {}:
{}",
+ column_type->get_name(), key_type->get_name(),
converter->get_error_msg());
+ }
+ if (converter->is_consistent()) {
+ // The same column layout, for example DATETIMEV2 of another scale.
+ *result = std::move(column);
+ return Status::OK();
+ }
+ MutableColumnPtr widened = key_type->create_column();
+ // The converters fill only the nested values; as in
OrcReader::_orc_column_to_doris_column, the
+ // null map is copied before converting.
+ const auto& source_null_map = assert_cast<const
ColumnNullable&>(*column).get_null_map_data();
+
assert_cast<ColumnNullable&>(*widened).get_null_map_data().insert(source_null_map.begin(),
+
source_null_map.end());
+ RETURN_IF_ERROR(converter->convert(column, widened));
+ *result = std::move(widened);
+ return Status::OK();
+}
+
+namespace {
+
// 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.
diff --git a/be/src/format/table/equality_delete.h
b/be/src/format/table/equality_delete.h
index 572766091a6..058b6445bdf 100644
--- a/be/src/format/table/equality_delete.h
+++ b/be/src/format/table/equality_delete.h
@@ -19,8 +19,36 @@
#include "exprs/hybrid_set.h"
#include "runtime/runtime_profile.h"
+namespace doris::schema::external {
+class TField;
+} // namespace doris::schema::external
+
namespace doris {
+// The type an equality-delete key is compared in: the field's type in the
scan schema (`field`, which
+// is null when the plan carries no schema), as in the V2 reader. Data files
are read in that type, so
+// an equality delete written before a type promotion is widened to it rather
than the data narrowed
+// to the delete file's type. Example: `id` promoted from INT to BIGINT, a
data file written after the
+// promotion holds 4294967296, and a delete file written before it holds the
NULL key. Narrowed to
+// INT, 4294967296 became NULL and was deleted by that NULL key; widened, the
delete key is compared
+// as BIGINT and deletes only rows whose id is NULL. STRING / VARBINARY keys
keep `delete_key_type`:
+// they match by their bytes whatever the two sides were read as.
+DataTypePtr equality_delete_key_type(const schema::external::TField* field,
+ const DataTypePtr& delete_key_type);
+
+// 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);
+
+// Widens an equality-delete key read in `column_type` to `key_type`, the type
the key is compared in.
+// A file written before an Iceberg type promotion holds the key in the old
type: the INT key of a
+// column since promoted to BIGINT becomes BIGINT here. Iceberg's promotions
(INT to BIGINT, FLOAT to
+// DOUBLE, a wider DECIMAL precision) only widen, so every value is kept.
Returns `column` itself when
+// the types are equal.
+Status widen_equality_delete_key(ColumnPtr column, const DataTypePtr&
column_type,
+ const DataTypePtr& key_type, ColumnPtr*
result);
+
/**
* Support Iceberg equality delete.
* If there's only one delete column in delete file, use
`SimpleEqualityDelete`,
diff --git a/be/src/format/table/iceberg_reader.cpp
b/be/src/format/table/iceberg_reader.cpp
index d49d690a01e..5c14badbbc5 100644
--- a/be/src/format/table/iceberg_reader.cpp
+++ b/be/src/format/table/iceberg_reader.cpp
@@ -728,8 +728,10 @@ Status
IcebergParquetReader::on_before_init_reader(ReaderInitContext* ctx) {
RETURN_IF_ERROR(find_projected_equality_key_path(*root_path.front(),
key_path,
slot->second->type(),
child_indexes,
&leaf_type, &found));
- // The projected leaf has the table's current type. A key written
before a type promotion
- // (INT promoted to BIGINT) keeps its own type, so it still reads the
physical root.
+ // The projected leaf has the table's current type, which is the type
a key is compared in
+ // (equality_delete_key_type) even when the key was written before a
type promotion. Only a
+ // STRING / VARBINARY key keeps its delete file's type: a STRING key
under a VARBINARY
+ // projected leaf still reads the physical root, where the leaf is
read as STRING too.
if (found &&
remove_nullable(leaf_type)->equals(*remove_nullable(key_type)) &&
projected_root_reads_physical_path(*ctx->table_info_node,
slot->second->col_name(),
slot->second->type(),
*child_indexes, file_path)) {
@@ -834,6 +836,7 @@ Status
IcebergParquetReader::on_before_init_reader(ReaderInitContext* ctx) {
.field_id = field_id,
.block_name = table_col_name,
.leaf_type = _expand_columns[i].type,
+ .source_leaf_type = _expand_columns[i].type,
.child_indexes = std::move(projected_child_indexes),
.missing_value = nullptr,
.source_block_name = std::move(projected_root_name),
@@ -846,6 +849,14 @@ Status
IcebergParquetReader::on_before_init_reader(ReaderInitContext* ctx) {
field_id, _expand_columns[i].type,
file_path.fields.size(),
&missing_value));
}
+ // The physical root holds the leaf in the data file's own type,
which is narrower than
+ // the compared type when the file was written before a type
promotion: payload.existing
+ // promoted from INT to BIGINT is INT in older files, and copying
those INT values into a
+ // BIGINT column read the wrong bytes. A leaf the file lacks is
the missing default,
+ // which is built in the compared type.
+ const DataTypePtr source_leaf_type =
+ complete_file_path ?
make_nullable(file_path.fields.back()->data_type)
+ : _expand_columns[i].type;
const auto root_carrier =
physical_root_carriers.find(file_column->name);
if (root_carrier != physical_root_carriers.end()) {
// Keys payload.a and payload.b: the carrier of payload.a
reads payload, and
@@ -854,6 +865,7 @@ Status
IcebergParquetReader::on_before_init_reader(ReaderInitContext* ctx) {
.field_id = field_id,
.block_name = table_col_name,
.leaf_type = _expand_columns[i].type,
+ .source_leaf_type = source_leaf_type,
.child_indexes = file_path.child_indexes,
.missing_value = std::move(missing_value),
.source_block_name = root_carrier->second,
@@ -865,6 +877,7 @@ Status
IcebergParquetReader::on_before_init_reader(ReaderInitContext* ctx) {
.field_id = field_id,
.block_name = table_col_name,
.leaf_type = _expand_columns[i].type,
+ .source_leaf_type = source_leaf_type,
.child_indexes = file_path.child_indexes,
.missing_value = std::move(missing_value),
});
@@ -1234,16 +1247,21 @@ Status
IcebergOrcReader::on_before_init_reader(ReaderInitContext* ctx) {
.field_id = field_id,
.block_name = table_col_name,
.leaf_type = _expand_columns[i].type,
+ .source_leaf_type = _expand_columns[i].type,
.child_indexes = file_path.child_indexes,
.missing_value = std::move(missing_value),
});
_expand_columns[i].type =
make_nullable(convert_to_doris_type(file_column));
_expand_columns[i].column =
_expand_columns[i].type->create_column();
} else if (!file_path.child_indexes.empty()) {
+ // The root is read in the data file's own types; a leaf written
before a type
+ // promotion (INT payload.existing of a column now BIGINT) is
widened after extraction.
_nested_equality_delete_columns.push_back({
.field_id = field_id,
.block_name = table_col_name,
.leaf_type = _expand_columns[i].type,
+ .source_leaf_type =
+
make_nullable(convert_to_doris_type(file_path.fields.back())),
.child_indexes = file_path.child_indexes,
.missing_value = nullptr,
});
diff --git a/be/src/format/table/iceberg_reader_mixin.h
b/be/src/format/table/iceberg_reader_mixin.h
index bf47010289e..ca5b63f5ddd 100644
--- a/be/src/format/table/iceberg_reader_mixin.h
+++ b/be/src/format/table/iceberg_reader_mixin.h
@@ -362,7 +362,12 @@ protected:
struct NestedEqualityDeleteColumn {
int32_t field_id = -1;
std::string block_name;
+ // The type the key is compared in (equality_delete_key_type).
DataTypePtr leaf_type;
+ // The type of the leaf in the column it is extracted from, widened to
leaf_type afterwards.
+ // A file written before a type promotion holds the old type:
payload.existing promoted from
+ // INT to BIGINT is still INT in the delete files and data files
written before.
+ DataTypePtr source_leaf_type;
std::vector<size_t> child_indexes;
ColumnPtr missing_value;
// Block column holding the key's root struct. Empty when the key's
own column reads the
@@ -677,12 +682,15 @@ Status
IcebergReaderMixin<BaseReader>::_build_parquet_equality_delete_read_specs
return Status::NotSupported(
"Iceberg equality delete does not support complex column
{}", leaf->name);
}
+ const auto delete_key_type = make_nullable(leaf->data_type);
read_specs->push_back({
.nested_field =
{
.field_id = field_id,
.block_name = leaf->name,
- .leaf_type = make_nullable(leaf->data_type),
+ .leaf_type =
equality_delete_key_type(_find_schema_field(field_id),
+
delete_key_type),
+ .source_leaf_type = delete_key_type,
.child_indexes = std::move(child_indexes),
.missing_value = nullptr,
},
@@ -724,12 +732,15 @@ Status
IcebergReaderMixin<BaseReader>::_build_orc_equality_delete_read_specs(
return Status::NotSupported(
"Iceberg equality delete does not support complex column
{}", names.back());
}
+ const auto delete_key_type =
make_nullable(reader->convert_to_doris_type(leaf));
read_specs->push_back({
.nested_field =
{
.field_id = field_id,
.block_name = names.back(),
- .leaf_type =
make_nullable(reader->convert_to_doris_type(leaf)),
+ .leaf_type =
equality_delete_key_type(_find_schema_field(field_id),
+
delete_key_type),
+ .source_leaf_type = delete_key_type,
.child_indexes = std::move(child_indexes),
.missing_value = nullptr,
},
@@ -1109,8 +1120,9 @@ Status
IcebergReaderMixin<BaseReader>::_extract_nested_equality_delete_column(
}
}
- auto result =
ColumnNullable::create(remove_nullable(nested_field.leaf_type)->create_column(),
- ColumnUInt8::create());
+ DORIS_CHECK(nested_field.source_leaf_type != nullptr);
+ auto result = ColumnNullable::create(
+ remove_nullable(nested_field.source_leaf_type)->create_column(),
ColumnUInt8::create());
auto& result_data = result->get_nested_column();
auto& result_null_map = result->get_null_map_data();
result_data.reserve(root_column->size());
@@ -1131,8 +1143,9 @@ Status
IcebergReaderMixin<BaseReader>::_extract_nested_equality_delete_column(
result_null_map.push_back(0);
}
}
- *leaf_column = std::move(result);
- return Status::OK();
+ return widen_equality_delete_key(std::move(result),
+
make_nullable(nested_field.source_leaf_type),
+ make_nullable(nested_field.leaf_type),
leaf_column);
}
template <typename BaseReader>
@@ -1232,6 +1245,7 @@ Status
IcebergReaderMixin<BaseReader>::_create_missing_equality_delete_value(
.field_id = field_id,
.block_name = "",
.leaf_type = delete_key_type,
+ .source_leaf_type = delete_key_type,
.child_indexes = std::vector<size_t>(table_path.size() -
missing_index - 1, 0),
.missing_value = nullptr,
};
diff --git a/be/test/format/table/iceberg/iceberg_reader_test.cpp
b/be/test/format/table/iceberg/iceberg_reader_test.cpp
index 08767f5e022..2fbabcabc7f 100644
--- a/be/test/format/table/iceberg/iceberg_reader_test.cpp
+++ b/be/test/format/table/iceberg/iceberg_reader_test.cpp
@@ -84,9 +84,10 @@
namespace doris {
void write_iceberg_int_orc_file(const std::string& file_path, const
std::string& field_name,
- int32_t field_id, const std::vector<int64_t>&
values) {
+ int32_t field_id, const std::vector<int64_t>&
values,
+ const std::string& orc_type = "int") {
auto type = std::unique_ptr<::orc::Type>(
- ::orc::Type::buildTypeFromString("struct<" + field_name +
":int>"));
+ ::orc::Type::buildTypeFromString("struct<" + field_name + ":" +
orc_type + ">"));
type->getSubtype(0)->setAttribute("iceberg.id", std::to_string(field_id));
MemoryOutputStream memory_stream(1024 * 1024);
@@ -456,10 +457,11 @@ void write_iceberg_nested_struct_orc_file(const
std::string& file_path,
const std::vector<int64_t>& values,
const std::vector<bool>&
parent_nulls,
const std::string& child_name =
"existing",
- int32_t child_id = 2) {
+ int32_t child_id = 2,
+ const std::string& child_type =
"int") {
DORIS_CHECK(values.size() == parent_nulls.size());
- auto type = std::unique_ptr<::orc::Type>(
- ::orc::Type::buildTypeFromString("struct<payload:struct<" +
child_name + ":int>>"));
+ auto type = std::unique_ptr<::orc::Type>(::orc::Type::buildTypeFromString(
+ "struct<payload:struct<" + child_name + ":" + child_type + ">>"));
type->getSubtype(0)->setAttribute("iceberg.id", "1");
type->getSubtype(0)->getSubtype(0)->setAttribute("iceberg.id",
std::to_string(child_id));
@@ -592,9 +594,82 @@ void write_iceberg_int_equality_delete_parquet_file(const
std::string& file_path
1, properties.build()));
}
-Status create_single_int_tuple_descriptor(ObjectPool* object_pool, const
std::string& column_name,
- int32_t field_id, DescriptorTbl**
descriptor_table,
- const TupleDescriptor**
tuple_descriptor) {
+// One optional INT32 or INT64 column `field_name` (field `field_id`);
std::nullopt writes NULL.
+void write_iceberg_single_integer_parquet_file(const std::string& file_path,
+ const std::string& field_name,
int32_t field_id,
+ bool int64_column,
+ const
std::vector<std::optional<int64_t>>& values) {
+ std::shared_ptr<arrow::Array> array;
+ if (int64_column) {
+ arrow::Int64Builder builder;
+ for (const auto& value : values) {
+ DORIS_CHECK((value.has_value() ? builder.Append(*value) :
builder.AppendNull()).ok());
+ }
+ DORIS_CHECK(builder.Finish(&array).ok());
+ } else {
+ arrow::Int32Builder builder;
+ for (const auto& value : values) {
+ DORIS_CHECK((value.has_value() ?
builder.Append(static_cast<int32_t>(*value))
+ : builder.AppendNull())
+ .ok());
+ }
+ DORIS_CHECK(builder.Finish(&array).ok());
+ }
+ auto field = arrow::field(field_name, int64_column ? arrow::int64() :
arrow::int32(), true)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"},
+
{std::to_string(field_id)}));
+ auto table = arrow::Table::Make(arrow::schema({field}), {array});
+ auto output = arrow::io::FileOutputStream::Open(file_path);
+ DORIS_CHECK(output.ok());
+ ::parquet::WriterProperties::Builder properties;
+ properties.version(::parquet::ParquetVersion::PARQUET_2_6);
+ properties.data_page_version(::parquet::ParquetDataPageVersion::V2);
+ properties.compression(::parquet::Compression::UNCOMPRESSED);
+ PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table,
arrow::default_memory_pool(), *output,
+
static_cast<int64_t>(values.size()),
+ properties.build()));
+}
+
+// `payload STRUCT<existing BIGINT>` (field 1, child 2): an equality delete on
payload.existing
+// written after the field was promoted from INT.
+void write_iceberg_bigint_nested_struct_parquet_file(const std::string&
file_path,
+ const
std::vector<int64_t>& values) {
+ auto existing_field =
+ arrow::field("existing", arrow::int64(), false)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"2"}));
+ auto struct_type = arrow::struct_({existing_field});
+ std::vector<std::shared_ptr<arrow::ArrayBuilder>> field_builders;
+ field_builders.emplace_back(std::make_shared<arrow::Int64Builder>());
+ arrow::StructBuilder builder(struct_type, arrow::default_memory_pool(),
+ std::move(field_builders));
+ auto* existing_builder =
assert_cast<arrow::Int64Builder*>(builder.field_builder(0));
+ for (const auto value : values) {
+ DORIS_CHECK(builder.Append().ok());
+ DORIS_CHECK(existing_builder->Append(value).ok());
+ }
+ std::shared_ptr<arrow::Array> payload;
+ DORIS_CHECK(builder.Finish(&payload).ok());
+ auto payload_field =
+ arrow::field("payload", struct_type, true)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"1"}));
+ auto table = arrow::Table::Make(arrow::schema({payload_field}), {payload});
+
+ auto output = arrow::io::FileOutputStream::Open(file_path);
+ DORIS_CHECK(output.ok());
+ ::parquet::WriterProperties::Builder properties;
+ properties.version(::parquet::ParquetVersion::PARQUET_2_6);
+ properties.data_page_version(::parquet::ParquetDataPageVersion::V2);
+ properties.compression(::parquet::Compression::UNCOMPRESSED);
+ PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table,
arrow::default_memory_pool(), *output,
+
static_cast<int64_t>(values.size()),
+ properties.build()));
+}
+
+Status create_single_scalar_tuple_descriptor(ObjectPool* object_pool,
+ const std::string& column_name,
int32_t field_id,
+ TPrimitiveType::type slot_type,
+ DescriptorTbl** descriptor_table,
+ const TupleDescriptor**
tuple_descriptor) {
TDescriptorTable thrift_table;
TTableDescriptor table_descriptor;
table_descriptor.__set_id(0);
@@ -606,7 +681,7 @@ Status create_single_int_tuple_descriptor(ObjectPool*
object_pool, const std::st
TTypeNode type_node;
type_node.__set_type(TTypeNodeType::SCALAR);
TScalarType scalar_type;
- scalar_type.__set_type(TPrimitiveType::INT);
+ scalar_type.__set_type(slot_type);
type_node.__set_scalar_type(scalar_type);
TTypeDesc type;
type.__set_types({type_node});
@@ -637,6 +712,14 @@ Status create_single_int_tuple_descriptor(ObjectPool*
object_pool, const std::st
return Status::OK();
}
+Status create_single_int_tuple_descriptor(ObjectPool* object_pool, const
std::string& column_name,
+ int32_t field_id, DescriptorTbl**
descriptor_table,
+ const TupleDescriptor**
tuple_descriptor) {
+ return create_single_scalar_tuple_descriptor(object_pool, column_name,
field_id,
+ TPrimitiveType::INT,
descriptor_table,
+ tuple_descriptor);
+}
+
Status create_two_int_tuple_descriptor(
ObjectPool* object_pool, const std::array<std::pair<std::string,
int32_t>, 2>& columns,
DescriptorTbl** descriptor_table, const TupleDescriptor**
tuple_descriptor) {
@@ -1045,6 +1128,15 @@ schema::external::TFieldPtr
make_external_int_field(const std::string& name, int
return field_ptr;
}
+// A field promoted from INT to BIGINT: its type in the scan schema is BIGINT.
+schema::external::TFieldPtr make_external_bigint_field(const std::string&
name, int32_t field_id) {
+ auto field_ptr = make_external_int_field(name, field_id, std::nullopt);
+ TColumnType type;
+ type.__set_type(TPrimitiveType::BIGINT);
+ field_ptr.field_ptr->__set_type(type);
+ return field_ptr;
+}
+
schema::external::TFieldPtr make_external_struct_field(
const std::string& name, int32_t field_id,
std::vector<schema::external::TFieldPtr> children,
std::vector<std::string> aliases = {},
@@ -3506,6 +3598,291 @@ TEST_F(IcebergReaderTest,
&timezone_obj,
cache.get(), true);
}
+// Reads `data_file` through the V1 Iceberg reader with the equality deletes
`delete_files` (path and
+// key field ids) applied, under the scan schema `current_root`. `block` holds
the projected slots of
+// `tuple_descriptor` in order; all rows fit in one batch.
+Status read_v1_with_equality_deletes(
+ TFileFormatType::type file_format, const std::string& data_file,
+ const std::vector<std::pair<std::string, std::vector<int32_t>>>&
delete_files,
+ schema::external::TStructField current_root, const TupleDescriptor*
tuple_descriptor,
+ Block* block, const cctz::time_zone* timezone, FileMetaCache*
meta_cache) {
+ schema::external::TSchema current_schema;
+ current_schema.__set_schema_id(100);
+ current_schema.__set_root_field(std::move(current_root));
+ TFileScanRangeParams scan_params;
+
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ scan_params.__set_file_type(TFileType::FILE_LOCAL);
+ scan_params.__set_format_type(file_format);
+ scan_params.__set_current_schema_id(100);
+ scan_params.__set_history_schema_info({current_schema});
+
+ std::vector<TIcebergDeleteFileDesc> delete_descriptors;
+ for (const auto& [delete_file, field_ids] : delete_files) {
+ TIcebergDeleteFileDesc delete_descriptor;
+ delete_descriptor.__set_content(2);
+ delete_descriptor.__set_path(delete_file);
+ delete_descriptor.__set_field_ids(field_ids);
+ delete_descriptor.__set_file_format(file_format);
+ delete_descriptors.push_back(std::move(delete_descriptor));
+ }
+ TIcebergFileDesc iceberg_descriptor;
+ iceberg_descriptor.__set_format_version(2);
+ iceberg_descriptor.__set_original_file_path(data_file);
+ iceberg_descriptor.__set_delete_files(std::move(delete_descriptors));
+ TTableFormatFileDesc table_format_descriptor;
+
table_format_descriptor.__set_iceberg_params(std::move(iceberg_descriptor));
+
+ TFileRangeDesc scan_range;
+ scan_range.__set_fs_name("");
+ scan_range.__set_path(data_file);
+ scan_range.__set_start_offset(0);
+
scan_range.__set_size(static_cast<int64_t>(std::filesystem::file_size(data_file)));
+
scan_range.__set_file_size(static_cast<int64_t>(std::filesystem::file_size(data_file)));
+ scan_range.__set_table_format_params(std::move(table_format_descriptor));
+
+ RuntimeProfile profile("test_profile");
+ RuntimeState runtime_state {TQueryOptions(), TQueryGlobals()};
+ runtime_state.set_timezone("UTC");
+ io::IOContext io_ctx;
+ ShardedKVCache kv_cache(8);
+ std::vector<ColumnDescriptor> column_descriptors(block->columns());
+ std::unordered_map<std::string, uint32_t> block_positions;
+ for (uint32_t position = 0; position < block->columns(); ++position) {
+ column_descriptors[position].name =
block->get_by_position(position).name;
+ block_positions.emplace(column_descriptors[position].name, position);
+ }
+ size_t read_rows = 0;
+ bool eof = false;
+ if (file_format == TFileFormatType::FORMAT_PARQUET) {
+ IcebergParquetReader reader(&kv_cache, &profile, scan_params,
scan_range, 1024, timezone,
+ &io_ctx, &runtime_state, meta_cache);
+ io::FileReaderSPtr file_reader;
+ RETURN_IF_ERROR(io::global_local_filesystem()->open_file(data_file,
&file_reader));
+ reader.set_file_reader(file_reader);
+ ParquetInitContext context;
+ context.column_descs = &column_descriptors;
+ context.col_name_to_block_idx = &block_positions;
+ context.tuple_descriptor = tuple_descriptor;
+ context.params = &scan_params;
+ context.range = &scan_range;
+ RETURN_IF_ERROR(reader.init_reader(&context));
+ return reader.get_next_block(block, &read_rows, &eof);
+ }
+ IcebergOrcReader reader(&kv_cache, &profile, &runtime_state, scan_params,
scan_range, 1024,
+ "UTC", &io_ctx, meta_cache);
+ OrcInitContext context;
+ context.column_descs = &column_descriptors;
+ context.col_name_to_block_idx = &block_positions;
+ context.tuple_descriptor = tuple_descriptor;
+ context.params = &scan_params;
+ context.range = &scan_range;
+ RETURN_IF_ERROR(reader.init_reader(&context));
+ return reader.get_next_block(block, &read_rows, &eof);
+}
+
+// Iceberg allows promoting int to long, and an equality delete keeps the key
type it was written
+// with. Data `id, v, w` holds (1,10,100), (2,20,200) and (3,30,300) with `id`
still int32 in the
+// file, the table type of `id` is now long, a delete written before the
promotion holds id = 2 as
+// INT and one written after it id = 3 as BIGINT. Both are compared as BIGINT,
the type the data is
+// read in. `project_key` selects `SELECT id` (the key is the projected
column) or `SELECT v` (the
+// key is read into a hidden carrier).
+//
NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size)
+void check_equality_deletes_match_promoted_key(TFileFormatType::type
file_format, bool project_key,
+ bool delete_after_promotion,
+ const cctz::time_zone* timezone,
+ FileMetaCache* meta_cache) {
+ const bool is_parquet = file_format == TFileFormatType::FORMAT_PARQUET;
+ const std::string format_name = is_parquet ? "parquet" : "orc";
+ const std::string case_name = format_name + (project_key ? "_id" : "_v") +
+ (delete_after_promotion ? "_both_deletes" :
"_int_delete");
+ SCOPED_TRACE(case_name);
+ const auto test_dir = std::filesystem::temp_directory_path() /
+ ("doris_v1_equality_delete_promoted_key_" +
case_name);
+ std::filesystem::remove_all(test_dir);
+ std::filesystem::create_directories(test_dir);
+ const auto data_file = (test_dir / ("data." + format_name)).string();
+ const auto int_delete_file = (test_dir / ("int-delete." +
format_name)).string();
+ const auto bigint_delete_file = (test_dir / ("bigint-delete." +
format_name)).string();
+ if (is_parquet) {
+ write_iceberg_three_int_parquet_file(data_file, "id", 0, {1, 2, 3},
"v", 1, {10, 20, 30},
+ "w", 2, {100, 200, 300});
+ write_iceberg_single_integer_parquet_file(int_delete_file, "id", 0,
false, {2});
+ write_iceberg_single_integer_parquet_file(bigint_delete_file, "id", 0,
true, {3});
+ } else {
+ write_iceberg_three_int_orc_file(data_file, "id", 0, {1, 2, 3}, "v",
1, {10, 20, 30}, "w",
+ 2, {100, 200, 300});
+ write_iceberg_int_orc_file(int_delete_file, "id", 0, {2});
+ write_iceberg_int_orc_file(bigint_delete_file, "id", 0, {3}, "bigint");
+ }
+ std::vector<std::pair<std::string, std::vector<int32_t>>> delete_files = {
+ {int_delete_file, {0}}};
+ if (delete_after_promotion) {
+ delete_files.emplace_back(bigint_delete_file, std::vector<int32_t>
{0});
+ }
+
+ schema::external::TStructField current_root;
+ current_root.__set_fields({make_external_bigint_field("id", 0),
+ make_external_int_field("v", 1, std::nullopt),
+ make_external_int_field("w", 2, std::nullopt)});
+ ObjectPool object_pool;
+ DescriptorTbl* descriptor_table = nullptr;
+ const TupleDescriptor* tuple_descriptor = nullptr;
+ ASSERT_TRUE(create_single_scalar_tuple_descriptor(
+ &object_pool, project_key ? "id" : "v", project_key ?
0 : 1,
+ project_key ? TPrimitiveType::BIGINT :
TPrimitiveType::INT,
+ &descriptor_table, &tuple_descriptor)
+ .ok());
+ const DataTypePtr type = project_key ?
make_nullable(std::make_shared<DataTypeInt64>())
+ :
make_nullable(std::make_shared<DataTypeInt32>());
+ Block block;
+ block.insert({type->create_column(), type, project_key ? "id" : "v"});
+ const Status status = read_v1_with_equality_deletes(file_format,
data_file, delete_files,
+
std::move(current_root), tuple_descriptor,
+ &block, timezone,
meta_cache);
+
+ ASSERT_TRUE(status.ok()) << status;
+ std::vector<std::string> expected = project_key ? std::vector<std::string>
{"1", "3"}
+ : std::vector<std::string>
{"10", "30"};
+ if (delete_after_promotion) {
+ expected.pop_back();
+ }
+ ASSERT_EQ(block.rows(), expected.size());
+ for (size_t row = 0; row < expected.size(); ++row) {
+ EXPECT_EQ(type->to_string(*block.get_by_position(0).column, row),
expected[row]);
+ }
+ std::filesystem::remove_all(test_dir);
+}
+
+TEST_F(IcebergReaderTest,
v1_parquet_equality_delete_matches_key_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_PARQUET,
true, false,
+ &timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
v1_orc_equality_delete_matches_key_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_ORC,
true, false,
+ &timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
v1_parquet_equality_deletes_before_and_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_PARQUET,
true, true,
+ &timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
v1_orc_equality_deletes_before_and_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_ORC,
true, true,
+ &timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
+
v1_parquet_hidden_equality_deletes_before_and_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_PARQUET,
false, true,
+ &timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
v1_orc_hidden_equality_deletes_before_and_after_int_to_long_promotion) {
+ check_equality_deletes_match_promoted_key(TFileFormatType::FORMAT_ORC,
false, true,
+ &timezone_obj, cache.get());
+}
+
+// A nested key promoted from int to long: data `id, payload STRUCT<existing>`
holds (1,{10}),
+// (2,{20}) and (3,{30}) with payload.existing still int32 in the file, and a
delete written after
+// the promotion holds payload.existing = 20 as BIGINT. The query projects
only `id`, so the key is
+// extracted from the physical payload root and must be widened before it is
compared; copying the
+// INT values into a BIGINT column read the wrong bytes.
+void check_nested_equality_delete_after_promotion_reads_older_file(
+ TFileFormatType::type file_format, const cctz::time_zone* timezone,
+ FileMetaCache* meta_cache) {
+ const bool is_parquet = file_format == TFileFormatType::FORMAT_PARQUET;
+ const std::string format_name = is_parquet ? "parquet" : "orc";
+ SCOPED_TRACE(format_name);
+ const auto test_dir = std::filesystem::temp_directory_path() /
+ ("doris_v1_nested_equality_delete_promoted_key_" +
format_name);
+ std::filesystem::remove_all(test_dir);
+ std::filesystem::create_directories(test_dir);
+ const auto data_file = (test_dir / ("data." + format_name)).string();
+ const auto delete_file = (test_dir / ("equality-delete." +
format_name)).string();
+ if (is_parquet) {
+ write_iceberg_id_and_nested_struct_parquet_file(data_file, {1, 2, 3},
{10, 20, 30},
+ {false, false, false});
+ write_iceberg_bigint_nested_struct_parquet_file(delete_file, {20});
+ } else {
+ write_iceberg_id_and_nested_struct_orc_file(data_file, {1, 2, 3}, {10,
20, 30},
+ {false, false, false});
+ write_iceberg_nested_struct_orc_file(delete_file, {20}, {false},
"existing", 2, "bigint");
+ }
+
+ schema::external::TStructField current_root;
+ current_root.__set_fields({make_external_int_field("id", 0, std::nullopt),
+ make_external_struct_field(
+ "payload", 1,
{make_external_bigint_field("existing", 2)})});
+ ObjectPool object_pool;
+ DescriptorTbl* descriptor_table = nullptr;
+ const TupleDescriptor* tuple_descriptor = nullptr;
+ ASSERT_TRUE(create_single_int_tuple_descriptor(&object_pool, "id", 0,
&descriptor_table,
+ &tuple_descriptor)
+ .ok());
+ const auto int_type = make_nullable(std::make_shared<DataTypeInt32>());
+ Block block;
+ block.insert({int_type->create_column(), int_type, "id"});
+ const Status status = read_v1_with_equality_deletes(
+ file_format, data_file, {{delete_file, {2}}},
std::move(current_root), tuple_descriptor,
+ &block, timezone, meta_cache);
+
+ ASSERT_TRUE(status.ok()) << status;
+ ASSERT_EQ(block.rows(), 2);
+ EXPECT_EQ(int_type->to_string(*block.get_by_position(0).column, 0), "1");
+ EXPECT_EQ(int_type->to_string(*block.get_by_position(0).column, 1), "3");
+ std::filesystem::remove_all(test_dir);
+}
+
+TEST_F(IcebergReaderTest,
v1_parquet_nested_equality_delete_after_promotion_reads_older_file) {
+
check_nested_equality_delete_after_promotion_reads_older_file(TFileFormatType::FORMAT_PARQUET,
+
&timezone_obj, cache.get());
+}
+
+TEST_F(IcebergReaderTest,
v1_orc_nested_equality_delete_after_promotion_reads_older_file) {
+
check_nested_equality_delete_after_promotion_reads_older_file(TFileFormatType::FORMAT_ORC,
+
&timezone_obj, cache.get());
+}
+
+// The key is compared in the promoted type, not in the delete file's older
one. A data file written
+// after `id` became long holds NULL, 0, 1 and 4294967296, and a delete
written before the promotion
+// holds the NULL key as INT. Narrowed to INT, 4294967296 would become NULL
and be deleted by that
+// key; compared as BIGINT, only the NULL row is deleted.
+TEST_F(IcebergReaderTest,
v1_parquet_equality_delete_keeps_out_of_range_promoted_value) {
+ const auto test_dir = std::filesystem::temp_directory_path() /
+
"doris_v1_equality_delete_out_of_range_promoted_value";
+ std::filesystem::remove_all(test_dir);
+ std::filesystem::create_directories(test_dir);
+ const auto data_file = (test_dir / "data.parquet").string();
+ const auto delete_file = (test_dir / "equality-delete.parquet").string();
+ write_iceberg_single_integer_parquet_file(data_file, "id", 0, true,
+ {std::nullopt, 0, 1,
4294967296});
+ write_iceberg_single_integer_parquet_file(delete_file, "id", 0, false,
{std::nullopt});
+
+ schema::external::TStructField current_root;
+ current_root.__set_fields({make_external_bigint_field("id", 0)});
+ ObjectPool object_pool;
+ DescriptorTbl* descriptor_table = nullptr;
+ const TupleDescriptor* tuple_descriptor = nullptr;
+ ASSERT_TRUE(create_single_scalar_tuple_descriptor(&object_pool, "id", 0,
TPrimitiveType::BIGINT,
+ &descriptor_table,
&tuple_descriptor)
+ .ok());
+ const auto bigint_type = make_nullable(std::make_shared<DataTypeInt64>());
+ Block block;
+ block.insert({bigint_type->create_column(), bigint_type, "id"});
+ const Status status = read_v1_with_equality_deletes(
+ TFileFormatType::FORMAT_PARQUET, data_file, {{delete_file, {0}}},
+ std::move(current_root), tuple_descriptor, &block, &timezone_obj,
cache.get());
+
+ ASSERT_TRUE(status.ok()) << status;
+ ASSERT_EQ(block.rows(), 3);
+ EXPECT_EQ(bigint_type->to_string(*block.get_by_position(0).column, 0),
"0");
+ EXPECT_EQ(bigint_type->to_string(*block.get_by_position(0).column, 1),
"1");
+ EXPECT_EQ(bigint_type->to_string(*block.get_by_position(0).column, 2),
"4294967296");
+ std::filesystem::remove_all(test_dir);
+}
+
// Data `id INT, payload STRUCT<existing INT, k INT>` holds (1,{10,100}),
(2,{20,200}) and
// (3,{30,300}). An equality delete on `delete_field_ids` (2 =
payload.existing, 3 = payload.k)
// with the row {20,200} removes id 2. `projected_children` lists the payload
children the query
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]