mirror of
https://github.com/apache/doris.git
synced 2026-11-04 07:31:17 +00:00
[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 |
This commit is contained in:
1 parent
5b3d224421
commit
2760a7663b
5 files changed
+517
-21
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user