This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new b75649609ef [fix](be) Fix deletion vector cache identity (#65281)
b75649609ef is described below
commit b75649609ef90e10d45619ca939d1aaefbaa41b5
Author: Gabriel <[email protected]>
AuthorDate: Wed Jul 8 09:50:03 2026 +0800
[fix](be) Fix deletion vector cache identity (#65281)
---
.../table/iceberg_delete_file_reader_helper.cpp | 15 +-
.../table/iceberg_delete_file_reader_helper.h | 6 +
be/src/format/table/iceberg_reader_mixin.h | 86 +---
be/src/format/table/paimon_reader.cpp | 264 +++++------
be/src/format/table/paimon_reader.h | 9 +
be/src/format_v2/table/iceberg_reader.cpp | 36 +-
be/src/format_v2/table/iceberg_reader.h | 2 -
be/src/format_v2/table/paimon_reader.cpp | 11 +-
be/src/format_v2/table/paimon_reader.h | 4 +
be/src/format_v2/table_reader.cpp | 56 +--
.../iceberg_delete_file_reader_helper_test.cpp | 48 ++
be/test/format/table/paimon_cpp_reader_test.cpp | 106 +++++
be/test/format_v2/table/iceberg_reader_test.cpp | 51 ++-
be/test/format_v2/table/paimon_reader_test.cpp | 102 ++++-
.../docker-compose/iceberg/iceberg.yaml.tpl | 17 +
.../create_preinstalled_scripts/iceberg/run28.sql | 50 ++
.../iceberg/test_iceberg_deletion_vector.out | 119 +++++
.../paimon/test_paimon_deletion_vector.out | 24 +
.../iceberg/test_iceberg_deletion_vector.groovy | 510 +++++++++++++++++++++
.../paimon/test_paimon_deletion_vector.groovy | 14 +-
20 files changed, 1230 insertions(+), 300 deletions(-)
diff --git a/be/src/format/table/iceberg_delete_file_reader_helper.cpp
b/be/src/format/table/iceberg_delete_file_reader_helper.cpp
index c0fe46e1d35..5845e440d95 100644
--- a/be/src/format/table/iceberg_delete_file_reader_helper.cpp
+++ b/be/src/format/table/iceberg_delete_file_reader_helper.cpp
@@ -17,6 +17,7 @@
#include "format/table/iceberg_delete_file_reader_helper.h"
+#include <fmt/format.h>
#include <gen_cpp/parquet_types.h>
#include <parallel_hashmap/phmap.h>
@@ -216,6 +217,13 @@ bool is_iceberg_deletion_vector(const
TIcebergDeleteFileDesc& delete_file) {
return delete_file.__isset.content && delete_file.content == 3;
}
+std::string build_iceberg_deletion_vector_cache_key(const std::string&
data_file_path,
+ const
TIcebergDeleteFileDesc& delete_file) {
+ return fmt::format("delete_dv_{}:{}{}:{}#{}#{}", data_file_path.size(),
data_file_path,
+ delete_file.path.size(), delete_file.path,
delete_file.content_offset,
+ delete_file.content_size_in_bytes);
+}
+
Status read_iceberg_position_delete_file(const TIcebergDeleteFileDesc&
delete_file,
const IcebergDeleteFileReaderOptions&
options,
IcebergPositionDeleteVisitor*
visitor) {
@@ -308,7 +316,12 @@ Status read_iceberg_deletion_vector(const
TIcebergDeleteFileDesc& delete_file,
std::vector<char> buf(delete_range.size);
RETURN_IF_ERROR(dv_reader.read_at(delete_range.start_offset,
{buf.data(),
cast_set<size_t>(delete_range.size)}));
- return decode_deletion_vector_buffer(buf.data(), delete_range.size,
rows_to_delete);
+ return decode_iceberg_deletion_vector_buffer(buf.data(),
delete_range.size, rows_to_delete);
+}
+
+Status decode_iceberg_deletion_vector_buffer(const char* buf, size_t
buffer_size,
+ roaring::Roaring64Map*
rows_to_delete) {
+ return decode_deletion_vector_buffer(buf, buffer_size, rows_to_delete);
}
} // namespace doris
diff --git a/be/src/format/table/iceberg_delete_file_reader_helper.h
b/be/src/format/table/iceberg_delete_file_reader_helper.h
index a6771212851..95109384076 100644
--- a/be/src/format/table/iceberg_delete_file_reader_helper.h
+++ b/be/src/format/table/iceberg_delete_file_reader_helper.h
@@ -67,6 +67,12 @@ TFileRangeDesc build_iceberg_delete_file_range(const
std::string& path);
bool is_iceberg_deletion_vector(const TIcebergDeleteFileDesc& delete_file);
+std::string build_iceberg_deletion_vector_cache_key(const std::string&
data_file_path,
+ const
TIcebergDeleteFileDesc& delete_file);
+
+Status decode_iceberg_deletion_vector_buffer(const char* buf, size_t
buffer_size,
+ roaring::Roaring64Map*
rows_to_delete);
+
Status read_iceberg_position_delete_file(const TIcebergDeleteFileDesc&
delete_file,
const IcebergDeleteFileReaderOptions&
options,
IcebergPositionDeleteVisitor*
visitor);
diff --git a/be/src/format/table/iceberg_reader_mixin.h
b/be/src/format/table/iceberg_reader_mixin.h
index c88703325a8..f27fa83467c 100644
--- a/be/src/format/table/iceberg_reader_mixin.h
+++ b/be/src/format/table/iceberg_reader_mixin.h
@@ -33,8 +33,8 @@
#include "core/data_type/data_type_number.h"
#include "core/data_type/data_type_string.h"
#include "format/generic_reader.h"
-#include "format/table/deletion_vector_reader.h"
#include "format/table/equality_delete.h"
+#include "format/table/iceberg_delete_file_reader_helper.h"
#include "format/table/table_schema_change_helper.h"
#include "runtime/runtime_profile.h"
#include "runtime/runtime_state.h"
@@ -805,67 +805,31 @@ Status
IcebergReaderMixin<BaseReader>::read_deletion_vector(
const std::string& data_file_path, const TIcebergDeleteFileDesc&
delete_file_desc) {
Status create_status = Status::OK();
SCOPED_TIMER(_iceberg_profile.delete_files_read_time);
- _iceberg_delete_rows = _kv_cache->template get<
- DeleteRows>(data_file_path, [&]() -> DeleteRows* {
- auto* delete_rows = new DeleteRows;
-
- TFileRangeDesc delete_range;
- delete_range.__set_fs_name(this->get_scan_range().fs_name);
- delete_range.path = delete_file_desc.path;
- delete_range.start_offset = delete_file_desc.content_offset;
- delete_range.size = delete_file_desc.content_size_in_bytes;
- delete_range.file_size = -1;
-
- DeletionVectorReader dv_reader(this->get_state(), this->get_profile(),
- this->get_scan_params(), delete_range,
this->get_io_ctx());
- create_status = dv_reader.open();
- if (!create_status.ok()) [[unlikely]] {
- return nullptr;
- }
-
- size_t buffer_size = delete_range.size;
- std::vector<char> buf(buffer_size);
- if (buffer_size < 12) [[unlikely]] {
- create_status = Status::DataQualityError("Deletion vector file
size too small: {}",
- buffer_size);
- return nullptr;
- }
-
- create_status = dv_reader.read_at(delete_range.start_offset,
{buf.data(), buffer_size});
- if (!create_status) [[unlikely]] {
- return nullptr;
- }
-
- auto total_length = BigEndian::Load32(buf.data());
- if (total_length + 8 != buffer_size) [[unlikely]] {
- create_status = Status::DataQualityError(
- "Deletion vector length mismatch, expected: {}, actual:
{}", total_length + 8,
- buffer_size);
- return nullptr;
- }
-
- constexpr static char MAGIC_NUMBER[] = {'\xD1', '\xD3', '\x39',
'\x64'};
- if (memcmp(buf.data() + sizeof(total_length), MAGIC_NUMBER, 4))
[[unlikely]] {
- create_status = Status::DataQualityError("Deletion vector magic
number mismatch");
- return nullptr;
- }
-
- roaring::Roaring64Map bitmap;
- SCOPED_TIMER(_iceberg_profile.parse_delete_file_time);
- try {
- bitmap = roaring::Roaring64Map::readSafe(buf.data() + 8,
buffer_size - 12);
- } catch (const std::runtime_error& e) {
- create_status = Status::DataQualityError("Decode roaring bitmap
failed, {}", e.what());
- return nullptr;
- }
+ _iceberg_delete_rows = _kv_cache->template get<DeleteRows>(
+ build_iceberg_deletion_vector_cache_key(data_file_path,
delete_file_desc),
+ [&]() -> DeleteRows* {
+ auto* delete_rows = new DeleteRows;
+
+ roaring::Roaring64Map bitmap;
+ IcebergDeleteFileReaderOptions options;
+ options.state = this->get_state();
+ options.profile = this->get_profile();
+ options.scan_params = &this->get_scan_params();
+ options.io_ctx = this->get_io_ctx();
+ options.fs_name = &this->get_scan_range().fs_name;
+ create_status = read_iceberg_deletion_vector(delete_file_desc,
options, &bitmap);
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
- delete_rows->reserve(bitmap.cardinality());
- for (auto it = bitmap.begin(); it != bitmap.end(); it++) {
- delete_rows->push_back(*it);
- }
- COUNTER_UPDATE(_iceberg_profile.num_delete_rows, delete_rows->size());
- return delete_rows;
- });
+ SCOPED_TIMER(_iceberg_profile.parse_delete_file_time);
+ delete_rows->reserve(bitmap.cardinality());
+ for (auto it = bitmap.begin(); it != bitmap.end(); it++) {
+ delete_rows->push_back(*it);
+ }
+ COUNTER_UPDATE(_iceberg_profile.num_delete_rows,
delete_rows->size());
+ return delete_rows;
+ });
RETURN_IF_ERROR(create_status);
if (!_iceberg_delete_rows->empty()) [[likely]] {
diff --git a/be/src/format/table/paimon_reader.cpp
b/be/src/format/table/paimon_reader.cpp
index 59a448def18..98f85658e32 100644
--- a/be/src/format/table/paimon_reader.cpp
+++ b/be/src/format/table/paimon_reader.cpp
@@ -17,14 +17,64 @@
#include "format/table/paimon_reader.h"
+#include <fmt/format.h>
+
+#include <cstring>
#include <vector>
#include "common/status.h"
+#include "exec/common/endian.h"
#include "format/table/deletion_vector_reader.h"
#include "runtime/runtime_state.h"
namespace doris {
+namespace {
+
+constexpr static char PAIMON_BITMAP_MAGIC[] = {'\x5E', '\x43', '\xF2', '\xD0'};
+
+} // namespace
+
+std::string build_paimon_deletion_vector_cache_key(const
TPaimonDeletionFileDesc& deletion_file) {
+ return fmt::format("paimon_dv_{}#{}#{}", deletion_file.path,
deletion_file.offset,
+ deletion_file.length);
+}
+
+Status decode_paimon_deletion_vector_buffer(const char* buf, size_t
buffer_size,
+ std::vector<int64_t>* delete_rows)
{
+ if (buffer_size < 8) [[unlikely]] {
+ return Status::DataQualityError("Deletion vector file size too small:
{}", buffer_size);
+ }
+
+ const uint32_t actual_length = BigEndian::Load32(buf);
+ if (actual_length + 4 != buffer_size) [[unlikely]] {
+ return Status::RuntimeError(
+ "DeletionVector deserialize error: length not match, "
+ "actual length: {}, expect length: {}",
+ actual_length, buffer_size - 4);
+ }
+
+ if (memcmp(buf + sizeof(actual_length), PAIMON_BITMAP_MAGIC, 4) != 0)
[[unlikely]] {
+ return Status::RuntimeError("DeletionVector deserialize error: invalid
magic number {}",
+ BigEndian::Load32(buf +
sizeof(actual_length)));
+ }
+
+ roaring::Roaring roaring_bitmap;
+ try {
+ roaring_bitmap = roaring::Roaring::readSafe(buf + 8, buffer_size - 8);
+ } catch (const std::runtime_error& e) {
+ return Status::RuntimeError(
+ "DeletionVector deserialize error: failed to deserialize
roaring bitmap, {}",
+ e.what());
+ }
+
+ delete_rows->reserve(roaring_bitmap.cardinality());
+ for (auto it = roaring_bitmap.begin(); it != roaring_bitmap.end(); it++) {
+ delete_rows->push_back(*it);
+ }
+ return Status::OK();
+}
+
// ============================================================================
// PaimonOrcReader
// ============================================================================
@@ -80,80 +130,43 @@ Status PaimonOrcReader::_init_deletion_vector() {
Status create_status = Status::OK();
- std::string key;
- key.resize(deletion_file.path.size() + sizeof(deletion_file.offset));
- memcpy(key.data(), deletion_file.path.data(), deletion_file.path.size());
- memcpy(key.data() + deletion_file.path.size(), &deletion_file.offset,
- sizeof(deletion_file.offset));
-
SCOPED_TIMER(_paimon_profile.delete_files_read_time);
using DeleteRows = std::vector<int64_t>;
- _delete_rows = _kv_cache->get<DeleteRows>(key, [&]() -> DeleteRows* {
- auto* delete_rows = new DeleteRows;
-
- TFileRangeDesc delete_range;
- delete_range.__set_fs_name(get_scan_range().fs_name);
- delete_range.path = deletion_file.path;
- delete_range.start_offset = deletion_file.offset;
- delete_range.size = deletion_file.length + 4;
- delete_range.file_size = -1;
-
- DeletionVectorReader dv_reader(get_state(), get_profile(),
get_scan_params(), delete_range,
- get_io_ctx());
- create_status = dv_reader.open();
- if (!create_status.ok()) [[unlikely]] {
- return nullptr;
- }
-
- size_t bytes_read = deletion_file.length + 4;
- std::vector<char> buffer(bytes_read);
- create_status = dv_reader.read_at(deletion_file.offset,
{buffer.data(), bytes_read});
- if (!create_status.ok()) [[unlikely]] {
- return nullptr;
- }
-
- const char* buf = buffer.data();
- uint32_t actual_length;
- std::memcpy(reinterpret_cast<char*>(&actual_length), buf, 4);
- std::reverse(reinterpret_cast<char*>(&actual_length),
- reinterpret_cast<char*>(&actual_length) + 4);
- buf += 4;
- if (actual_length != bytes_read - 4) [[unlikely]] {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: length not match, "
- "actual length: {}, expect length: {}",
- actual_length, bytes_read - 4);
- return nullptr;
- }
- uint32_t magic_number;
- std::memcpy(reinterpret_cast<char*>(&magic_number), buf, 4);
- std::reverse(reinterpret_cast<char*>(&magic_number),
- reinterpret_cast<char*>(&magic_number) + 4);
- buf += 4;
- const static uint32_t MAGIC_NUMBER = 1581511376;
- if (magic_number != MAGIC_NUMBER) [[unlikely]] {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: invalid magic number
{}", magic_number);
- return nullptr;
- }
-
- roaring::Roaring roaring_bitmap;
- SCOPED_TIMER(_paimon_profile.parse_deletion_vector_time);
- try {
- roaring_bitmap = roaring::Roaring::readSafe(buf, bytes_read - 4);
- } catch (const std::runtime_error& e) {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: failed to deserialize
roaring bitmap, {}",
- e.what());
- return nullptr;
- }
- delete_rows->reserve(roaring_bitmap.cardinality());
- for (auto it = roaring_bitmap.begin(); it != roaring_bitmap.end();
it++) {
- delete_rows->push_back(*it);
- }
- COUNTER_UPDATE(_paimon_profile.num_delete_rows, delete_rows->size());
- return delete_rows;
- });
+ _delete_rows = _kv_cache->get<DeleteRows>(
+ build_paimon_deletion_vector_cache_key(deletion_file), [&]() ->
DeleteRows* {
+ auto* delete_rows = new DeleteRows;
+
+ TFileRangeDesc delete_range;
+ delete_range.__set_fs_name(get_scan_range().fs_name);
+ delete_range.path = deletion_file.path;
+ delete_range.start_offset = deletion_file.offset;
+ delete_range.size = deletion_file.length + 4;
+ delete_range.file_size = -1;
+
+ DeletionVectorReader dv_reader(get_state(), get_profile(),
get_scan_params(),
+ delete_range, get_io_ctx());
+ create_status = dv_reader.open();
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+
+ size_t bytes_read = deletion_file.length + 4;
+ std::vector<char> buffer(bytes_read);
+ create_status =
+ dv_reader.read_at(deletion_file.offset,
{buffer.data(), bytes_read});
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+
+ SCOPED_TIMER(_paimon_profile.parse_deletion_vector_time);
+ create_status =
decode_paimon_deletion_vector_buffer(buffer.data(), bytes_read,
+
delete_rows);
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+ COUNTER_UPDATE(_paimon_profile.num_delete_rows,
delete_rows->size());
+ return delete_rows;
+ });
RETURN_IF_ERROR(create_status);
if (!_delete_rows->empty()) [[likely]] {
set_position_delete_rowids(_delete_rows);
@@ -216,80 +229,43 @@ Status PaimonParquetReader::_init_deletion_vector() {
Status create_status = Status::OK();
- std::string key;
- key.resize(deletion_file.path.size() + sizeof(deletion_file.offset));
- memcpy(key.data(), deletion_file.path.data(), deletion_file.path.size());
- memcpy(key.data() + deletion_file.path.size(), &deletion_file.offset,
- sizeof(deletion_file.offset));
-
SCOPED_TIMER(_paimon_profile.delete_files_read_time);
using DeleteRows = std::vector<int64_t>;
- _delete_rows = _kv_cache->get<DeleteRows>(key, [&]() -> DeleteRows* {
- auto* delete_rows = new DeleteRows;
-
- TFileRangeDesc delete_range;
- delete_range.__set_fs_name(get_scan_range().fs_name);
- delete_range.path = deletion_file.path;
- delete_range.start_offset = deletion_file.offset;
- delete_range.size = deletion_file.length + 4;
- delete_range.file_size = -1;
-
- DeletionVectorReader dv_reader(get_state(), get_profile(),
get_scan_params(), delete_range,
- get_io_ctx());
- create_status = dv_reader.open();
- if (!create_status.ok()) [[unlikely]] {
- return nullptr;
- }
-
- size_t bytes_read = deletion_file.length + 4;
- std::vector<char> buffer(bytes_read);
- create_status = dv_reader.read_at(deletion_file.offset,
{buffer.data(), bytes_read});
- if (!create_status.ok()) [[unlikely]] {
- return nullptr;
- }
-
- const char* buf = buffer.data();
- uint32_t actual_length;
- std::memcpy(reinterpret_cast<char*>(&actual_length), buf, 4);
- std::reverse(reinterpret_cast<char*>(&actual_length),
- reinterpret_cast<char*>(&actual_length) + 4);
- buf += 4;
- if (actual_length != bytes_read - 4) [[unlikely]] {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: length not match, "
- "actual length: {}, expect length: {}",
- actual_length, bytes_read - 4);
- return nullptr;
- }
- uint32_t magic_number;
- std::memcpy(reinterpret_cast<char*>(&magic_number), buf, 4);
- std::reverse(reinterpret_cast<char*>(&magic_number),
- reinterpret_cast<char*>(&magic_number) + 4);
- buf += 4;
- const static uint32_t MAGIC_NUMBER = 1581511376;
- if (magic_number != MAGIC_NUMBER) [[unlikely]] {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: invalid magic number
{}", magic_number);
- return nullptr;
- }
-
- roaring::Roaring roaring_bitmap;
- SCOPED_TIMER(_paimon_profile.parse_deletion_vector_time);
- try {
- roaring_bitmap = roaring::Roaring::readSafe(buf, bytes_read - 4);
- } catch (const std::runtime_error& e) {
- create_status = Status::RuntimeError(
- "DeletionVector deserialize error: failed to deserialize
roaring bitmap, {}",
- e.what());
- return nullptr;
- }
- delete_rows->reserve(roaring_bitmap.cardinality());
- for (auto it = roaring_bitmap.begin(); it != roaring_bitmap.end();
it++) {
- delete_rows->push_back(*it);
- }
- COUNTER_UPDATE(_paimon_profile.num_delete_rows, delete_rows->size());
- return delete_rows;
- });
+ _delete_rows = _kv_cache->get<DeleteRows>(
+ build_paimon_deletion_vector_cache_key(deletion_file), [&]() ->
DeleteRows* {
+ auto* delete_rows = new DeleteRows;
+
+ TFileRangeDesc delete_range;
+ delete_range.__set_fs_name(get_scan_range().fs_name);
+ delete_range.path = deletion_file.path;
+ delete_range.start_offset = deletion_file.offset;
+ delete_range.size = deletion_file.length + 4;
+ delete_range.file_size = -1;
+
+ DeletionVectorReader dv_reader(get_state(), get_profile(),
get_scan_params(),
+ delete_range, get_io_ctx());
+ create_status = dv_reader.open();
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+
+ size_t bytes_read = deletion_file.length + 4;
+ std::vector<char> buffer(bytes_read);
+ create_status =
+ dv_reader.read_at(deletion_file.offset,
{buffer.data(), bytes_read});
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+
+ SCOPED_TIMER(_paimon_profile.parse_deletion_vector_time);
+ create_status =
decode_paimon_deletion_vector_buffer(buffer.data(), bytes_read,
+
delete_rows);
+ if (!create_status.ok()) [[unlikely]] {
+ return nullptr;
+ }
+ COUNTER_UPDATE(_paimon_profile.num_delete_rows,
delete_rows->size());
+ return delete_rows;
+ });
RETURN_IF_ERROR(create_status);
if (!_delete_rows->empty()) [[likely]] {
ParquetReader::set_delete_rows(_delete_rows);
diff --git a/be/src/format/table/paimon_reader.h
b/be/src/format/table/paimon_reader.h
index 42a5d211a9b..10da0b784e2 100644
--- a/be/src/format/table/paimon_reader.h
+++ b/be/src/format/table/paimon_reader.h
@@ -17,10 +17,14 @@
#pragma once
+#include <gen_cpp/PlanNodes_types.h>
+
#include <memory>
+#include <string>
#include <utility>
#include <vector>
+#include "common/status.h"
#include "format/orc/vorc_reader.h"
#include "format/parquet/vparquet_reader.h"
#include "format/table/table_schema_change_helper.h"
@@ -28,6 +32,11 @@
namespace doris {
class ShardedKVCache;
+std::string build_paimon_deletion_vector_cache_key(const
TPaimonDeletionFileDesc& deletion_file);
+
+Status decode_paimon_deletion_vector_buffer(const char* buf, size_t
buffer_size,
+ std::vector<int64_t>* delete_rows);
+
// PaimonOrcReader: directly inherits OrcReader (no composition wrapping).
// Schema mapping in on_before_init_reader, deletion vector reading in
on_after_init_reader.
class PaimonOrcReader final : public OrcReader, public TableSchemaChangeHelper
{
diff --git a/be/src/format_v2/table/iceberg_reader.cpp
b/be/src/format_v2/table/iceberg_reader.cpp
index ccc100f0504..eb1172ed358 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -18,7 +18,6 @@
#include "format_v2/table/iceberg_reader.h"
#include <algorithm>
-#include <cstring>
#include <memory>
#include <sstream>
#include <utility>
@@ -323,7 +322,10 @@ Status
IcebergTableReader::_parse_deletion_vector_file(const TTableFormatFileDes
return Status::InternalError("Deletion vector is missing content
offset or length");
}
- desc->key = _iceberg_delete_vector_cache_key(*deletion_vector);
+ const std::string data_file_path =
iceberg_params.__isset.original_file_path
+ ?
iceberg_params.original_file_path
+ : _data_file_path();
+ desc->key = build_iceberg_deletion_vector_cache_key(data_file_path,
*deletion_vector);
desc->path = deletion_vector->path;
desc->start_offset = deletion_vector->content_offset;
desc->size = deletion_vector->content_size_in_bytes;
@@ -358,16 +360,17 @@ Status IcebergTableReader::_init_delete_predicates(const
TTableFormatFileDesc& t
equality_delete_files.push_back(delete_file);
}
}
- // `_delete_rows != nullptr` means DeleteVector is parsed
+ // `_delete_rows != nullptr` means a deletion vector is parsed. Per
Iceberg scan planning,
+ // position delete files apply only when there is no deletion vector for
the data file.
if (_delete_rows != nullptr) {
_position_delete_rows_storage = *_delete_rows;
_delete_rows = &_position_delete_rows_storage;
+ position_delete_files.clear();
}
- // Combine position delete rows from both deletion vector and position
delete files, and
- // initialize equality delete predicates. Position delete files contain
row positions of
- // deleted rows, which can be directly added to `_delete_rows`. Equality
delete files contain
- // values of deleted rows, which require reading the files and building
predicates for later
- // filtering.
+ // Initialize position and equality delete predicates. Position delete
files contain row
+ // positions of deleted rows, which can be directly added to
`_delete_rows`. Equality delete
+ // files contain values of deleted rows, which require reading the files
and building
+ // predicates for later filtering.
if (!position_delete_files.empty()) {
RETURN_IF_ERROR(_init_position_delete_rows(position_delete_files));
}
@@ -379,23 +382,6 @@ Status IcebergTableReader::_init_delete_predicates(const
TTableFormatFileDesc& t
return Status::OK();
}
-std::string IcebergTableReader::_iceberg_delete_vector_cache_key(
- const TIcebergDeleteFileDesc& delete_file) {
- const std::string key_prefix = "iceberg_dv:";
- std::string key;
- key.resize(key_prefix.size() + delete_file.path.size() +
sizeof(delete_file.content_offset) +
- sizeof(delete_file.content_size_in_bytes));
- char* data = key.data();
- memcpy(data, key_prefix.data(), key_prefix.size());
- data += key_prefix.size();
- memcpy(data, delete_file.path.data(), delete_file.path.size());
- data += delete_file.path.size();
- memcpy(data, &delete_file.content_offset,
sizeof(delete_file.content_offset));
- data += sizeof(delete_file.content_offset);
- memcpy(data, &delete_file.content_size_in_bytes,
sizeof(delete_file.content_size_in_bytes));
- return key;
-}
-
std::shared_ptr<io::FileSystemProperties>
IcebergTableReader::_delete_file_system_properties(
const TFileScanRangeParams& scan_params) {
auto system_properties = std::make_shared<io::FileSystemProperties>();
diff --git a/be/src/format_v2/table/iceberg_reader.h
b/be/src/format_v2/table/iceberg_reader.h
index 1a2811ef968..15e03342d4b 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -119,8 +119,6 @@ private:
format::DeleteRows* _rows = nullptr;
};
- static std::string _iceberg_delete_vector_cache_key(const
TIcebergDeleteFileDesc& delete_file);
-
static std::shared_ptr<io::FileSystemProperties>
_delete_file_system_properties(
const TFileScanRangeParams& scan_params);
diff --git a/be/src/format_v2/table/paimon_reader.cpp
b/be/src/format_v2/table/paimon_reader.cpp
index 5b79b743fd3..c1a0f441950 100644
--- a/be/src/format_v2/table/paimon_reader.cpp
+++ b/be/src/format_v2/table/paimon_reader.cpp
@@ -19,12 +19,12 @@
#include <glog/logging.h>
-#include <cstring>
#include <string>
#include <utility>
#include "exprs/vexpr_context.h"
#include "format/table/deletion_vector_reader.h"
+#include "format/table/paimon_reader.h"
#include "format_v2/column_mapper.h"
#include "format_v2/jni/paimon_jni_reader.h"
#include "format_v2/table/schema_history_util.h"
@@ -66,14 +66,7 @@ Status PaimonReader::_parse_deletion_vector_file(const
TTableFormatFileDesc& t_d
}
const auto& deletion_file = table_desc.deletion_file;
- const std::string key_prefix = "paimon_dv:";
- desc->key.resize(key_prefix.size() + deletion_file.path.size() +
sizeof(deletion_file.offset));
- char* key_data = desc->key.data();
- memcpy(key_data, key_prefix.data(), key_prefix.size());
- key_data += key_prefix.size();
- memcpy(key_data, deletion_file.path.data(), deletion_file.path.size());
- key_data += deletion_file.path.size();
- memcpy(key_data, &deletion_file.offset, sizeof(deletion_file.offset));
+ desc->key = build_paimon_deletion_vector_cache_key(deletion_file);
desc->path = deletion_file.path;
desc->start_offset = deletion_file.offset;
desc->size = deletion_file.length + 4;
diff --git a/be/src/format_v2/table/paimon_reader.h
b/be/src/format_v2/table/paimon_reader.h
index 200c4e885b5..a7aa227ddb4 100644
--- a/be/src/format_v2/table/paimon_reader.h
+++ b/be/src/format_v2/table/paimon_reader.h
@@ -36,6 +36,10 @@ public:
Status TEST_annotate_file_schema(std::vector<format::ColumnDefinition>*
file_schema) {
return annotate_file_schema(file_schema);
}
+ Status TEST_parse_deletion_vector_file(const TTableFormatFileDesc& t_desc,
DeleteFileDesc* desc,
+ bool* has_delete_file) {
+ return _parse_deletion_vector_file(t_desc, desc, has_delete_file);
+ }
#endif
protected:
diff --git a/be/src/format_v2/table_reader.cpp
b/be/src/format_v2/table_reader.cpp
index 11317c5fd7b..e1bd6b04ad2 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -22,11 +22,9 @@
#include <gen_cpp/Types_types.h>
#include <algorithm>
-#include <cstring>
#include <ranges>
#include <set>
#include <sstream>
-#include <stdexcept>
#include <utility>
#include <vector>
@@ -36,17 +34,17 @@
#include "core/data_type/data_type_array.h"
#include "core/data_type/data_type_map.h"
#include "core/data_type/data_type_struct.h"
-#include "exec/common/endian.h"
#include "exprs/vexpr_context.h"
#include "exprs/vslot_ref.h"
#include "format/table/deletion_vector_reader.h"
+#include "format/table/iceberg_delete_file_reader_helper.h"
+#include "format/table/paimon_reader.h"
#include "format_v2/column_mapper.h"
#include "format_v2/delimited_text/csv_reader.h"
#include "format_v2/delimited_text/text_reader.h"
#include "format_v2/json/json_reader.h"
#include "format_v2/native/native_reader.h"
#include "format_v2/parquet/parquet_reader.h"
-#include "roaring/roaring64map.hh"
#include "storage/segment/condition_cache.h"
#include "util/string_util.h"
@@ -362,59 +360,13 @@ Status parse_deletion_vector(const char* buf, size_t
buffer_size, DeleteFileDesc
DORIS_CHECK(format == DeleteFileDesc::Format::PAIMON ||
format == DeleteFileDesc::Format::ICEBERG);
- const size_t checksum_size = format == DeleteFileDesc::Format::ICEBERG ? 4
: 0;
- if (buffer_size < 8 + checksum_size) [[unlikely]] {
- return Status::DataQualityError("Deletion vector file size too small:
{}", buffer_size);
- }
-
- auto total_length = BigEndian::Load32(buf);
- if (total_length + 4 + checksum_size != buffer_size) [[unlikely]] {
- return Status::DataQualityError("Deletion vector length mismatch,
expected: {}, actual: {}",
- total_length + 4 + checksum_size,
buffer_size);
- }
-
- const char* bitmap_buf = buf + 8;
- const size_t bitmap_size = buffer_size - 8 - checksum_size;
if (format == DeleteFileDesc::Format::PAIMON) {
- // Paimon BitmapDeletionVector stores:
- // [4-byte big-endian length][4-byte magic 0x5E43F2D0][32-bit
roaring bitmap]
- // The length covers magic + bitmap, and does not include the leading
length field.
- constexpr static char PAIMON_BITMAP_MAGIC[] = {'\x5E', '\x43', '\xF2',
'\xD0'};
- if (memcmp(buf + sizeof(total_length), PAIMON_BITMAP_MAGIC, 4) != 0)
[[unlikely]] {
- return Status::DataQualityError(
- "Paimon deletion vector magic number mismatch, expected:
{}, actual: {}",
- BigEndian::Load32(PAIMON_BITMAP_MAGIC),
- BigEndian::Load32(buf + sizeof(total_length)));
- }
-
- roaring::Roaring bitmap;
- try {
- bitmap = roaring::Roaring::readSafe(bitmap_buf, bitmap_size);
- } catch (const std::runtime_error& e) {
- return Status::DataQualityError("Decode roaring bitmap failed,
{}", e.what());
- }
-
- delete_rows->reserve(bitmap.cardinality());
- for (auto it = bitmap.begin(); it != bitmap.end(); it++) {
- delete_rows->push_back(*it);
- }
+ RETURN_IF_ERROR(decode_paimon_deletion_vector_buffer(buf, buffer_size,
delete_rows));
return Status::OK();
}
- constexpr static char ICEBERG_DV_MAGIC[] = {'\xD1', '\xD3', '\x39',
'\x64'};
- if (memcmp(buf + sizeof(total_length), ICEBERG_DV_MAGIC, 4) != 0)
[[unlikely]] {
- return Status::DataQualityError(
- "Iceberg deletion vector magic number mismatch, expected: {},
actual: {}",
- BigEndian::Load32(ICEBERG_DV_MAGIC), BigEndian::Load32(buf +
sizeof(total_length)));
- }
-
roaring::Roaring64Map bitmap;
- try {
- bitmap = roaring::Roaring64Map::readSafe(bitmap_buf, bitmap_size);
- } catch (const std::runtime_error& e) {
- return Status::DataQualityError("Decode roaring bitmap failed, {}",
e.what());
- }
-
+ RETURN_IF_ERROR(decode_iceberg_deletion_vector_buffer(buf, buffer_size,
&bitmap));
delete_rows->reserve(bitmap.cardinality());
for (auto it = bitmap.begin(); it != bitmap.end(); it++) {
delete_rows->push_back(cast_set<int64_t>(*it));
diff --git
a/be/test/format/table/iceberg/iceberg_delete_file_reader_helper_test.cpp
b/be/test/format/table/iceberg/iceberg_delete_file_reader_helper_test.cpp
index 658d3f70d0a..6ce562d6de0 100644
--- a/be/test/format/table/iceberg/iceberg_delete_file_reader_helper_test.cpp
+++ b/be/test/format/table/iceberg/iceberg_delete_file_reader_helper_test.cpp
@@ -71,6 +71,54 @@ TEST(IcebergDeleteFileReaderHelperTest,
IsNotDeletionVectorWhenContentMissing) {
EXPECT_FALSE(is_iceberg_deletion_vector(delete_file));
}
+TEST(IcebergDeleteFileReaderHelperTest,
DeletionVectorCacheKeyIncludesLocationAndRange) {
+ // Scenario: one Puffin file can hold several DV blobs. The cache key must
isolate both the
+ // data file and the blob range so a scanner never reuses another file's
DV.
+ TIcebergDeleteFileDesc first_delete_file;
+ first_delete_file.__set_path("s3://bucket/shared/delete.puffin");
+ first_delete_file.__set_content_offset(128);
+ first_delete_file.__set_content_size_in_bytes(64);
+
+ TIcebergDeleteFileDesc different_offset = first_delete_file;
+ different_offset.__set_content_offset(256);
+
+ TIcebergDeleteFileDesc different_length = first_delete_file;
+ different_length.__set_content_size_in_bytes(96);
+
+ TIcebergDeleteFileDesc different_delete_file = first_delete_file;
+ different_delete_file.__set_path("s3://bucket/shared/other-delete.puffin");
+
+ const std::string data_file_path = "s3://bucket/table/data-00001.parquet";
+ const auto first_key =
+ build_iceberg_deletion_vector_cache_key(data_file_path,
first_delete_file);
+
+ EXPECT_NE(first_key,
build_iceberg_deletion_vector_cache_key(data_file_path, different_offset));
+ EXPECT_NE(first_key,
build_iceberg_deletion_vector_cache_key(data_file_path, different_length));
+ EXPECT_NE(first_key,
+ build_iceberg_deletion_vector_cache_key(data_file_path,
different_delete_file));
+ EXPECT_NE(first_key,
+ build_iceberg_deletion_vector_cache_key(
+ "s3://bucket/table/snapshot-branch/data-00001.parquet",
first_delete_file));
+}
+
+TEST(IcebergDeleteFileReaderHelperTest,
DeletionVectorCacheKeyEscapesPathBoundaries) {
+ TIcebergDeleteFileDesc first_delete_file;
+ first_delete_file.__set_path("middle#right#tail.puffin");
+ first_delete_file.__set_content_offset(1);
+ first_delete_file.__set_content_size_in_bytes(2);
+
+ TIcebergDeleteFileDesc second_delete_file = first_delete_file;
+ second_delete_file.__set_path("right#tail.puffin");
+
+ const std::string first_data_file_path = "s3://bucket/table/data#left";
+ const std::string second_data_file_path =
"s3://bucket/table/data#left#middle";
+ ASSERT_EQ(first_data_file_path + "#" + first_delete_file.path,
+ second_data_file_path + "#" + second_delete_file.path);
+
+ EXPECT_NE(build_iceberg_deletion_vector_cache_key(first_data_file_path,
first_delete_file),
+ build_iceberg_deletion_vector_cache_key(second_data_file_path,
second_delete_file));
+}
+
TEST(IcebergDeleteFileReaderHelperTest,
ReadMixedEncodingParquetPositionDeleteFile) {
RuntimeProfile profile("test_profile");
RuntimeState runtime_state((TQueryOptions()), TQueryGlobals());
diff --git a/be/test/format/table/paimon_cpp_reader_test.cpp
b/be/test/format/table/paimon_cpp_reader_test.cpp
index b352696927e..ecec461d6b1 100644
--- a/be/test/format/table/paimon_cpp_reader_test.cpp
+++ b/be/test/format/table/paimon_cpp_reader_test.cpp
@@ -19,15 +19,39 @@
#include <gtest/gtest.h>
+#include <cstring>
#include <string>
#include <vector>
#include "core/block/block.h"
+#include "exec/common/endian.h"
+#include "format/table/paimon_reader.h"
+#include "roaring/roaring.hh"
#include "runtime/runtime_profile.h"
#include "runtime/runtime_state.h"
namespace doris {
+namespace {
+
+std::vector<char> build_paimon_deletion_vector_buffer(const
std::vector<uint32_t>& positions) {
+ roaring::Roaring rows;
+ for (const auto position : positions) {
+ rows.add(position);
+ }
+
+ const size_t bitmap_size = rows.getSizeInBytes();
+ const uint32_t total_length = static_cast<uint32_t>(4 + bitmap_size);
+ std::vector<char> buffer(4 + total_length);
+ BigEndian::Store32(buffer.data(), total_length);
+ constexpr char PAIMON_BITMAP_MAGIC[] = {'\x5E', '\x43', '\xF2', '\xD0'};
+ memcpy(buffer.data() + 4, PAIMON_BITMAP_MAGIC, 4);
+ rows.write(buffer.data() + 8);
+ return buffer;
+}
+
+} // namespace
+
class PaimonCppReaderTest : public testing::Test {
protected:
void SetUp() override {
@@ -92,4 +116,86 @@ TEST_F(PaimonCppReaderTest,
InitReaderFailsWithoutPaimonSplit) {
EXPECT_NE(status.to_string().find("missing paimon_split"),
std::string::npos);
}
+TEST(PaimonDeletionVectorTest, DecodeValidBuffer) {
+ // Scenario: a valid Paimon DV buffer should decode to sorted row
positions that readers pass
+ // into the common position-delete filter.
+ const auto buffer = build_paimon_deletion_vector_buffer({0, 3, 5});
+ std::vector<int64_t> delete_rows;
+ const auto status =
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows);
+
+ ASSERT_TRUE(status.ok()) << status;
+ EXPECT_EQ(delete_rows, std::vector<int64_t>({0, 3, 5}));
+}
+
+TEST(PaimonDeletionVectorTest, RejectShortBuffer) {
+ // Scenario: malformed DV content must fail before reading the length and
magic fields.
+ const std::vector<char> buffer(7, '\0');
+ std::vector<int64_t> delete_rows;
+ const auto status =
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows);
+
+ ASSERT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("file size too small"),
std::string::npos);
+}
+
+TEST(PaimonDeletionVectorTest, RejectLengthMismatch) {
+ // Scenario: the big-endian length prefix protects against using a
truncated or over-read DV
+ // slice from a shared deletion-vector file.
+ auto buffer = build_paimon_deletion_vector_buffer({1});
+ BigEndian::Store32(buffer.data(), static_cast<uint32_t>(buffer.size()));
+ std::vector<int64_t> delete_rows;
+ const auto status =
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows);
+
+ ASSERT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("length not match"), std::string::npos);
+}
+
+TEST(PaimonDeletionVectorTest, RejectMagicMismatch) {
+ // Scenario: Paimon DV buffers have a fixed magic header, so a cache entry
or offset pointing
+ // to unrelated bytes must be rejected.
+ auto buffer = build_paimon_deletion_vector_buffer({1});
+ buffer[4] = '\0';
+ std::vector<int64_t> delete_rows;
+ const auto status =
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows);
+
+ ASSERT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("invalid magic number"),
std::string::npos);
+}
+
+TEST(PaimonDeletionVectorTest, RejectCorruptRoaringBitmap) {
+ // Scenario: a buffer with a valid header but incomplete Roaring payload
should surface as a
+ // data-quality error instead of silently producing partial delete rows.
+ auto buffer = build_paimon_deletion_vector_buffer({1, 2});
+ buffer.resize(10);
+ BigEndian::Store32(buffer.data(), static_cast<uint32_t>(buffer.size() -
4));
+ std::vector<int64_t> delete_rows;
+ const auto status =
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows);
+
+ ASSERT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("failed to deserialize roaring bitmap"),
std::string::npos);
+}
+
+TEST(PaimonDeletionVectorTest, CacheKeyIncludesOffsetAndLength) {
+ // Scenario: different Paimon tables or splits may reference the same DV
file with different
+ // ranges; cache keys must include both offset and length to avoid sharing
the wrong bitmap.
+ TPaimonDeletionFileDesc first_deletion_file;
+ first_deletion_file.__set_path("s3://bucket/table/deletion.dv");
+ first_deletion_file.__set_offset(128);
+ first_deletion_file.__set_length(64);
+
+ TPaimonDeletionFileDesc different_offset = first_deletion_file;
+ different_offset.__set_offset(256);
+
+ TPaimonDeletionFileDesc different_length = first_deletion_file;
+ different_length.__set_length(96);
+
+ const auto first_key =
build_paimon_deletion_vector_cache_key(first_deletion_file);
+ EXPECT_NE(first_key,
build_paimon_deletion_vector_cache_key(different_offset));
+ EXPECT_NE(first_key,
build_paimon_deletion_vector_cache_key(different_length));
+}
+
} // namespace doris
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 442ea660f35..4d57fcd950b 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -1179,6 +1179,7 @@ TEST(IcebergV2ReaderTest,
IcebergDeletionVectorUsesTableReaderDeleteFileInterfac
TTableFormatFileDesc table_format_desc;
TIcebergFileDesc iceberg_desc;
iceberg_desc.__set_format_version(2);
+ iceberg_desc.__set_original_file_path("data-a.parquet");
iceberg_desc.__set_delete_files({make_iceberg_deletion_vector("dv.bin", 8,
128)});
table_format_desc.__set_iceberg_params(iceberg_desc);
@@ -1195,6 +1196,52 @@ TEST(IcebergV2ReaderTest,
IcebergDeletionVectorUsesTableReaderDeleteFileInterfac
EXPECT_EQ(desc.format, DeleteFileDesc::Format::ICEBERG);
}
+TEST(IcebergV2ReaderTest,
IcebergDeletionVectorCacheKeyIncludesDataFileAndRange) {
+ // Scenario: Iceberg Puffin deletion vectors are scoped to the data file.
The same Puffin blob
+ // referenced by different data files, offsets or lengths must not share a
DeleteFileDesc key.
+ const auto shared_dv =
+ make_iceberg_deletion_vector("s3://bucket/table/shared-dv.puffin",
8, 128);
+ IcebergTableReaderDeleteFileTestHelper reader;
+
+ DeleteFileDesc first_desc;
+ bool has_delete_file = false;
+ ASSERT_TRUE(reader.parse_deletion_vector_file(
+
make_iceberg_table_format_desc("s3://bucket/table/data-a.parquet",
+ {shared_dv}),
+ &first_desc, &has_delete_file)
+ .ok());
+ EXPECT_TRUE(has_delete_file);
+
+ DeleteFileDesc different_data_file_desc;
+ ASSERT_TRUE(reader.parse_deletion_vector_file(
+
make_iceberg_table_format_desc("s3://bucket/table/data-b.parquet",
+ {shared_dv}),
+ &different_data_file_desc, &has_delete_file)
+ .ok());
+
+ DeleteFileDesc different_offset_desc;
+ ASSERT_TRUE(reader.parse_deletion_vector_file(
+ make_iceberg_table_format_desc(
+ "s3://bucket/table/data-a.parquet",
+ {make_iceberg_deletion_vector(
+
"s3://bucket/table/shared-dv.puffin", 16, 128)}),
+ &different_offset_desc, &has_delete_file)
+ .ok());
+
+ DeleteFileDesc different_length_desc;
+ ASSERT_TRUE(reader.parse_deletion_vector_file(
+ make_iceberg_table_format_desc(
+ "s3://bucket/table/data-a.parquet",
+ {make_iceberg_deletion_vector(
+
"s3://bucket/table/shared-dv.puffin", 8, 256)}),
+ &different_length_desc, &has_delete_file)
+ .ok());
+
+ EXPECT_NE(first_desc.key, different_data_file_desc.key);
+ EXPECT_NE(first_desc.key, different_offset_desc.key);
+ EXPECT_NE(first_desc.key, different_length_desc.key);
+}
+
TEST(IcebergV2ReaderTest, IcebergDeletionVectorRejectsMultipleDeleteFiles) {
TTableFormatFileDesc table_format_desc;
TIcebergFileDesc iceberg_desc;
@@ -1741,7 +1788,7 @@ TEST(IcebergV2ReaderTest,
IcebergTableReaderAppliesPositionDeleteFile) {
std::filesystem::remove_all(test_dir);
}
-TEST(IcebergV2ReaderTest,
IcebergTableReaderMergesDeletionVectorAndPositionDeleteFiles) {
+TEST(IcebergV2ReaderTest,
IcebergTableReaderIgnoresPositionDeleteFilesWhenDeletionVectorPresent) {
const auto test_dir =
std::filesystem::temp_directory_path() /
"doris_iceberg_delete_files_merge_test";
std::filesystem::remove_all(test_dir);
@@ -1784,7 +1831,7 @@ TEST(IcebergV2ReaderTest,
IcebergTableReaderMergesDeletionVectorAndPositionDelet
make_iceberg_position_delete_file(position_delete_path)}));
ASSERT_TRUE(reader.prepare_split(split_options).ok());
- EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
std::vector<int32_t>({2, 3, 5}));
+ EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
std::vector<int32_t>({2, 3, 4, 5}));
ASSERT_TRUE(reader.close().ok());
std::filesystem::remove_all(test_dir);
diff --git a/be/test/format_v2/table/paimon_reader_test.cpp
b/be/test/format_v2/table/paimon_reader_test.cpp
index 2e07706a71a..caa4de6ce44 100644
--- a/be/test/format_v2/table/paimon_reader_test.cpp
+++ b/be/test/format_v2/table/paimon_reader_test.cpp
@@ -44,6 +44,8 @@
#include "core/field.h"
#include "exec/common/endian.h"
#include "format/format_common.h"
+#include "format/table/deletion_vector_reader.h"
+#include "format/table/paimon_reader.h"
#include "format_v2/column_data.h"
#include "gen_cpp/ExternalTableSchema_types.h"
#include "gen_cpp/PlanNodes_types.h"
@@ -193,8 +195,8 @@ void write_int_pair_parquet_file(const std::string&
file_path, const std::vector
builder.build()));
}
-int64_t write_paimon_deletion_vector_file(const std::string& file_path,
- const std::vector<uint32_t>&
deleted_positions) {
+std::vector<char> build_paimon_deletion_vector_buffer(
+ const std::vector<uint32_t>& deleted_positions) {
roaring::Roaring rows;
for (const auto position : deleted_positions) {
rows.add(position);
@@ -207,13 +209,18 @@ int64_t write_paimon_deletion_vector_file(const
std::string& file_path,
constexpr char PAIMON_BITMAP_MAGIC[] = {'\x5E', '\x43', '\xF2', '\xD0'};
memcpy(blob.data() + 4, PAIMON_BITMAP_MAGIC, 4);
rows.write(blob.data() + 8);
+ return blob;
+}
+int64_t write_paimon_deletion_vector_file(const std::string& file_path,
+ const std::vector<uint32_t>&
deleted_positions) {
+ const auto blob = build_paimon_deletion_vector_buffer(deleted_positions);
std::ofstream output(file_path, std::ios::binary);
EXPECT_TRUE(output.is_open());
output.write(blob.data(), static_cast<std::streamsize>(blob.size()));
EXPECT_TRUE(output.good());
// Paimon DeletionFile.length is magic + bitmap length, excluding the
leading length field.
- return static_cast<int64_t>(total_length);
+ return static_cast<int64_t>(blob.size() - 4);
}
TFileScanRangeParams make_local_parquet_scan_params() {
@@ -382,6 +389,95 @@ TEST(PaimonReaderTest,
FallsBackToByNameWhenSplitHistorySchemaIsMissing) {
EXPECT_TRUE(file_schema[0].name_mapping.empty());
}
+TEST(PaimonReaderTest, DeletionVectorCacheKeyIncludesOffsetAndLength) {
+ // Scenario: format_v2 converts Paimon split metadata into a generic
DeleteFileDesc. The
+ // generated key must preserve offset and length so shared DV files do not
collide.
+ TTableFormatFileDesc table_format_params;
+ table_format_params.__isset.paimon_params = true;
+ TPaimonDeletionFileDesc deletion_file;
+ deletion_file.__set_path("s3://bucket/table/deletion.dv");
+ deletion_file.__set_offset(128);
+ deletion_file.__set_length(64);
+ table_format_params.paimon_params.__set_deletion_file(deletion_file);
+
+ paimon::PaimonReader reader;
+ DeleteFileDesc first_desc;
+ bool has_delete_file = false;
+ ASSERT_TRUE(reader.TEST_parse_deletion_vector_file(table_format_params,
&first_desc,
+ &has_delete_file)
+ .ok());
+ EXPECT_TRUE(has_delete_file);
+
+ table_format_params.paimon_params.deletion_file.__set_offset(256);
+ DeleteFileDesc different_offset_desc;
+ ASSERT_TRUE(reader.TEST_parse_deletion_vector_file(table_format_params,
&different_offset_desc,
+ &has_delete_file)
+ .ok());
+
+ table_format_params.paimon_params.deletion_file.__set_offset(128);
+ table_format_params.paimon_params.deletion_file.__set_length(96);
+ DeleteFileDesc different_length_desc;
+ ASSERT_TRUE(reader.TEST_parse_deletion_vector_file(table_format_params,
&different_length_desc,
+ &has_delete_file)
+ .ok());
+
+ EXPECT_NE(first_desc.key, different_offset_desc.key);
+ EXPECT_NE(first_desc.key, different_length_desc.key);
+}
+
+TEST(PaimonReaderTest, DecodeDeletionVectorBufferUsesSharedFormatHelper) {
+ // Scenario: format_v2 TableReader reads a raw Paimon BitmapDeletionVector
range and delegates
+ // the binary parsing to the same helper used by the format reader path.
+ const auto buffer = build_paimon_deletion_vector_buffer({0, 3, 5});
+ DeleteRows delete_rows;
+
+ ASSERT_TRUE(
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows).ok());
+ EXPECT_EQ(delete_rows, DeleteRows({0, 3, 5}));
+}
+
+TEST(PaimonReaderTest, DecodeDeletionVectorBufferRejectsShortBuffer) {
+ // Scenario: a truncated Paimon DV must fail before reading the magic or
roaring payload.
+ const std::vector<char> buffer = {'\0', '\0', '\0', '\4'};
+ DeleteRows delete_rows;
+
+ EXPECT_FALSE(
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows).ok());
+}
+
+TEST(PaimonReaderTest, DecodeDeletionVectorBufferRejectsLengthMismatch) {
+ // Scenario: a cached or remote Paimon DV range with a mismatched leading
length must not be
+ // accepted as a valid bitmap.
+ auto buffer = build_paimon_deletion_vector_buffer({1, 2});
+ BigEndian::Store32(buffer.data(), static_cast<uint32_t>(buffer.size()));
+ DeleteRows delete_rows;
+
+ EXPECT_FALSE(
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows).ok());
+}
+
+TEST(PaimonReaderTest, DecodeDeletionVectorBufferRejectsMagicMismatch) {
+ // Scenario: format_v2 must reject non-Paimon payloads even when the range
length is valid.
+ auto buffer = build_paimon_deletion_vector_buffer({1, 2});
+ buffer[4] = '\0';
+ DeleteRows delete_rows;
+
+ EXPECT_FALSE(
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows).ok());
+}
+
+TEST(PaimonReaderTest, DecodeDeletionVectorBufferRejectsCorruptRoaringBitmap) {
+ // Scenario: a valid Paimon DV header with a corrupt roaring body should
return a data quality
+ // error instead of producing partial delete rows.
+ auto buffer = build_paimon_deletion_vector_buffer({1, 2});
+ buffer.resize(8);
+ BigEndian::Store32(buffer.data(), 4);
+ DeleteRows delete_rows;
+
+ EXPECT_FALSE(
+ decode_paimon_deletion_vector_buffer(buffer.data(), buffer.size(),
&delete_rows).ok());
+}
+
// Scenario: PaimonReader must clear the previous split schema id before
reading a new split. A
// schema-evolved split must not force the following split without schema id
to keep BY_FIELD_ID.
TEST(PaimonReaderTest, ResetsSplitSchemaIdBeforePreparingNextSplit) {
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
index 9b1704a7891..c51bbcc9f82 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
@@ -101,6 +101,23 @@ services:
- /usr/lib/iceberg-rest/iceberg-rest-adapter.jar:/opt/jdbc/postgresql.jar
- org.apache.iceberg.rest.RESTCatalogServer
+ trino:
+ image: trinodb/trino:482
+ container_name: doris--iceberg-trino
+ depends_on:
+ rest:
+ condition: service_started
+ mc:
+ condition: service_completed_successfully
+ user: root
+ networks:
+ - doris--iceberg
+ healthcheck:
+ test: ["CMD-SHELL", "test -d /etc/trino/catalog && command -v trino
>/dev/null"]
+ interval: 5s
+ timeout: 60s
+ retries: 120
+
minio:
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
container_name: doris--iceberg-minio
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg/run28.sql
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg/run28.sql
index adbfafcf976..31d75a8d50b 100644
---
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg/run28.sql
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg/run28.sql
@@ -34,6 +34,56 @@ where batch = 2 and id >= 14;
delete from dv_test
where id % 2 = 1;
+drop table if exists dv_test_orc;
+create table dv_test_orc (
+ id int,
+ batch int,
+ data string
+)
+using iceberg
+tblproperties (
+ 'format-version' = '3',
+ 'write.format.default' = 'orc',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read'
+);
+
+insert into dv_test_orc values
+ (1, 1, 'a'), (2, 1, 'b'), (3, 1, 'c'), (4, 1, 'd'),
+ (5, 1, 'e'), (6, 1, 'f'), (7, 1, 'g'), (8, 1, 'h');
+
+delete from dv_test_orc
+where batch = 1 and id in (3, 4, 5);
+
+insert into dv_test_orc values
+ (9, 2, 'i'), (10, 2, 'j'), (11, 2, 'k'), (12, 2, 'l'),
+ (13, 2, 'm'), (14, 2, 'n'), (15, 2, 'o'), (16, 2, 'p');
+
+delete from dv_test_orc
+where batch = 2 and id >= 14;
+
+delete from dv_test_orc
+where id % 2 = 1;
+
+drop table if exists dv_test_no_delete;
+create table dv_test_no_delete (
+ id int,
+ batch int,
+ data string
+)
+using iceberg
+tblproperties (
+ 'format-version' = '3',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read'
+);
+
+insert into dv_test_no_delete values
+ (1, 1, 'a'), (2, 1, 'b'), (3, 1, 'c'), (4, 1, 'd'),
+ (5, 1, 'e'), (6, 1, 'f'), (7, 1, 'g'), (8, 1, 'h');
+
drop table if exists dv_test_v2;
create table dv_test_v2 (
id int,
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_deletion_vector.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_deletion_vector.out
index 41819b90bd7..6d3531f345e 100644
---
a/regression-test/data/external_table_p0/iceberg/test_iceberg_deletion_vector.out
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_deletion_vector.out
@@ -11,40 +11,97 @@
6 1 f
8 1 h
+-- !q3 --
+2 1 b
+6 1 f
+8 1 h
+10 2 j
+12 2 l
+
+-- !no_delete --
+1 1 a
+2 1 b
+3 1 c
+4 1 d
+5 1 e
+6 1 f
+7 1 g
+8 1 h
+
+-- !equality_only --
+1 a
+2 b
+
+-- !position_only_count --
+5632
+
+-- !position_and_dv_count --
+5120
+
+-- !equality_and_dv --
+2 1 b
+4 1 d
+6 1 f
+8 1 h
+12 2 l
+
+-- !split_cache_single_file_count --
+2048 4192256
+
-- !q4 --
2 1 b
-- !q5 --
2 1 b
+-- !q5_orc --
+2 1 b
+
-- !q6 --
-- !q7 --
+-- !q7_orc --
+
-- !q8 --
2 1 b
-- !q9 --
2 1 b
+-- !q9_orc --
+2 1 b
+
-- !q10 --
5
-- !q11 --
3
+-- !q11_orc --
+5
+
+-- !no_delete_count --
+8
+
-- !q12 --
5
-- !q13 --
3
+-- !q13_orc --
+5
+
-- !q14 --
5
-- !q15 --
3
+-- !q15_orc --
+5
+
-- !q16 --
33334
@@ -63,3 +120,65 @@
-- !q21 --
33334
+-- !q22 --
+12 2 l
+10 2 j
+8 1 h
+
+-- !q23 --
+8 1 h
+6 1 f
+2 1 b
+
+-- !q24 --
+1 3 16
+2 2 22
+
+-- !q25 --
+1 3 16
+
+-- !q26 --
+1 3 16
+2 2 22
+
+-- !q27 --
+2 b
+6 f
+
+-- !q28 --
+2 b
+6 f
+
+-- !q29 --
+2 b
+6 f
+
+-- !q30 --
+100
+
+-- !q31 --
+100
+
+-- !delete_type_matrix --
+dv_equality 0 PARQUET 12 12
+dv_equality 1 PUFFIN 6 6
+dv_equality 2 PARQUET 1 1
+dv_only 0 PARQUET 10 10
+dv_only 1 PUFFIN 5 5
+dv_position 0 PARQUET 1 7680
+dv_position 1 PUFFIN 1 2560
+equality_only 0 PARQUET 2 2
+equality_only 2 PARQUET 2 2
+no_delete 0 PARQUET 8 8
+position_only 0 PARQUET 1 7680
+position_only 1 PARQUET 4 2048
+
+-- !position_delete_ignored_with_dv --
+
+-- !equality_delete_active_with_dv --
+2 1 b
+4 1 d
+6 1 f
+8 1 h
+12 2 l
+
diff --git
a/regression-test/data/external_table_p0/paimon/test_paimon_deletion_vector.out
b/regression-test/data/external_table_p0/paimon/test_paimon_deletion_vector.out
index f0b1e92a088..d8b9e547101 100644
---
a/regression-test/data/external_table_p0/paimon/test_paimon_deletion_vector.out
+++
b/regression-test/data/external_table_p0/paimon/test_paimon_deletion_vector.out
@@ -35,6 +35,18 @@
-- !9 --
7
+-- !10 --
+3 3_1
+
+-- !11 --
+3 3_1
+
+-- !12 --
+4
+
+-- !13 --
+4
+
-- !1 --
3
@@ -71,3 +83,15 @@
-- !9 --
7
+-- !10 --
+3 3_1
+
+-- !11 --
+3 3_1
+
+-- !12 --
+4
+
+-- !13 --
+4
+
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_deletion_vector.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_deletion_vector.groovy
index cdd1764c794..63fcf1cfda0 100644
---
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_deletion_vector.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_deletion_vector.groovy
@@ -42,30 +42,329 @@ suite("test_iceberg_deletion_vector", "p0,external") {
sql """switch ${catalog_name};"""
sql """ use format_v3;"""
+ sql """ set file_split_size=1;"""
+ try {
+ def executeCommandWithStatus = { String cmd, int timeoutSeconds = 300,
Boolean logFailure = true,
+ Boolean logCommand = true ->
+ StringBuilder stdout = new StringBuilder()
+ StringBuilder stderr = new StringBuilder()
+ try {
+ if (logCommand) {
+ logger.info("execute ${cmd}")
+ }
+ def proc = new ProcessBuilder("/bin/bash", "-c", cmd).start()
+ proc.consumeProcessOutput(stdout, stderr)
+ proc.waitForOrKill(timeoutSeconds * 1000)
+ int exitcode = proc.exitValue()
+ String output = stdout.toString()
+ String error = stderr.toString()
+ if (exitcode != 0 && logFailure) {
+ logger.info("exit code: ${exitcode}, stdout\n:
${output}\nstderr\n: ${error}")
+ }
+ return [exitCode: exitcode, stdout: output, stderr: error]
+ } catch (IOException e) {
+ assertTrue(false, "Execute failed: ${cmd}, err: ${e.message}")
+ }
+ }
+
+ def executeCommand = { String cmd, Boolean mustSuc, int timeoutSeconds =
300 ->
+ def result = executeCommandWithStatus(cmd, timeoutSeconds)
+ if (mustSuc && result.exitCode != 0) {
+ assertTrue(false,
+ "Execute failed:
${cmd}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}")
+ }
+ return result.stdout
+ }
+
+ String dockerCommand =
context.config.otherConfigs.get("externalDockerCommand") ?: "docker"
+ def listDockerContainers = {
+ String containers =
+ executeCommand("${dockerCommand} ps --format
'{{.ID}}\t{{.Names}}\t{{.Image}}'", true, 30) ?:
+ ""
+ return containers.readLines().collect { it.trim() }.findAll {
!it.isEmpty() }
+ }
+
+ def findRequiredDockerContainer = { String role, String configKey, String
probeCommand ->
+ String configuredContainer = context.config.otherConfigs.get(configKey)
+ if (configuredContainer != null && !configuredContainer.isEmpty()) {
+ def probe = executeCommandWithStatus(
+ "${dockerCommand} exec ${configuredContainer} bash -lc
'${probeCommand}'",
+ 30)
+ assertEquals(0, probe.exitCode,
+ "${role} container configured by
${configKey}=${configuredContainer} is not usable")
+ return configuredContainer
+ }
+
+ def matchedContainers = []
+ listDockerContainers().each { String containerLine ->
+ def fields = containerLine.split(/\t/, 3)
+ assertTrue(fields.length >= 2, "Unexpected docker ps output:
${containerLine}")
+ String containerId = fields[0].trim()
+ String containerName = fields[1].trim()
+ String containerImage = fields.length >= 3 ? fields[2].trim() : ""
+ def probe = executeCommandWithStatus(
+ "${dockerCommand} exec ${containerId} bash -lc
'${probeCommand}'",
+ 30,
+ false,
+ false)
+ if (probe.exitCode == 0) {
+ matchedContainers.add(
+ [id: containerId, name: containerName, image:
containerImage])
+ }
+ }
+
+ assertFalse(matchedContainers.isEmpty(),
+ "No usable ${role} container found. Set ${configKey} to the
exact container name " +
+ "or start the required external environment.")
+ assertEquals(1, matchedContainers.size(),
+ "Multiple usable ${role} containers found:
${matchedContainers}. " +
+ "Set ${configKey} to the exact container name.")
+ logger.info("use ${role} container ${matchedContainers[0].name} " +
+ "(${matchedContainers[0].image})")
+ return matchedContainers[0].id
+ }
+
+ String sparkContainerName = findRequiredDockerContainer(
+ "Spark Iceberg", "icebergSparkContainer",
+ "command -v spark-sql >/dev/null && test -f /mnt/SUCCESS && " +
+ "spark-sql -e \"SHOW NAMESPACES IN demo\" >/dev/null")
+
+ def encodeBase64 = { String text ->
+ return text.getBytes("UTF-8").encodeBase64().toString()
+ }
+
+ def runInSparkContainer = { String command, int timeout = 300 ->
+ executeCommand("${dockerCommand} exec ${sparkContainerName} bash -lc
'${command}'", true,
+ timeout)
+ }
+
+ def runSparkSql = { String sqlText, int timeout = 600 ->
+ String encodedSql = encodeBase64(sqlText)
+ runInSparkContainer(
+ "echo ${encodedSql} | base64 -d
>/tmp/test_iceberg_deletion_vector.sql && " +
+ "spark-sql -f /tmp/test_iceberg_deletion_vector.sql",
+ timeout
+ )
+ }
+
+ String setupSql = """
+use format_v3;
+
+-- Delete-type matrix fixtures: reuse existing v2 equality/position-delete
tables and create
+-- v3 MOR tables that Spark writes with Iceberg deletion vectors.
+drop table if exists dv_delete_matrix_equality_only;
+CALL demo.system.register_table(
+ table => 'format_v3.dv_delete_matrix_equality_only',
+ metadata_file =>
's3a://warehouse/wh/test_db/customer_flink_one/metadata/00002-c38103a7-237b-4350-aa98-05fe2ad14d16.metadata.json'
+);
+
+drop table if exists dv_delete_matrix_position_only;
+CALL demo.system.register_table(
+ table => 'format_v3.dv_delete_matrix_position_only',
+ metadata_file =>
's3a://warehouse/wh/test_db/iceberg_position_parquet/metadata/00005-6ff36319-7a9a-4357-a9fc-965d5f280c9b.metadata.json'
+);
+
+drop table if exists dv_delete_matrix_position_and_dv;
+CALL demo.system.register_table(
+ table => 'format_v3.dv_delete_matrix_position_and_dv',
+ metadata_file =>
's3a://warehouse/wh/test_db/iceberg_position_parquet/metadata/00005-6ff36319-7a9a-4357-a9fc-965d5f280c9b.metadata.json'
+);
+alter table dv_delete_matrix_position_and_dv set tblproperties (
+ 'format-version' = '3',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read'
+);
+delete from dv_delete_matrix_position_and_dv where id = 2;
+
+drop table if exists dv_delete_matrix_equality_and_dv;
+create table dv_delete_matrix_equality_and_dv (
+ id int,
+ batch int,
+ data string
+)
+using iceberg
+tblproperties (
+ 'format-version' = '3',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read'
+);
+insert into dv_delete_matrix_equality_and_dv values
+ (1, 1, 'a'), (2, 1, 'b'), (3, 1, 'c'), (4, 1, 'd'),
+ (5, 1, 'e'), (6, 1, 'f'), (7, 1, 'g'), (8, 1, 'h'),
+ (9, 2, 'i'), (10, 2, 'j'), (11, 2, 'k'), (12, 2, 'l');
+delete from dv_delete_matrix_equality_and_dv where id % 2 = 1;
+
+-- Split/cache fixture: one sufficiently large data file plus one DV. Doris
reads it with a tiny
+-- file_split_size so the same data file is split across scanners and the
shared scan-node cache
+-- must prevent the DV from being parsed once per split.
+drop table if exists dv_split_cache_single_file;
+create table dv_split_cache_single_file (
+ id bigint,
+ grp int,
+ payload string
+)
+using iceberg
+tblproperties (
+ 'format-version' = '3',
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.update.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read',
+ 'write.parquet.row-group-size-bytes' = '1024'
+);
+set spark.sql.shuffle.partitions = 1;
+insert into dv_split_cache_single_file
+select /*+ COALESCE(1) */
+ id,
+ cast(id % 8 as int) as grp,
+ repeat(concat('payload_', cast(id as string)), 20) as payload
+ from range(0, 4096);
+delete from dv_split_cache_single_file where id % 2 = 1;
+reset spark.sql.shuffle.partitions;
+"""
+ runSparkSql(setupSql)
+
+ String javaSource = '''
+import java.util.HashMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.IcebergGenerics;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.data.parquet.GenericParquetWriter;
+import org.apache.iceberg.deletes.EqualityDeleteWriter;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.parquet.Parquet;
+
+public class AppendEqualityDelete {
+ public static void main(String[] args) throws Exception {
+ Catalog catalog = IcebergRestCatalog.load();
+ Table table = catalog.loadTable(TableIdentifier.of(args[0], args[1]));
+ Schema eqSchema = table.schema().select(args[2]);
+ int fieldId = table.schema().findField(args[2]).fieldId();
+ String location = table.location() + "/data/equality-delete-" +
args[2] + "-"
+ + args[3] + "-" + System.currentTimeMillis() + ".parquet";
+ OutputFile outputFile = table.io().newOutputFile(location);
+ EqualityDeleteWriter<Record> writer = Parquet.writeDeletes(outputFile)
+ .forTable(table)
+ .rowSchema(eqSchema)
+ .withSpec(table.spec())
+ .createWriterFunc(GenericParquetWriter::create)
+ .equalityFieldIds(fieldId)
+ .overwrite()
+ .buildEqualityWriter();
+ GenericRecord record = GenericRecord.create(eqSchema);
+ record.setField(args[2], Integer.valueOf(args[3]));
+ writer.write(record);
+ writer.close();
+ DeleteFile deleteFile = writer.toDeleteFile();
+ table.newRowDelta().addDeletes(deleteFile).commit();
+ }
+}
+class ReadIcebergRows {
+ public static void main(String[] args) throws Exception {
+ Catalog catalog = IcebergRestCatalog.load();
+ Table table = catalog.loadTable(TableIdentifier.of(args[0], args[1]));
+ String[] columns = Arrays.copyOfRange(args, 2, args.length);
+ List<String> rows = new ArrayList<>();
+ try (CloseableIterable<Record> records =
IcebergGenerics.read(table).select(columns).build()) {
+ for (Record record : records) {
+ List<String> values = new ArrayList<>();
+ for (String column : columns) {
+ values.add(String.valueOf(record.getField(column)));
+ }
+ rows.add(String.join("\\t", values));
+ }
+ }
+ Collections.sort(rows, Comparator.comparingInt(row ->
Integer.parseInt(row.split("\\t")[0])));
+ for (String row : rows) {
+ System.out.println(row);
+ }
+ }
+}
+
+class IcebergRestCatalog {
+ static Catalog load() {
+ Map<String, String> props = new HashMap<>();
+ props.put("type", "rest");
+ props.put("uri", "http://rest:8181");
+ props.put("warehouse", "s3://warehouse/wh/");
+ props.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO");
+ props.put("s3.endpoint", "http://minio:9000");
+ props.put("s3.path-style-access", "true");
+ props.put("s3.region", "us-east-1");
+ return CatalogUtil.buildIcebergCatalog("demo", props, null);
+ }
+}
+'''
+ String encodedJavaSource = encodeBase64(javaSource)
+ runInSparkContainer(
+ "echo ${encodedJavaSource} | base64 -d
>/tmp/AppendEqualityDelete.java && " +
+ "javac -cp \"/opt/spark/jars/*\"
/tmp/AppendEqualityDelete.java && " +
+ "java -cp \"/tmp:/opt/spark/jars/*\" AppendEqualityDelete
" +
+ "format_v3 dv_delete_matrix_equality_and_dv id 10",
+ 300
+ )
+
+
+ // Baseline DV correctness across Parquet, ORC, no-delete, and v2-to-v3
upgrade fixtures.
qt_q1 """ SELECT * FROM dv_test ORDER BY id; """
qt_q2 """ SELECT * FROM dv_test_v2 ORDER BY id; """
+ qt_q3 """ SELECT * FROM dv_test_orc ORDER BY id; """
+ qt_no_delete """ SELECT * FROM dv_test_no_delete ORDER BY id; """
+
+ // Delete-type matrix checks cover equality-only, position-only, DV-only,
DV+position,
+ // and DV+equality combinations.
+ qt_equality_only """ SELECT * FROM dv_delete_matrix_equality_only ORDER BY
id; """
+ qt_position_only_count """ SELECT count(*) FROM
dv_delete_matrix_position_only; """
+ qt_position_and_dv_count """ SELECT count(*) FROM
dv_delete_matrix_position_and_dv; """
+ qt_equality_and_dv """ SELECT * FROM dv_delete_matrix_equality_and_dv
ORDER BY id; """
+ qt_split_cache_single_file_count """
+ SELECT count(*), sum(id)
+ FROM dv_split_cache_single_file;
+ """
qt_q4 """ SELECT * FROM dv_test where id = 2 ORDER BY id ; """
qt_q5 """ SELECT * FROM dv_test_v2 where id = 2 ORDER BY id ; """
+ qt_q5_orc """ SELECT * FROM dv_test_orc where id = 2 ORDER BY id; """
qt_q6 """ SELECT * FROM dv_test where id %2 =1 ORDER BY id; """
qt_q7 """ SELECT * FROM dv_test_v2 where id %2 =1 ORDER BY id; """
+ qt_q7_orc """ SELECT * FROM dv_test_orc where id %2 =1 ORDER BY id; """
qt_q8 """ SELECT * FROM dv_test where data < 'f' ORDER BY id; """
qt_q9 """ SELECT * FROM dv_test_v2 where data < 'f' ORDER BY id; """
+ qt_q9_orc """ SELECT * FROM dv_test_orc where data < 'f' ORDER BY id; """
qt_q10 """ SELECT count(*) FROM dv_test ; """
qt_q11 """ SELECT count(*) FROM dv_test_v2 ; """
+ qt_q11_orc """ SELECT count(*) FROM dv_test_orc; """
+ qt_no_delete_count """ SELECT count(*) FROM dv_test_no_delete; """
qt_q12 """ SELECT count(id) FROM dv_test ; """
qt_q13 """ SELECT count(id) FROM dv_test_v2 ; """
+ qt_q13_orc """ SELECT count(id) FROM dv_test_orc; """
qt_q14 """ SELECT count(batch) FROM dv_test ; """
qt_q15 """ SELECT count(batch) FROM dv_test_v2 ; """
+ qt_q15_orc """ SELECT count(batch) FROM dv_test_orc; """
qt_q16 """ SELECT count(*) FROM dv_test_1w ; """
@@ -74,9 +373,220 @@ suite("test_iceberg_deletion_vector", "p0,external") {
qt_q19 """ SELECT count(value) FROM dv_test_1w where id%2 = 1; """
qt_q20 """ SELECT count(id) FROM dv_test_1w where id%3 = 1; """
qt_q21 """ SELECT count(ts) FROM dv_test_1w where id%3 != 1; """
+ qt_q22 """ SELECT * FROM dv_test ORDER BY data DESC LIMIT 3; """
+ qt_q23 """ SELECT * FROM dv_test_v2 ORDER BY data DESC LIMIT 3; """
+ qt_q24 """ SELECT batch, count(*), sum(id) FROM dv_test GROUP BY batch
ORDER BY batch; """
+ qt_q25 """ SELECT batch, count(*), sum(id) FROM dv_test_v2 GROUP BY batch
ORDER BY batch; """
+ qt_q26 """ SELECT batch, count(*), sum(id) FROM dv_test_orc GROUP BY batch
ORDER BY batch; """
+ qt_q27 """ SELECT id, data FROM dv_test WHERE id BETWEEN 2 AND 10 ORDER BY
id LIMIT 2; """
+ qt_q28 """ SELECT id, data FROM dv_test_v2 WHERE id BETWEEN 2 AND 10 ORDER
BY id LIMIT 2; """
+ qt_q29 """ SELECT id, data FROM dv_test_orc WHERE id BETWEEN 2 AND 10
ORDER BY id LIMIT 2; """
+ qt_q30 """ SELECT count(*) FROM (SELECT * FROM dv_test_1w WHERE id % 7 = 0
ORDER BY id LIMIT 100) t; """
+ qt_q31 """ SELECT count(*) FROM (SELECT * FROM dv_test_1w WHERE id % 11 =
0 ORDER BY id LIMIT 100) t; """
+
+ // Metadata matrix verifies the physical delete-file content types that
back the logical
+ // scenario checks above.
+ qt_delete_type_matrix """
+ SELECT 'dv_equality' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_delete_matrix_equality_and_dv\$files`
+ GROUP BY content, file_format
+ UNION ALL
+ SELECT 'dv_only' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_test\$files`
+ GROUP BY content, file_format
+ UNION ALL
+ SELECT 'dv_position' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_delete_matrix_position_and_dv\$files`
+ GROUP BY content, file_format
+ UNION ALL
+ SELECT 'equality_only' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_delete_matrix_equality_only\$files`
+ GROUP BY content, file_format
+ UNION ALL
+ SELECT 'no_delete' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_test_no_delete\$files`
+ GROUP BY content, file_format
+ UNION ALL
+ SELECT 'position_only' scenario, content, file_format, count(*) files,
sum(record_count) records
+ FROM `dv_delete_matrix_position_only\$files`
+ GROUP BY content, file_format
+ ORDER BY scenario, content, file_format;
+ """
+ // Iceberg v3 semantics: when a data file has a DV, old position deletes
for that file must be
+ // ignored, while equality deletes still filter matching rows.
+ qt_position_delete_ignored_with_dv """
+ SELECT id, name
+ FROM dv_delete_matrix_position_and_dv
+ WHERE id IN (1, 2, 5, 10, 15)
+ ORDER BY id, name
+ LIMIT 20;
+ """
+ qt_equality_delete_active_with_dv """
+ SELECT id, batch, data
+ FROM dv_delete_matrix_equality_and_dv
+ ORDER BY id;
+ """
+
+ def normalizeExternalRows = { String output ->
+ return output.readLines()
+ .collect { it.trim() }
+ .findAll { it ==~ /^-?[0-9].*/ && it.contains("\t") }
+ .join("\n")
+ }
+ String expectedRows = ["2\t1\tb", "4\t1\td", "6\t1\tf", "8\t1\th",
"12\t2\tl"].join("\n")
+ String sparkRows = normalizeExternalRows(runSparkSql(
+ "use format_v3; select id, batch, data from
dv_delete_matrix_equality_and_dv order by id;",
+ 300
+ ))
+ assertEquals(expectedRows, sparkRows)
+ String javaRows = normalizeExternalRows(runInSparkContainer(
+ "java -cp \"/tmp:/opt/spark/jars/*\" ReadIcebergRows " +
+ "format_v3 dv_delete_matrix_equality_and_dv id batch data",
+ 300
+ ))
+ assertEquals(expectedRows, javaRows)
+
+ String trinoContainerName = findRequiredDockerContainer(
+ "Trino", "icebergTrinoContainer",
+ "test -d /etc/trino/catalog && command -v trino >/dev/null")
+ String trinoExternalEnvIp = externalEnvIp
+ if (trinoExternalEnvIp == "127.0.0.1" ||
trinoExternalEnvIp.equalsIgnoreCase("localhost")) {
+ trinoExternalEnvIp = executeCommand("hostname -I | cut -d' ' -f1",
true, 30)?.trim()
+ }
+ String trinoCatalogProps = """
+connector.name=iceberg
+iceberg.catalog.type=rest
+iceberg.rest-catalog.uri=http://${trinoExternalEnvIp}:${rest_port}
+fs.native-s3.enabled=true
+s3.endpoint=http://${trinoExternalEnvIp}:${minio_port}
+s3.aws-access-key=admin
+s3.aws-secret-key=password
+s3.region=us-east-1
+s3.path-style-access=true
+"""
+ String encodedTrinoCatalogProps = encodeBase64(trinoCatalogProps)
+ executeCommand(
+ "${dockerCommand} exec ${trinoContainerName} bash -lc " +
+ "'echo ${encodedTrinoCatalogProps} | " +
+ "base64 -d >/etc/trino/catalog/iceberg.properties'",
+ true,
+ 30
+ )
+ executeCommand("${dockerCommand} restart ${trinoContainerName}", true, 60)
+ String trinoRows = ""
+ for (int i = 0; i < 12; i++) {
+ Thread.sleep(5000)
+ trinoRows = normalizeExternalRows(executeCommand(
+ "${dockerCommand} exec ${trinoContainerName} trino
--output-format TSV " +
+ "--catalog iceberg --schema format_v3 --execute " +
+ "\"SELECT id, batch, data " +
+ "FROM dv_delete_matrix_equality_and_dv ORDER BY id\"",
+ false,
+ 120
+ ))
+ if (!trinoRows.isEmpty()) {
+ break
+ }
+ }
+ assertEquals(expectedRows, trinoRows)
+ def profileCounterValues = { String profileText, String counterName ->
+ def values = []
+ def matcher = profileText =~ ("(?m)^\\s*(?:-\\s*)?" +
+ java.util.regex.Pattern.quote(counterName) + ":\\s+([^\\n]+)")
+ while (matcher.find()) {
+ String valueText = matcher.group(1).toString()
+ def exact = valueText =~ /\(([0-9,]+)\)/
+ String rawValue
+ if (exact.find()) {
+ rawValue = exact.group(1)
+ } else {
+ def number = valueText =~ /([0-9,]+)/
+ rawValue = number.find() ? number.group(1) : null
+ }
+ if (rawValue != null) {
+ values.add(Long.parseLong(rawValue.replace(",", "")))
+ }
+ }
+ return values
+ }
+
+ def profileInfoValueCount = { String profileText, String infoName ->
+ def matcher = profileText =~ (java.util.regex.Pattern.quote(infoName)
+ ":\\s*\\[([^\\]]*)\\]")
+ if (!matcher.find()) {
+ return 0
+ }
+ return matcher.group(1).split(",").collect { it.trim() }.findAll {
!it.isEmpty() }.size()
+ }
+
+ // Split/cache scenario for 3.4: this query scans one data file with
file_split_size=1. The
+ // result is captured by qt_split_cache_single_file_count; the profile
assertion checks the DV
+ // rows were materialized once for the shared scan-node cache, not once
per split.
+ String splitCacheProfileTag = "iceberg_dv_split_cache_" +
UUID.randomUUID().toString()
+ profile(splitCacheProfileTag) {
+ run {
+ sql """
+ /* ${splitCacheProfileTag} */
+ SELECT count(*), sum(id)
+ FROM dv_split_cache_single_file;
+ """
+ // The detailed scanner counters are populated asynchronously
after the query returns.
+ Thread.sleep(5000)
+ }
+ check { profileString, exception ->
+ if (exception != null) {
+ throw exception
+ }
+ String mergedProfile = profileString
+ if (profileString.contains("MergedProfile:")) {
+ mergedProfile =
profileString.substring(profileString.indexOf("MergedProfile:"))
+ int mergedProfileEnd = mergedProfile.length()
+ ["DetailProfile(", "Execution Profile:", "Appendix:"].each {
String sectionName ->
+ int sectionIndex = mergedProfile.indexOf(sectionName)
+ if (sectionIndex > 0) {
+ mergedProfileEnd = Math.min(mergedProfileEnd,
sectionIndex)
+ }
+ }
+ mergedProfile = mergedProfile.substring(0, mergedProfileEnd)
+ }
+ def numDeleteRowsValues = profileCounterValues(mergedProfile,
"NumDeleteRows")
+ numDeleteRowsValues = numDeleteRowsValues.findAll { it > 0L }
+ int scannerCount = profileInfoValueCount(profileString,
"PerScannerRowsRead")
+ assertFalse(numDeleteRowsValues.isEmpty(),
+ "Expected NumDeleteRows counter for split DV scan,
profile: ${profileString}")
+ assertEquals(2048L, numDeleteRowsValues.sum(0L),
+ "Expected DV rows to be materialized once across split
scanners, " +
+ "NumDeleteRows counters: ${numDeleteRowsValues},
profile: ${profileString}")
+ assertTrue(scannerCount > 1,
+ "Expected multiple scanner entries for split DV scan,
profile: ${profileString}")
+ }
+ }
+
+ // Cross-reader comparison protects against Doris accepting a
DV/equality-delete result that
+ // diverges from Spark, Trino, or Iceberg's Java reader.
+ explain {
+ sql("SELECT count(*) FROM dv_test;")
+ verbose(true)
+ contains "deleteFileNum"
+ }
+
+ explain {
+ sql("SELECT count(*) FROM dv_test_orc;")
+ verbose(true)
+ contains "deleteFileNum"
+ }
+
+ explain {
+ sql("SELECT count(*) FROM dv_test_v2;")
+ verbose(true)
+ contains "deleteFileNum"
+ }
+
+ } finally {
+ sql """ unset variable file_split_size;"""
+ }
}
diff --git
a/regression-test/suites/external_table_p0/paimon/test_paimon_deletion_vector.groovy
b/regression-test/suites/external_table_p0/paimon/test_paimon_deletion_vector.groovy
index 044166c3c5d..172df875927 100644
---
a/regression-test/suites/external_table_p0/paimon/test_paimon_deletion_vector.groovy
+++
b/regression-test/suites/external_table_p0/paimon/test_paimon_deletion_vector.groovy
@@ -38,6 +38,9 @@ suite("test_paimon_deletion_vector", "p0,external") {
def test_cases = { String force ->
sql """ set force_jni_scanner=${force} """
+ // Force tiny splits so both native and JNI paths read Paimon DV
tables through multiple
+ // scanner ranges while still returning the same filtered rows.
+ sql """ set file_split_size=1 """
qt_1 """select count(*) from deletion_vector_orc;"""
qt_2 """select count(*) from deletion_vector_parquet;"""
qt_3 """select count(*) from deletion_vector_orc where id > 2;"""
@@ -47,10 +50,16 @@ suite("test_paimon_deletion_vector", "p0,external") {
qt_7 """select * from deletion_vector_table_1_0 order by id;"""
qt_8 """select count(*) from deletion_vector_table_1_0;"""
qt_9 """select count(*) from deletion_vector_table_1_0 where id >
2;"""
+ qt_10 """select * from deletion_vector_orc where id > 2 order by
id limit 1;"""
+ qt_11 """select * from deletion_vector_parquet where id > 2 order
by id limit 1;"""
+ qt_12 """select count(*) from (select * from deletion_vector_orc
where id > 2 union all select * from deletion_vector_orc where id > 2) t;"""
+ qt_13 """select count(*) from (select * from
deletion_vector_parquet where id > 2 union all select * from
deletion_vector_parquet where id > 2) t;"""
}
def test_table_count_push_down = { String force ->
sql """ set force_jni_scanner=${force} """
+ // DV tables cannot use table-level count pushdown because deleted
rows must be applied;
+ // the no-DV v1.0 table keeps the positive pushdown baseline.
explain {
sql("select count(*) from deletion_vector_orc;")
contains "pushdown agg=COUNT (-1)"
@@ -68,6 +77,8 @@ suite("test_paimon_deletion_vector", "p0,external") {
def test_not_table_count_push_down = { String force ->
sql """ set enable_count_push_down_for_external_table=false; """
sql """ set force_jni_scanner=${force} """
+ // With count pushdown disabled, all DV and non-DV tables must
report NONE regardless
+ // of the selected scanner path.
explain {
sql("select count(*) from deletion_vector_orc;")
contains "pushdown agg=NONE"
@@ -90,7 +101,8 @@ suite("test_paimon_deletion_vector", "p0,external") {
test_not_table_count_push_down("true")
} finally {
sql """ set enable_count_push_down_for_external_table=true; """
+ sql """unset variable file_split_size"""
sql """set force_jni_scanner=false"""
}
-}
\ No newline at end of file
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]