This is an automated email from the ASF dual-hosted git repository.
SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new a965a7d4 fix(read): correct data evolution selection and blob backfill
reads (#472)
a965a7d4 is described below
commit a965a7d4c70d63d84bad1a8dfd619fce68282c6c
Author: lxy <[email protected]>
AuthorDate: Wed Oct 28 19:25:52 2026 +0800
fix(read): correct data evolution selection and blob backfill reads (#472)
---
include/paimon/utils/roaring_bitmap32.h | 3 +
include/paimon/utils/roaring_bitmap64.h | 4 +
.../common/reader/blob_fallback_batch_reader.cpp | 9 +-
.../common/reader/blob_fallback_batch_reader.h | 6 +-
.../reader/blob_fallback_batch_reader_test.cpp | 13 +-
src/paimon/common/utils/roaring_bitmap32.cpp | 15 +-
src/paimon/common/utils/roaring_bitmap32_test.cpp | 27 ++
src/paimon/common/utils/roaring_bitmap64.cpp | 47 ++-
src/paimon/common/utils/roaring_bitmap64_test.cpp | 36 ++
.../apply_deletion_vector_batch_reader.h | 10 +-
.../core/operation/data_evolution_split_read.cpp | 368 +++++++++++-----
.../core/operation/data_evolution_split_read.h | 76 ++--
.../operation/data_evolution_split_read_test.cpp | 466 ++++++++++++++++++---
test/inte/blob_table_inte_test.cpp | 141 ++++++-
test/inte/data_evolution_table_test.cpp | 167 +++++++-
15 files changed, 1132 insertions(+), 256 deletions(-)
diff --git a/include/paimon/utils/roaring_bitmap32.h
b/include/paimon/utils/roaring_bitmap32.h
index 29d45809..85f6a87e 100644
--- a/include/paimon/utils/roaring_bitmap32.h
+++ b/include/paimon/utils/roaring_bitmap32.h
@@ -65,6 +65,8 @@ class PAIMON_EXPORT RoaringBitmap32 {
bool operator!=(const Iterator& other) const;
private:
+ Iterator() = default;
+ // Null represents the end sentinel without an underlying iterator
allocation.
void* iterator_ = nullptr;
};
@@ -136,6 +138,7 @@ class PAIMON_EXPORT RoaringBitmap32 {
std::string ToString() const;
Iterator Begin() const;
+ /// Returns an end sentinel without allocating an underlying iterator.
Iterator End() const;
/// @return the iterator moved to the value which is equal or larger than
key
Iterator EqualOrLarger(int32_t key) const;
diff --git a/include/paimon/utils/roaring_bitmap64.h
b/include/paimon/utils/roaring_bitmap64.h
index 289622b0..931f1b52 100644
--- a/include/paimon/utils/roaring_bitmap64.h
+++ b/include/paimon/utils/roaring_bitmap64.h
@@ -65,9 +65,12 @@ class PAIMON_EXPORT RoaringBitmap64 {
bool operator==(const Iterator& other) const;
bool operator!=(const Iterator& other) const;
/// Move the iterator to the value which is equal or larger than input
value
+ /// An End() sentinel remains at the end.
void EqualOrLarger(int64_t value);
private:
+ Iterator() = default;
+ // Null represents the end sentinel without an underlying iterator
allocation.
void* iterator_ = nullptr;
};
@@ -149,6 +152,7 @@ class PAIMON_EXPORT RoaringBitmap64 {
std::string ToString() const;
Iterator Begin() const;
+ /// Returns an end sentinel without allocating an underlying iterator.
Iterator End() const;
/// @return the iterator moved to the value which is equal or larger than
key
Iterator EqualOrLarger(int64_t key) const;
diff --git a/src/paimon/common/reader/blob_fallback_batch_reader.cpp
b/src/paimon/common/reader/blob_fallback_batch_reader.cpp
index 830b0df4..a5e23855 100644
--- a/src/paimon/common/reader/blob_fallback_batch_reader.cpp
+++ b/src/paimon/common/reader/blob_fallback_batch_reader.cpp
@@ -42,10 +42,8 @@ Result<std::unique_ptr<BlobFallbackBatchReader>>
BlobFallbackBatchReader::Create
std::vector<std::vector<Segment>>&& sequence_groups,
const std::shared_ptr<arrow::Schema>& read_schema, int32_t read_batch_size,
const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
- if (sequence_groups.size() < 2) {
- return Status::Invalid(
- "Blob fallback needs at least two sequence groups; a single group
should be read "
- "sequentially.");
+ if (sequence_groups.empty()) {
+ return Status::Invalid("Blob fallback needs at least one sequence
group.");
}
if (read_schema == nullptr) {
return Status::Invalid("Blob fallback read schema cannot be nullptr.");
@@ -339,6 +337,9 @@ Result<BatchReader::ReadBatch>
BlobFallbackBatchReader::NextBatch() {
return Status::Invalid("blob fallback batch reader is closed");
}
std::vector<std::vector<Chunk>> group_chunks(groups_.size());
+ // TODO(xinyu.lxy): Older layers are materialized even when newer values
cover them, adding
+ // unnecessary reads and potentially exposing stale payload errors. Add
Java-like read/skip
+ // support to materialize older values only when fallback is needed.
// the first (newest) group defines the window; the others must step in
lockstep
PAIMON_ASSIGN_OR_RAISE(int64_t row_count, FillWindow(0, read_batch_size_,
&group_chunks[0]));
for (size_t g = 1; g < groups_.size(); g++) {
diff --git a/src/paimon/common/reader/blob_fallback_batch_reader.h
b/src/paimon/common/reader/blob_fallback_batch_reader.h
index e15667b4..36bceb99 100644
--- a/src/paimon/common/reader/blob_fallback_batch_reader.h
+++ b/src/paimon/common/reader/blob_fallback_batch_reader.h
@@ -73,9 +73,9 @@ class BlobFallbackBatchReader : public BatchReader {
};
/// `sequence_groups` must be ordered by descending max sequence number
and contain at least
- /// two groups (a single group needs no fallback). `read_schema` is the
schema every file
- /// reader emits; it must contain exactly one blob field and may
additionally contain the
- /// row-tracking fields _ROW_ID and _SEQUENCE_NUMBER (completed per file by
+ /// one group (a single group can have gaps to fill with NULLs).
`read_schema` is the schema
+ /// every file reader emits; it must contain exactly one blob field and
may additionally contain
+ /// the row-tracking fields _ROW_ID and _SEQUENCE_NUMBER (completed per
file by
/// CompleteRowTrackingFieldsBatchReader), which stay correct for rows
that are a
/// placeholder in every layer.
static Result<std::unique_ptr<BlobFallbackBatchReader>> Create(
diff --git a/src/paimon/common/reader/blob_fallback_batch_reader_test.cpp
b/src/paimon/common/reader/blob_fallback_batch_reader_test.cpp
index 7eac84b4..9f67737d 100644
--- a/src/paimon/common/reader/blob_fallback_batch_reader_test.cpp
+++ b/src/paimon/common/reader/blob_fallback_batch_reader_test.cpp
@@ -145,6 +145,11 @@ TEST_F(BlobFallbackBatchReaderTest, TestBasicFallback) {
{"b0", "u1", "b2"});
}
+TEST_F(BlobFallbackBatchReaderTest, TestSingleLayerFillsUnwrittenRows) {
+ CheckFallback({{SegmentSpec::Gap(2), SegmentSpec::File({"b2", "PH"}),
SegmentSpec::Gap(2)}},
+ {std::nullopt, std::nullopt, "b2", std::nullopt,
std::nullopt, std::nullopt});
+}
+
TEST_F(BlobFallbackBatchReaderTest, TestGapPadding) {
// the newer layer only covers rows 2-3; the gaps stand for placeholders
CheckFallback({{SegmentSpec::Gap(2), SegmentSpec::File({"u2", "PH"})},
@@ -325,12 +330,10 @@ TEST_F(BlobFallbackBatchReaderTest,
TestMisalignedGroupsFail) {
}
TEST_F(BlobFallbackBatchReaderTest, TestCreateValidation) {
- // a single group needs no fallback
- std::vector<std::vector<BlobFallbackBatchReader::Segment>> single_group;
- single_group.push_back(MakeGroup({SegmentSpec::File({"b0"})}, 1024));
-
ASSERT_NOK_WITH_MSG(BlobFallbackBatchReader::Create(std::move(single_group),
read_schema_, 1024,
+ std::vector<std::vector<BlobFallbackBatchReader::Segment>> empty_groups;
+
ASSERT_NOK_WITH_MSG(BlobFallbackBatchReader::Create(std::move(empty_groups),
read_schema_, 1024,
GetArrowPool(pool_)),
- "at least two sequence groups");
+ "at least one sequence group");
// the read schema must contain a blob field
std::vector<std::vector<BlobFallbackBatchReader::Segment>> groups;
diff --git a/src/paimon/common/utils/roaring_bitmap32.cpp
b/src/paimon/common/utils/roaring_bitmap32.cpp
index 8a90952a..7644c550 100644
--- a/src/paimon/common/utils/roaring_bitmap32.cpp
+++ b/src/paimon/common/utils/roaring_bitmap32.cpp
@@ -56,6 +56,11 @@ RoaringBitmap32::Iterator&
RoaringBitmap32::Iterator::operator=(
if (&other == this) {
return *this;
}
+ if (!other.iterator_) {
+ delete static_cast<roaring::Roaring::const_iterator*>(iterator_);
+ iterator_ = nullptr;
+ return *this;
+ }
if (!iterator_) {
iterator_ = new
roaring::Roaring::const_iterator(GetIterator(other.iterator_));
} else {
@@ -92,7 +97,11 @@ bool RoaringBitmap32::Iterator::operator==(const Iterator&
other) const {
if (&other == this) {
return true;
}
- assert(iterator_ && other.iterator_);
+ bool is_end = !iterator_ || !GetIterator(iterator_).i.has_value;
+ bool other_is_end = !other.iterator_ ||
!GetIterator(other.iterator_).i.has_value;
+ if (is_end || other_is_end) {
+ return is_end == other_is_end;
+ }
return GetIterator(iterator_) == GetIterator(other.iterator_);
}
bool RoaringBitmap32::Iterator::operator!=(const Iterator& other) const {
@@ -301,9 +310,7 @@ RoaringBitmap32::Iterator RoaringBitmap32::Begin() const {
}
RoaringBitmap32::Iterator RoaringBitmap32::End() const {
- RoaringBitmap32::Iterator iter(*this);
- GetIterator(iter.iterator_) = GetRoaringBitmap(roaring_bitmap_).end();
- return iter;
+ return Iterator();
}
RoaringBitmap32::Iterator RoaringBitmap32::EqualOrLarger(int32_t key) const {
diff --git a/src/paimon/common/utils/roaring_bitmap32_test.cpp
b/src/paimon/common/utils/roaring_bitmap32_test.cpp
index 239381f2..b4334a9c 100644
--- a/src/paimon/common/utils/roaring_bitmap32_test.cpp
+++ b/src/paimon/common/utils/roaring_bitmap32_test.cpp
@@ -256,6 +256,21 @@ TEST(RoaringBitmap32Test, TestIterator) {
ASSERT_EQ(iter, roaring.End());
}
+TEST(RoaringBitmap32Test, TestEndIterator) {
+ RoaringBitmap32 empty;
+ ASSERT_EQ(empty.Begin(), empty.End());
+ ASSERT_EQ(empty.EqualOrLarger(0), empty.End());
+
+ auto bitmap = RoaringBitmap32::From({0, 10, 20});
+ auto iter = bitmap.Begin();
+ std::vector<int32_t> values;
+ for (; iter != bitmap.End(); ++iter) {
+ values.push_back(*iter);
+ }
+ ASSERT_EQ(values, (std::vector<int32_t>{0, 10, 20}));
+ ASSERT_EQ(iter, bitmap.End());
+}
+
TEST(RoaringBitmap32Test, TestNextAndPreviousValue) {
RoaringBitmap32 roaring = RoaringBitmap32::From({10, 20, 30});
@@ -307,6 +322,18 @@ TEST(RoaringBitmap32Test, TestIteratorAssignAndMove) {
iter3 = std::move(iter2);
ASSERT_EQ(10, *iter3);
ASSERT_FALSE(iter2.iterator_); // NOLINT(bugprone-use-after-move)
+
+ iter3 = roaring.End();
+ auto copied_end = iter3;
+ auto moved_end = std::move(copied_end);
+ ASSERT_EQ(moved_end, roaring.End());
+ iter3 = roaring.Begin();
+ iter3 = moved_end;
+ ASSERT_EQ(iter3, roaring.End());
+ iter3 = roaring.Begin();
+ ASSERT_EQ(*iter3, 10);
+ iter3 = std::move(moved_end);
+ ASSERT_EQ(iter3, roaring.End());
}
TEST(RoaringBitmap32Test, TestDeserializeFromInputStream) {
diff --git a/src/paimon/common/utils/roaring_bitmap64.cpp
b/src/paimon/common/utils/roaring_bitmap64.cpp
index 510ef4e8..0bd4f8c1 100644
--- a/src/paimon/common/utils/roaring_bitmap64.cpp
+++ b/src/paimon/common/utils/roaring_bitmap64.cpp
@@ -30,8 +30,19 @@
namespace paimon {
namespace {
-roaring::Roaring64Map::const_iterator& GetIterator(void* iter) {
- return *(static_cast<roaring::Roaring64Map::const_iterator*>(iter));
+// Expose exhaustion without constructing another native iterator or modifying
CRoaring.
+class Bitmap64Iterator : public roaring::Roaring64Map::const_iterator {
+ public:
+ explicit Bitmap64Iterator(const roaring::Roaring64Map& bitmap)
+ : roaring::Roaring64Map::const_iterator(bitmap) {}
+
+ bool IsEnd() const {
+ return map_iter == map_end;
+ }
+};
+
+Bitmap64Iterator& GetIterator(void* iter) {
+ return *(static_cast<Bitmap64Iterator*>(iter));
}
roaring::Roaring64Map& GetRoaringBitmap(void* bitmap) {
return *(static_cast<roaring::Roaring64Map*>(bitmap));
@@ -39,13 +50,12 @@ roaring::Roaring64Map& GetRoaringBitmap(void* bitmap) {
} // namespace
RoaringBitmap64::Iterator::Iterator(const RoaringBitmap64& bitmap) {
- iterator_ =
- new
roaring::Roaring64Map::const_iterator(GetRoaringBitmap(bitmap.roaring_bitmap_).begin());
+ iterator_ = new Bitmap64Iterator(GetRoaringBitmap(bitmap.roaring_bitmap_));
}
RoaringBitmap64::Iterator::~Iterator() {
if (iterator_) {
- delete static_cast<roaring::Roaring64Map::const_iterator*>(iterator_);
+ delete static_cast<Bitmap64Iterator*>(iterator_);
}
}
@@ -57,8 +67,13 @@ RoaringBitmap64::Iterator&
RoaringBitmap64::Iterator::operator=(
if (&other == this) {
return *this;
}
+ if (!other.iterator_) {
+ delete static_cast<Bitmap64Iterator*>(iterator_);
+ iterator_ = nullptr;
+ return *this;
+ }
if (!iterator_) {
- iterator_ = new
roaring::Roaring64Map::const_iterator(GetIterator(other.iterator_));
+ iterator_ = new Bitmap64Iterator(GetIterator(other.iterator_));
} else {
GetIterator(iterator_) = GetIterator(other.iterator_);
}
@@ -75,7 +90,7 @@ RoaringBitmap64::Iterator&
RoaringBitmap64::Iterator::operator=(
return *this;
}
if (iterator_) {
- delete static_cast<roaring::Roaring64Map::const_iterator*>(iterator_);
+ delete static_cast<Bitmap64Iterator*>(iterator_);
}
iterator_ = other.iterator_;
other.iterator_ = nullptr;
@@ -93,7 +108,11 @@ bool RoaringBitmap64::Iterator::operator==(const Iterator&
other) const {
if (&other == this) {
return true;
}
- assert(iterator_ && other.iterator_);
+ bool is_end = !iterator_ || GetIterator(iterator_).IsEnd();
+ bool other_is_end = !other.iterator_ ||
GetIterator(other.iterator_).IsEnd();
+ if (is_end || other_is_end) {
+ return is_end == other_is_end;
+ }
return GetIterator(iterator_) == GetIterator(other.iterator_);
}
bool RoaringBitmap64::Iterator::operator!=(const Iterator& other) const {
@@ -101,6 +120,9 @@ bool RoaringBitmap64::Iterator::operator!=(const Iterator&
other) const {
}
void RoaringBitmap64::Iterator::EqualOrLarger(int64_t value) {
+ if (!iterator_) {
+ return;
+ }
[[maybe_unused]] bool _ = GetIterator(iterator_).move(value);
}
@@ -372,17 +394,12 @@ RoaringBitmap64::Iterator RoaringBitmap64::Begin() const {
}
RoaringBitmap64::Iterator RoaringBitmap64::End() const {
- RoaringBitmap64::Iterator iter(*this);
- GetIterator(iter.iterator_) = GetRoaringBitmap(roaring_bitmap_).end();
- return iter;
+ return Iterator();
}
RoaringBitmap64::Iterator RoaringBitmap64::EqualOrLarger(int64_t key) const {
RoaringBitmap64::Iterator iter(*this);
- bool not_end = GetIterator(iter.iterator_).move(key);
- if (!not_end) {
- return End();
- }
+ iter.EqualOrLarger(key);
return iter;
}
diff --git a/src/paimon/common/utils/roaring_bitmap64_test.cpp
b/src/paimon/common/utils/roaring_bitmap64_test.cpp
index 6a51d29a..e77aba03 100644
--- a/src/paimon/common/utils/roaring_bitmap64_test.cpp
+++ b/src/paimon/common/utils/roaring_bitmap64_test.cpp
@@ -275,6 +275,22 @@ TEST(RoaringBitmap64Test, TestIterator) {
ASSERT_EQ(iter, roaring.End());
}
+TEST(RoaringBitmap64Test, TestEndIterator) {
+ RoaringBitmap64 empty;
+ ASSERT_EQ(empty.Begin(), empty.End());
+ ASSERT_EQ(empty.EqualOrLarger(0), empty.End());
+
+ const std::vector<int64_t> expected = {0, 1LL << 32,
RoaringBitmap64::MAX_VALUE};
+ auto bitmap = RoaringBitmap64::From(expected);
+ auto iter = bitmap.Begin();
+ std::vector<int64_t> values;
+ for (; iter != bitmap.End(); ++iter) {
+ values.push_back(*iter);
+ }
+ ASSERT_EQ(values, expected);
+ ASSERT_EQ(iter, bitmap.End());
+}
+
TEST(RoaringBitmap64Test, TestIteratorAssignAndMove) {
RoaringBitmap64 roaring =
RoaringBitmap64::From({10000000000010l, 100000000000100l,
100000000000200l});
@@ -308,6 +324,18 @@ TEST(RoaringBitmap64Test, TestIteratorAssignAndMove) {
iter3 = std::move(iter2);
ASSERT_EQ(10000000000010l, *iter3);
ASSERT_FALSE(iter2.iterator_); // NOLINT(bugprone-use-after-move)
+
+ iter3 = roaring.End();
+ auto copied_end = iter3;
+ auto moved_end = std::move(copied_end);
+ ASSERT_EQ(moved_end, roaring.End());
+ iter3 = roaring.Begin();
+ iter3 = moved_end;
+ ASSERT_EQ(iter3, roaring.End());
+ iter3 = roaring.Begin();
+ ASSERT_EQ(*iter3, 10000000000010l);
+ iter3 = std::move(moved_end);
+ ASSERT_EQ(iter3, roaring.End());
}
TEST(RoaringBitmap64Test, TestDeserializeFromInputStream) {
@@ -417,6 +445,14 @@ TEST(RoaringBitmap64Test, TestIteratorEqualOrLarger) {
ASSERT_EQ(*iter, 100l);
iter.EqualOrLarger(200l);
ASSERT_EQ(iter, roaring.End());
+ ASSERT_EQ(roaring.End(), iter);
+ iter.EqualOrLarger(3l);
+ ASSERT_EQ(*iter, 3l);
+
+ iter = roaring.EqualOrLarger(200l);
+ ASSERT_EQ(iter, roaring.End());
+ iter.EqualOrLarger(5l);
+ ASSERT_EQ(*iter, 5l);
}
// Helper function to convert a RoaringBitmap64 to a list of contiguous ranges.
diff --git
a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
index c428a079..704247e9 100644
--- a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
+++ b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
@@ -57,8 +57,7 @@ class ApplyDeletionVectorBatchReader : public FileBatchReader
{
return batch_with_bitmap;
}
auto& [batch, bitmap] = batch_with_bitmap;
- PAIMON_ASSIGN_OR_RAISE(RoaringBitmap32 valid_bitmap,
Filter(batch.first->length));
- bitmap &= valid_bitmap;
+ PAIMON_ASSIGN_OR_RAISE(bitmap, Filter(bitmap));
if (bitmap.IsEmpty()) {
ReaderUtils::ReleaseReadBatch(std::move(batch));
continue;
@@ -101,9 +100,12 @@ class ApplyDeletionVectorBatchReader : public
FileBatchReader {
}
private:
- Result<RoaringBitmap32> Filter(int32_t batch_size) const {
+ Result<RoaringBitmap32> Filter(const RoaringBitmap32& selection) const {
RoaringBitmap32 is_valid;
- for (int32_t i = 0; i < batch_size; ++i) {
+ // Unselected rows may lie outside the group's anchor range. Probe
only rows that the
+ // format/index selection actually returns, just as
ApplyDeletionVectorReader does.
+ for (auto iter = selection.Begin(); iter != selection.End(); ++iter) {
+ int32_t i = *iter;
PAIMON_ASSIGN_OR_RAISE(uint64_t file_row_id,
reader_->GetPreviousBatchFileRowId(i));
PAIMON_ASSIGN_OR_RAISE(bool is_deleted,
deletion_vector_->IsDeleted(file_row_id));
if (!is_deleted) {
diff --git a/src/paimon/core/operation/data_evolution_split_read.cpp
b/src/paimon/core/operation/data_evolution_split_read.cpp
index 5fea0f21..87dc99cc 100644
--- a/src/paimon/core/operation/data_evolution_split_read.cpp
+++ b/src/paimon/core/operation/data_evolution_split_read.cpp
@@ -52,6 +52,7 @@
#include "paimon/common/utils/vector_store_utils.h"
#include "paimon/core/core_options.h"
#include "paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h"
+#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
#include "paimon/core/global_index/indexed_split_impl.h"
#include "paimon/core/io/file_index_evaluator.h"
#include "paimon/core/io/file_index_options.h"
@@ -137,63 +138,53 @@ class PositionShiftedDeletionVector : public
DeletionVector {
};
} // namespace
-int64_t DataEvolutionSplitRead::BlobBunch::RowCount() const {
- if (files_.empty()) {
- return 0;
+Result<int64_t> DataEvolutionSplitRead::BlobBunch::RowCount() const {
+ const auto merged = Range::SortAndMergeOverlap(ranges_, /*adjacent=*/true);
+ if (expected_row_range_) {
+ for (const auto& range : merged) {
+ if (range.from < expected_row_range_->from || range.to >
expected_row_range_->to) {
+ return Status::Invalid("Blob file range should be within
normal file range.");
+ }
+ }
+ return expected_row_range_->Count();
}
- if (!has_row_ids_selection_) {
- // Add enforces the union range to be contiguous
- return union_end_row_id_ - union_first_row_id_;
+ if (!has_row_ids_selection_ && merged.size() != 1) {
+ return Status::Invalid("Blob file bunch should always contain a
contiguous row range.");
}
- // with a row-ids selection the scan may have pruned files, leaving holes
in the union
int64_t row_count = 0;
- for (const auto& range : Range::SortAndMergeOverlap(ranges_,
/*adjacent=*/true)) {
+ for (const auto& range : merged) {
row_count += range.Count();
}
return row_count;
}
+Result<Range> DataEvolutionSplitRead::BlobBunch::LogicalRange() const {
+ if (expected_row_range_) {
+ return *expected_row_range_;
+ }
+ const auto merged = Range::SortAndMergeOverlap(ranges_, /*adjacent=*/true);
+ if (merged.empty()) {
+ return Status::Invalid("Blob file bunch should not be empty.");
+ }
+ return Range(merged.front().from, merged.back().to);
+}
+
Status DataEvolutionSplitRead::BlobBunch::Add(const
std::shared_ptr<DataFileMeta>& file) {
if (!BlobUtils::IsBlobFile(file->file_name)) {
return Status::Invalid("Only blob file can be added to a blob bunch.");
}
- PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id, file->NonNullFirstRowId());
if (!files_.empty()) {
// Blob files for the same field may span schema ids.
if (file->write_cols != files_[0]->write_cols) {
return Status::Invalid("All files in a blob bunch should have the
same write columns.");
}
}
- // files sharing a max sequence number form one layer, whose row id ranges
must be disjoint;
- // overlaps across layers are the expected shape of partial updates and
are kept for the
- // row-level placeholder fallback
- auto [layer_iter, layer_inserted] =
- sequence_group_end_.try_emplace(file->max_sequence_number, 0);
- if (!layer_inserted && first_row_id < layer_iter->second) {
- return Status::Invalid(fmt::format(
- "Blob files with the same max sequence number should not have
overlapping row id "
- "ranges: file {} (max sequence number {}) starts at row id {}
before the previous "
- "file's end {}",
- file->file_name, file->max_sequence_number, first_row_id,
layer_iter->second));
- }
- if (!has_row_ids_selection_ && !files_.empty() && first_row_id >
union_end_row_id_) {
- // a hole no layer covers cannot be aligned with the data files
- return Status::Invalid(
- fmt::format("Blob file first row id should be continuous, expect
{} but got {}",
- union_end_row_id_, first_row_id));
- }
- int64_t end_row_id = first_row_id + file->row_count;
- layer_iter->second = end_row_id;
- union_first_row_id_ = std::min(union_first_row_id_, first_row_id);
- union_end_row_id_ = std::max(union_end_row_id_, end_row_id);
- ranges_.emplace_back(first_row_id, end_row_id - 1);
+ PAIMON_ASSIGN_OR_RAISE(Range range, file->NonNullRowIdRange());
files_.push_back(file);
- if (!has_row_ids_selection_ && expected_row_count_ >= 0 && RowCount() >
expected_row_count_) {
- return Status::Invalid(
- fmt::format("Blob files row count exceed the expect {}",
expected_row_count_));
- }
+ ranges_.push_back(range);
return Status::OK();
}
+
DataEvolutionSplitRead::DataEvolutionSplitRead(
const std::shared_ptr<FileStorePathFactory>& path_factory,
const std::shared_ptr<InternalReadContext>& context,
@@ -346,7 +337,7 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobViewReade
PAIMON_ASSIGN_OR_RAISE(std::optional<GroupDeletionVector> group_dv,
ReadGroupDeletionVector(group,
split_dv_factory));
PAIMON_ASSIGN_OR_RAISE(DeletionVector::Factory group_dv_factory,
- CreateGroupDvFactory(data_files, group_dv));
+ CreateGroupDvFactory(data_files, group_dv,
row_ranges));
PAIMON_ASSIGN_OR_RAISE(
std::vector<std::unique_ptr<FileBatchReader>> raw_file_readers,
CreateRawFileReaders(split_impl->Partition(), data_files,
blob_view_schema,
@@ -431,26 +422,35 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::InnerCreateReader(
std::vector<std::unique_ptr<BatchReader>> sub_readers;
for (const std::vector<std::shared_ptr<DataFileMeta>>& need_merge_files :
split_by_row_id) {
- if (need_merge_files.size() > 1) {
- PAIMON_ASSIGN_OR_RAISE(
- bool skip_group,
- SkipByFileIndex(push_down_predicate, need_merge_files,
data_file_path_factory));
- if (skip_group) {
- continue;
- }
+ PAIMON_ASSIGN_OR_RAISE(std::optional<RoaringBitmap32> group_selection,
+ SelectByFileIndex(push_down_predicate,
need_merge_files, read_schema,
+ row_ranges,
data_file_path_factory));
+ if (group_selection && group_selection->IsEmpty()) {
+ continue;
}
PAIMON_ASSIGN_OR_RAISE(std::optional<GroupDeletionVector> group_dv,
ReadGroupDeletionVector(need_merge_files,
split_dv_factory));
PAIMON_ASSIGN_OR_RAISE(DeletionVector::Factory group_dv_factory,
- CreateGroupDvFactory(need_merge_files,
group_dv));
+ CreateGroupDvFactory(need_merge_files,
group_dv, row_ranges));
+ if (group_dv && group_selection) {
+ PAIMON_ASSIGN_OR_RAISE(
+ *group_selection,
+ ExcludeDeletedPositions(std::move(*group_selection),
group_dv->deletion_vector));
+ if (group_selection->IsEmpty()) {
+ continue;
+ }
+ }
+ DataEvolutionSplitRead group_read(path_factory_, context_, pool_,
executor_);
+ group_read.file_indexes_evaluated_ = true;
+ group_read.group_selection_ = std::move(group_selection);
if (need_merge_files.size() == 1) {
// No need to merge fields, just create a single file reader
PAIMON_ASSIGN_OR_RAISE(
std::vector<std::unique_ptr<FileBatchReader>> raw_file_readers,
- CreateRawFileReaders(split_impl->Partition(),
need_merge_files, read_schema,
- push_down_predicate, group_dv_factory,
row_ranges,
- data_file_path_factory,
- /*extra_format_options=*/{}));
+ group_read.CreateRawFileReaders(split_impl->Partition(),
need_merge_files,
+ read_schema,
push_down_predicate, group_dv_factory,
+ row_ranges,
data_file_path_factory,
+ /*extra_format_options=*/{}));
if (raw_file_readers.empty()) {
continue;
}
@@ -461,8 +461,9 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::InnerCreateReader(
} else {
PAIMON_ASSIGN_OR_RAISE(
std::unique_ptr<BatchReader> evolution_reader,
- CreateUnionReader(split_impl->Partition(), need_merge_files,
row_ranges,
- data_file_path_factory, group_dv_factory,
group_dv, read_schema));
+ group_read.CreateUnionReader(split_impl->Partition(),
need_merge_files, row_ranges,
+ data_file_path_factory,
group_dv_factory, group_dv,
+ read_schema));
sub_readers.push_back(std::move(evolution_reader));
}
}
@@ -487,22 +488,55 @@ Result<std::shared_ptr<Predicate>>
DataEvolutionSplitRead::CreatePushDownPredica
return PredicateUtils::CreatePickedFieldFilter(predicate,
picked_field_name_to_idx);
}
-Result<bool> DataEvolutionSplitRead::SkipByFileIndex(
+Result<std::optional<RoaringBitmap32>>
DataEvolutionSplitRead::SelectByFileIndex(
const std::shared_ptr<Predicate>& predicate,
const std::vector<std::shared_ptr<DataFileMeta>>& files,
+ const std::shared_ptr<arrow::Schema>& read_schema,
+ const std::optional<std::vector<Range>>& row_ranges,
const std::shared_ptr<DataFilePathFactory>& data_file_path_factory) const {
+ if (row_ranges) {
+ std::vector<Range> file_ranges;
+ for (const auto& file : files) {
+ PAIMON_ASSIGN_OR_RAISE(Range range, file->NonNullRowIdRange());
+ file_ranges.push_back(range);
+ }
+ if (Range::And(Range::SortAndMergeOverlap(*row_ranges,
/*adjacent=*/true),
+ Range::SortAndMergeOverlap(file_ranges,
/*adjacent=*/true))
+ .empty()) {
+ return std::optional<RoaringBitmap32>(RoaringBitmap32());
+ }
+ }
if (!options_.FileIndexReadEnabled() || !predicate) {
- return false;
+ return std::optional<RoaringBitmap32>();
+ }
+
+ // Like Java's canPushDownGroupSelection, only share exact bitmap
selections across
+ // normal files with the same row-id range. Other groups can still be
rejected by indexes.
+ bool can_push_down_selection = true;
+ if (files.size() > 1) {
+ PAIMON_ASSIGN_OR_RAISE(Range group_range,
files[0]->NonNullRowIdRange());
+ for (const auto& file : files) {
+ if (!DataEvolutionUtils::IsNormalFile(file->file_name)) {
+ can_push_down_selection = false;
+ break;
+ }
+ PAIMON_ASSIGN_OR_RAISE(Range file_range,
file->NonNullRowIdRange());
+ if (!(file_range == group_range)) {
+ can_push_down_selection = false;
+ break;
+ }
+ }
}
PAIMON_ASSIGN_OR_RAISE(
std::unique_ptr<FieldMappingBuilder> field_mapping_builder,
- FieldMappingBuilder::Create(raw_read_schema_,
context_->GetPartitionKeys(), predicate));
+ FieldMappingBuilder::Create(read_schema, context_->GetPartitionKeys(),
predicate));
+ std::optional<RoaringBitmap32> selection;
std::set<int32_t> claimed_field_ids;
for (const auto& file : files) {
// Blob and vector-store files may cover only part of the row range,
so their indexes
// cannot prove that the complete merged group misses the predicate.
- if (!DataEvolutionUtils::IsNormalFile(file->file_name)) {
+ if (files.size() > 1 &&
!DataEvolutionUtils::IsNormalFile(file->file_name)) {
continue;
}
@@ -555,10 +589,28 @@ Result<bool> DataEvolutionSplitRead::SkipByFileIndex(
file,
options_.GetFileSystem(), pool_));
PAIMON_ASSIGN_OR_RAISE(bool is_remain, index_result->IsRemain());
if (!is_remain) {
- return true;
+ return std::optional<RoaringBitmap32>(RoaringBitmap32());
+ }
+ if (!can_push_down_selection) {
+ continue;
+ }
+ if (auto* bitmap_index =
dynamic_cast<BitmapIndexResult*>(index_result.get())) {
+ PAIMON_ASSIGN_OR_RAISE(const RoaringBitmap32* bitmap,
bitmap_index->GetBitmap());
+ if (selection) {
+ *selection &= *bitmap;
+ } else {
+ selection = *bitmap;
+ }
+ }
+ }
+ if (selection && row_ranges) {
+ PAIMON_ASSIGN_OR_RAISE(std::optional<RoaringBitmap32> range_selection,
+ files[0]->ToFileSelection(row_ranges));
+ if (range_selection) {
+ *selection &= *range_selection;
}
}
- return false;
+ return selection;
}
Result<std::unique_ptr<FileBatchReader>>
DataEvolutionSplitRead::ApplyIndexAndDvReaderIfNeeded(
@@ -570,7 +622,7 @@ Result<std::unique_ptr<FileBatchReader>>
DataEvolutionSplitRead::ApplyIndexAndDv
const std::map<std::string, std::string>& file_index_options,
std::vector<float>* index_scores) const {
std::shared_ptr<FileIndexResult> file_index_result;
- if (options_.FileIndexReadEnabled()) {
+ if (options_.FileIndexReadEnabled() && !file_indexes_evaluated_) {
PAIMON_ASSIGN_OR_RAISE(file_index_result,
FileIndexEvaluator::Evaluate(data_schema,
file_index_options,
predicate,
data_file_path_factory, file,
@@ -580,7 +632,7 @@ Result<std::unique_ptr<FileBatchReader>>
DataEvolutionSplitRead::ApplyIndexAndDv
return std::unique_ptr<FileBatchReader>();
}
}
- const RoaringBitmap32* index_selection = nullptr;
+ const RoaringBitmap32* index_selection = group_selection_ ?
&*group_selection_ : nullptr;
if (auto* bitmap_index =
dynamic_cast<BitmapIndexResult*>(file_index_result.get())) {
PAIMON_ASSIGN_OR_RAISE(index_selection, bitmap_index->GetBitmap());
}
@@ -633,14 +685,14 @@ DataEvolutionSplitRead::SplitFieldBunches(
bool has_row_ranges_selection) {
std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>>
fields_files;
std::map<int32_t, std::shared_ptr<DataEvolutionSplitRead::BlobBunch>>
blob_bunch_map;
- int64_t row_count = -1;
+ std::optional<Range> expected_row_range;
for (const auto& file : need_merge_files) {
if (BlobUtils::IsBlobFile(file->file_name)) {
PAIMON_ASSIGN_OR_RAISE(int32_t field_id,
blob_field_to_field_id(file));
auto iter = blob_bunch_map.find(field_id);
if (iter == blob_bunch_map.end()) {
auto blob_bunch =
std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- row_count, has_row_ranges_selection);
+ expected_row_range, has_row_ranges_selection);
PAIMON_RETURN_NOT_OK(blob_bunch->Add(file));
blob_bunch_map.insert(std::make_pair(field_id, blob_bunch));
} else {
@@ -650,7 +702,9 @@ DataEvolutionSplitRead::SplitFieldBunches(
} else {
// Normal file, just add it to the current merge split
fields_files.push_back(std::make_shared<DataEvolutionSplitRead::DataBunch>(file));
- row_count = file->row_count;
+ if (DataEvolutionUtils::IsNormalFile(file->file_name)) {
+ PAIMON_ASSIGN_OR_RAISE(expected_row_range,
file->NonNullRowIdRange());
+ }
}
}
for (const auto& [field_id, blob_bunch] : blob_bunch_map) {
@@ -889,6 +943,8 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateUnionReader(
TryPlanSequentialVectorRead(need_merge_files,
file_to_field_ids));
auto split_read =
std::make_shared<DataEvolutionSplitRead>(path_factory_, context_,
pool_, executor_);
+ split_read->file_indexes_evaluated_ = file_indexes_evaluated_;
+ split_read->group_selection_ = group_selection_;
if (!sequential_bunches.empty()) {
std::vector<LazyConcatBatchReader::Source> sources;
sources.push_back(LazyConcatBatchReader::Source{
@@ -970,16 +1026,23 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateFieldUnionRea
const std::optional<GroupDeletionVector>& group_dv,
const std::shared_ptr<arrow::Schema>& read_schema) const {
assert(!fields_files.empty());
- int64_t row_count = fields_files[0]->RowCount();
- PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
fields_files[0]->Files()[0]->NonNullFirstRowId());
+ auto bunch_first_row_id = [](const std::shared_ptr<FieldBunch>& bunch) ->
Result<int64_t> {
+ if (auto blob_bunch = std::dynamic_pointer_cast<BlobBunch>(bunch)) {
+ PAIMON_ASSIGN_OR_RAISE(Range range, blob_bunch->LogicalRange());
+ return range.from;
+ }
+ return bunch->Files()[0]->NonNullFirstRowId();
+ };
+ PAIMON_ASSIGN_OR_RAISE(int64_t row_count, fields_files[0]->RowCount());
+ PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
bunch_first_row_id(fields_files[0]));
if (!row_ranges) {
for (const auto& bunch : fields_files) {
- if (bunch->RowCount() != row_count) {
+ PAIMON_ASSIGN_OR_RAISE(int64_t bunch_row_count, bunch->RowCount());
+ if (bunch_row_count != row_count) {
return Status::Invalid(
"All files in a field merge split should have the same row
count.");
}
- PAIMON_ASSIGN_OR_RAISE(int64_t current_bunch_first_row_id,
- bunch->Files()[0]->NonNullFirstRowId());
+ PAIMON_ASSIGN_OR_RAISE(int64_t current_bunch_first_row_id,
bunch_first_row_id(bunch));
if (current_bunch_first_row_id != first_row_id) {
return Status::Invalid(
"All files in a field merge split should have the same
first row id and could "
@@ -1048,6 +1111,8 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateFieldUnionRea
std::vector<LazyConcatBatchReader::Source> sources;
auto split_read =
std::make_shared<DataEvolutionSplitRead>(path_factory_, context_,
pool_, executor_);
+ split_read->file_indexes_evaluated_ = file_indexes_evaluated_;
+ split_read->group_selection_ = group_selection_;
for (const auto& file : bunch->Files()) {
std::optional<std::vector<Range>> selected = row_ranges;
if (row_ranges) {
@@ -1077,14 +1142,13 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateFieldUnionRea
}
file_batch_readers[file_idx] =
std::make_unique<LazyConcatBatchReader>(std::move(sources), arrow_pool_);
- } else if (blob_bunch && !blob_bunch->SequentialReadOptimize()) {
- // blob files span multiple max sequence number layers:
placeholder entries of
- // newer layers must fall back row by row to older layers
+ } else if (blob_bunch) {
+ PAIMON_ASSIGN_OR_RAISE(Range logical_range,
blob_bunch->LogicalRange());
PAIMON_ASSIGN_OR_RAISE(
file_batch_readers[file_idx],
- CreateBlobFallbackReader(partition, blob_bunch->Files(),
file_read_schema,
- row_ranges,
data_file_path_factory, group_dv_factory,
- group_dv));
+ CreateBlobFallbackReader(partition, blob_bunch->Files(),
logical_range,
+ file_read_schema, row_ranges,
data_file_path_factory,
+ group_dv_factory, group_dv));
} else {
PAIMON_ASSIGN_OR_RAISE(
std::vector<std::unique_ptr<FileBatchReader>> file_readers,
@@ -1097,7 +1161,6 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateFieldUnionRea
} else {
auto raw_readers =
ObjectUtils::MoveVector<std::unique_ptr<BatchReader>>(
std::move(file_readers));
- // Concat multiple blob files that map to the same data
file.
file_batch_readers[file_idx] =
std::make_unique<ConcatBatchReader>(std::move(raw_readers), arrow_pool_);
}
@@ -1148,20 +1211,28 @@ std::vector<Range> SelectedRangesInRange(int64_t from,
int64_t to,
Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobFallbackReader(
const BinaryRow& partition, const
std::vector<std::shared_ptr<DataFileMeta>>& files,
- const std::shared_ptr<arrow::Schema>& file_read_schema,
+ const Range& logical_range, const std::shared_ptr<arrow::Schema>&
file_read_schema,
const std::optional<std::vector<Range>>& row_ranges,
const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
const DeletionVector::Factory& group_dv_factory,
const std::optional<GroupDeletionVector>& group_dv) const {
- int64_t union_first_row_id = std::numeric_limits<int64_t>::max();
- int64_t union_last_row_id = std::numeric_limits<int64_t>::min();
+ if (files.empty()) {
+ return Status::Invalid("Blob bunch should not be empty.");
+ }
+ int64_t union_first_row_id = logical_range.from;
+ int64_t union_last_row_id = logical_range.to;
using FileWithFirstRowId = std::pair<int64_t,
std::shared_ptr<DataFileMeta>>;
std::map<int64_t, std::vector<FileWithFirstRowId>, std::greater<>>
sequence_groups;
for (const auto& file : files) {
- PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
file->NonNullFirstRowId());
- union_first_row_id = std::min(union_first_row_id, first_row_id);
- union_last_row_id = std::max(union_last_row_id, first_row_id +
file->row_count - 1);
- sequence_groups[file->max_sequence_number].emplace_back(first_row_id,
file);
+ PAIMON_ASSIGN_OR_RAISE(Range file_range, file->NonNullRowIdRange());
+ if (!row_ranges &&
+ (file_range.from < logical_range.from || file_range.to >
logical_range.to)) {
+ return Status::Invalid("Blob file range should be within logical
range.");
+ }
+ if (row_ranges && !Range::HasIntersection(file_range, logical_range)) {
+ return Status::Invalid("Blob file range should intersect logical
range.");
+ }
+
sequence_groups[file->max_sequence_number].emplace_back(file_range.from, file);
}
// the blob format reader must emit placeholder sentinels instead of
failing on them
const std::map<std::string, std::string> blob_format_options = {
@@ -1173,21 +1244,24 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobFallbackR
[](const FileWithFirstRowId& f1, const
FileWithFirstRowId& f2) {
return f1.first < f2.first;
});
- std::vector<BlobFallbackBatchReader::Segment> segments;
- // pad row ids this layer does not cover with placeholder gaps, so
that every layer spans
- // the same union range and the groups can be stepped in lockstep
- int64_t next_row_id = union_first_row_id;
+ int64_t previous_file_end = std::numeric_limits<int64_t>::min();
for (const auto& [first_row_id, file] : group_files) {
- if (first_row_id < next_row_id) {
+ if (first_row_id < previous_file_end) {
return Status::Invalid(fmt::format(
"Blob files with the same max sequence number should not
have overlapping "
"row id ranges: file {} (max sequence number {}) starts at
row id {} before "
"the previous file's end {}",
- file->file_name, max_sequence_number, first_row_id,
next_row_id));
+ file->file_name, max_sequence_number, first_row_id,
previous_file_end));
}
- if (first_row_id > next_row_id) {
- std::vector<Range> gap_selected_ranges =
- SelectedRangesInRange(next_row_id, first_row_id - 1,
row_ranges);
+ previous_file_end = first_row_id + file->row_count;
+ }
+ std::vector<BlobFallbackBatchReader::Segment> segments;
+ // Pad rows this layer does not cover, so that every layer spans the
logical range.
+ int64_t next_row_id = union_first_row_id;
+ for (const auto& [first_row_id, file] : group_files) {
+ if (first_row_id > next_row_id && next_row_id <=
union_last_row_id) {
+ std::vector<Range> gap_selected_ranges = SelectedRangesInRange(
+ next_row_id, std::min(first_row_id - 1,
union_last_row_id), row_ranges);
if (group_dv && !gap_selected_ranges.empty()) {
// gap segments have no file reader to wrap, so the
deleted row ids are
// dropped from their ranges to stay aligned with the
other layers
@@ -1200,12 +1274,15 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobFallbackR
BlobFallbackBatchReader::Segment{nullptr,
std::move(gap_selected_ranges)});
}
}
- if (!SelectedRangesInRange(first_row_id, first_row_id +
file->row_count - 1, row_ranges)
+ std::optional<std::vector<Range>> file_ranges =
+ SelectedRangesInRange(union_first_row_id, union_last_row_id,
row_ranges);
+ if (!SelectedRangesInRange(first_row_id, first_row_id +
file->row_count - 1,
+ file_ranges)
.empty()) {
PAIMON_ASSIGN_OR_RAISE(
std::vector<std::unique_ptr<FileBatchReader>> file_readers,
CreateRawFileReaders(partition, {file}, file_read_schema,
- /*predicate=*/nullptr,
group_dv_factory, row_ranges,
+ /*predicate=*/nullptr,
group_dv_factory, file_ranges,
data_file_path_factory,
blob_format_options));
if (file_readers.size() != 1) {
return Status::Invalid("Unexpected: blob fallback file
reader was skipped.");
@@ -1213,7 +1290,7 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobFallbackR
segments.push_back(
BlobFallbackBatchReader::Segment{std::move(file_readers[0]), {}});
}
- next_row_id = first_row_id + file->row_count;
+ next_row_id = std::max(next_row_id, first_row_id +
file->row_count);
}
if (next_row_id <= union_last_row_id) {
std::vector<Range> gap_selected_ranges =
@@ -1227,6 +1304,10 @@ Result<std::unique_ptr<BatchReader>>
DataEvolutionSplitRead::CreateBlobFallbackR
BlobFallbackBatchReader::Segment{nullptr,
std::move(gap_selected_ranges)});
}
}
+ if (segments.empty()) {
+ return
std::make_unique<ConcatBatchReader>(std::vector<std::unique_ptr<BatchReader>>(),
+ arrow_pool_);
+ }
groups.push_back(std::move(segments));
}
PAIMON_ASSIGN_OR_RAISE(
@@ -1334,14 +1415,6 @@ DataEvolutionSplitRead::ReadGroupDeletionVector(
if (!split_dv_factory) {
return std::optional<GroupDeletionVector>();
}
- // A split may pack a group holding nothing but blob files, written when
only the blob column
- // was, next to one that does carry a deletion vector. No anchor means no
writer could have
- // keyed a vector by it, so it reads undeleted instead of failing the
whole split.
- if (std::none_of(group.begin(), group.end(), [](const
std::shared_ptr<DataFileMeta>& file) {
- return DataEvolutionUtils::IsNormalFile(file->file_name);
- })) {
- return std::optional<GroupDeletionVector>();
- }
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DataFileMeta> anchor,
DataEvolutionUtils::RetrieveAnchorFile(group));
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DeletionVector> deletion_vector,
@@ -1356,7 +1429,8 @@ DataEvolutionSplitRead::ReadGroupDeletionVector(
Result<DeletionVector::Factory> DataEvolutionSplitRead::CreateGroupDvFactory(
const std::vector<std::shared_ptr<DataFileMeta>>& group,
- const std::optional<GroupDeletionVector>& group_dv) {
+ const std::optional<GroupDeletionVector>& group_dv,
+ const std::optional<std::vector<Range>>& row_ranges) {
if (!group_dv) {
return DeletionVector::Factory();
}
@@ -1365,11 +1439,18 @@ Result<DeletionVector::Factory>
DataEvolutionSplitRead::CreateGroupDvFactory(
for (const auto& file : group) {
PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
file->NonNullFirstRowId());
int64_t last_row_id = first_row_id + file->row_count - 1;
- if (first_row_id < group_dv->anchor_range.from || last_row_id >
group_dv->anchor_range.to) {
- return Status::Invalid(fmt::format(
- "Deletion vector range [{}, {}] should contain row id range
[{}, {}] of file {}.",
- group_dv->anchor_range.from, group_dv->anchor_range.to,
first_row_id, last_row_id,
- file->file_name));
+ const std::vector<Range> selected_ranges =
+ row_ranges ? Range::And({Range(first_row_id, last_row_id)},
+ Range::SortAndMergeOverlap(*row_ranges,
/*adjacent=*/true))
+ : std::vector<Range>{Range(first_row_id, last_row_id)};
+ for (const auto& range : selected_ranges) {
+ if (range.from < group_dv->anchor_range.from || range.to >
group_dv->anchor_range.to) {
+ return Status::Invalid(fmt::format(
+ "Deletion vector range [{}, {}] should contain selected
row id range [{}, {}] "
+ "of file {}.",
+ group_dv->anchor_range.from, group_dv->anchor_range.to,
range.from, range.to,
+ file->file_name));
+ }
}
int64_t offset = first_row_id - group_dv->anchor_range.from;
bool covers_whole_anchor_range = offset == 0 && last_row_id ==
group_dv->anchor_range.to;
@@ -1388,8 +1469,81 @@ Result<DeletionVector::Factory>
DataEvolutionSplitRead::CreateGroupDvFactory(
});
}
+Result<RoaringBitmap32> DataEvolutionSplitRead::ExcludeDeletedPositions(
+ RoaringBitmap32 selection, const std::shared_ptr<DeletionVector>&
deletion_vector) {
+ if (selection.IsEmpty() || deletion_vector->IsEmpty()) {
+ return selection;
+ }
+ if (auto* bitmap_dv =
dynamic_cast<BitmapDeletionVector*>(deletion_vector.get())) {
+ selection -= *bitmap_dv->GetBitmap();
+ return selection;
+ }
+ PAIMON_ASSIGN_OR_RAISE(int64_t cardinality,
deletion_vector->GetCardinality());
+ if (cardinality < selection.Cardinality()) {
+ RoaringBitmap32 deleted;
+
PAIMON_RETURN_NOT_OK(deletion_vector->ForEachDeletedPosition([&](int64_t
position) {
+ if (position >= 0 && position <=
std::numeric_limits<uint32_t>::max()) {
+ deleted.Add(static_cast<int32_t>(position));
+ }
+ }));
+ selection -= deleted;
+ return selection;
+ }
+ RoaringBitmap32 live;
+ const auto end = selection.End();
+ for (auto iter = selection.Begin(); iter != end; ++iter) {
+ PAIMON_ASSIGN_OR_RAISE(bool is_deleted,
+
deletion_vector->IsDeleted(static_cast<uint32_t>(*iter)));
+ if (!is_deleted) {
+ live.Add(*iter);
+ }
+ }
+ return live;
+}
+
Result<std::vector<Range>> DataEvolutionSplitRead::ExcludeDeletedRowIds(
const std::vector<Range>& ranges, const GroupDeletionVector& group_dv) {
+ if (ranges.empty()) {
+ return std::vector<Range>();
+ }
+ PAIMON_ASSIGN_OR_RAISE(int64_t cardinality,
group_dv.deletion_vector->GetCardinality());
+ int64_t selected_count = 0;
+ for (const auto& range : ranges) {
+ if (range.Count() > cardinality - selected_count) {
+ // Sparse deletions in a large selection: visit deleted positions
instead of every
+ // selected row. Coalesce runs before taking the complement to
keep memory bounded
+ // for contiguous deletions.
+ std::vector<Range> deleted;
+ PAIMON_RETURN_NOT_OK(
+ group_dv.deletion_vector->ForEachDeletedPosition([&](int64_t
position) {
+ if (position < 0 ||
+ position > group_dv.anchor_range.to -
group_dv.anchor_range.from) {
+ return;
+ }
+ int64_t row_id = group_dv.anchor_range.from + position;
+ // The caller supplies sorted, disjoint selected gap
ranges. Do not expand
+ // deletions elsewhere in the anchor into temporary ranges.
+ auto selected = std::lower_bound(
+ ranges.begin(), ranges.end(), row_id,
+ [](const Range& range, int64_t value) { return
range.to < value; });
+ if (selected == ranges.end() || selected->from > row_id) {
+ return;
+ }
+ if (!deleted.empty() && deleted.back().to < row_id &&
+ row_id - deleted.back().to == 1) {
+ deleted.back().to = row_id;
+ } else {
+ deleted.emplace_back(row_id, row_id);
+ }
+ }));
+ if (deleted.empty()) {
+ return ranges;
+ }
+ return Range::And(ranges,
+ Range(ranges.front().from,
ranges.back().to).Exclude(deleted));
+ }
+ selected_count += range.Count();
+ }
std::vector<Range> remaining;
for (const auto& range : ranges) {
std::optional<int64_t> run_start;
diff --git a/src/paimon/core/operation/data_evolution_split_read.h
b/src/paimon/core/operation/data_evolution_split_read.h
index e0406495..b7610dc1 100644
--- a/src/paimon/core/operation/data_evolution_split_read.h
+++ b/src/paimon/core/operation/data_evolution_split_read.h
@@ -20,7 +20,6 @@
#include <cstdint>
#include <functional>
-#include <limits>
#include <map>
#include <memory>
#include <string>
@@ -37,6 +36,7 @@
#include "paimon/reader/batch_reader.h"
#include "paimon/result.h"
#include "paimon/status.h"
+#include "paimon/utils/roaring_bitmap32.h"
namespace arrow {
class Schema;
@@ -62,7 +62,7 @@ struct DeletionFile;
/// splits)->(BlobViewResolvingBatchReader)->(CompleteIndexScoreBatchReader)->
/// CompleteRowKindBatchReader->(PredicateBatchReader)
/// ->ConcatBatchReader across files->DataEvolutionFileReader
-/// ->(ConcatBatchReader across blob files | BlobFallbackBatchReader across
blob sequence layers)
+/// ->(ConcatBatchReader across vector files | BlobFallbackBatchReader across
blob sequence layers)
///
->FieldMappingReader->(ApplyDeletionVectorBatchReader)->(ApplyBitmapIndexBatchReader)
/// ->(CompleteRowTrackingFieldsBatchReader)->(ShreddingFileReader)
///
->(VectorFileBatchReader)->(DelegatingPrefetchReader)->(PrefetchFileBatchReader)
@@ -70,9 +70,9 @@ struct DeletionFile;
///
///
/// A union `SplitRead` to read multiple inner files to merge columns. A
single-file row range
-/// group gets both file-index and format-level predicate pushdown. A merged
group only uses file
-/// indexes to skip the whole group: filtering its child readers independently
would break their
-/// positional alignment.
+/// group gets both file-index and format-level predicate pushdown. A merged
group of normal
+/// files sharing one row-id range shares the intersection of its file-index
selections across
+/// all child readers to preserve alignment.
///
/// Deletion vectors are supported: a row range group's vector is maintained
against the
/// group's anchor file (DataEvolutionUtils::RetrieveAnchorFile), so its
positions are
@@ -105,7 +105,7 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
class FieldBunch {
public:
virtual ~FieldBunch() = default;
- virtual int64_t RowCount() const = 0;
+ virtual Result<int64_t> RowCount() const = 0;
virtual const std::vector<std::shared_ptr<DataFileMeta>>& Files()
const = 0;
};
@@ -113,7 +113,7 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
public:
explicit DataBunch(const std::shared_ptr<DataFileMeta>& data_file)
: data_files_({data_file}) {}
- int64_t RowCount() const override {
+ Result<int64_t> RowCount() const override {
return data_files_[0]->row_count;
}
const std::vector<std::shared_ptr<DataFileMeta>>& Files() const
override {
@@ -129,7 +129,7 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
public:
VectorBunch(std::vector<std::shared_ptr<DataFileMeta>> files, int64_t
row_count)
: files_(std::move(files)), row_count_(row_count) {}
- int64_t RowCount() const override {
+ Result<int64_t> RowCount() const override {
return row_count_;
}
const std::vector<std::shared_ptr<DataFileMeta>>& Files() const
override {
@@ -145,38 +145,22 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
/// (aligned with Java's BlobFileBunch): files whose max sequence numbers
differ form layers
/// of a data-evolution partial update, where newer layers record
untouched rows as
/// placeholder entries and reading falls back row by row to older layers.
- /// Files must be added ordered by first row id ascending (then max
sequence number
- /// descending), as produced by MergeRangesAndSort.
class BlobBunch : public FieldBunch {
public:
- explicit BlobBunch(int64_t expected_row_count, bool
has_row_ids_selection)
- : expected_row_count_(expected_row_count),
+ BlobBunch(const std::optional<Range>& expected_row_range, bool
has_row_ids_selection)
+ : expected_row_range_(expected_row_range),
has_row_ids_selection_(has_row_ids_selection) {}
- /// Number of distinct row ids covered by the added files. Without a
row-ids selection
- /// the covered range is contiguous (enforced by Add) and matches the
data files.
- int64_t RowCount() const override;
+ /// The normal file owns the logical rows, including rows with no
physical blob value.
+ Result<int64_t> RowCount() const override;
+ Result<Range> LogicalRange() const;
const std::vector<std::shared_ptr<DataFileMeta>>& Files() const
override {
return files_;
}
Status Add(const std::shared_ptr<DataFileMeta>& file);
- /// True when every file shares one max sequence number, so the files
are read
- /// sequentially without the fallback merge. A lone layer is expected
to hold no
- /// placeholder entries; if one does (a user value equal to the
placeholder sentinel
- /// written by a first write), the strict blob reader rejects the
read, since no older
- /// layer exists to resolve it.
- bool SequentialReadOptimize() const {
- return sequence_group_end_.size() <= 1;
- }
private:
- int64_t expected_row_count_ = -1;
+ std::optional<Range> expected_row_range_;
bool has_row_ids_selection_ = false;
- int64_t union_first_row_id_ = std::numeric_limits<int64_t>::max();
- /// Exclusive end of the union row id range covered so far.
- int64_t union_end_row_id_ = std::numeric_limits<int64_t>::min();
- /// Per max sequence number: exclusive end of the last added range, to
reject
- /// overlapping files within one layer.
- std::map<int64_t, int64_t> sequence_group_end_;
std::vector<Range> ranges_;
std::vector<std::shared_ptr<DataFileMeta>> files_;
};
@@ -201,12 +185,15 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
const std::shared_ptr<Predicate>& predicate,
const std::shared_ptr<arrow::Schema>& read_schema);
- /// Returns true when file indexes prove that no row in a merged row range
group can match.
- /// Only normal files are considered, and an older copy of a field is
excluded after a newer
- /// file has claimed the same field id.
- Result<bool> SkipByFileIndex(
+ /// Evaluates the winning files' indexes before opening the group's DV or
data files. Shared
+ /// bitmap selection is restricted to normal files with the same row-id
range; a single-file
+ /// group may always use its bitmap. The bitmap uses file-local positions
and is intersected
+ /// with `row_ranges`. Nullopt means no bitmap restriction; an empty
bitmap rejects the group.
+ Result<std::optional<RoaringBitmap32>> SelectByFileIndex(
const std::shared_ptr<Predicate>& predicate,
const std::vector<std::shared_ptr<DataFileMeta>>& files,
+ const std::shared_ptr<arrow::Schema>& read_schema,
+ const std::optional<std::vector<Range>>& row_ranges,
const std::shared_ptr<DataFilePathFactory>& data_file_path_factory)
const;
/// Builds the deletion vector factory over the split's deletion files,
keyed by data file
@@ -225,11 +212,16 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
/// the anchor range and limited to its row count. Null factory when the
group has no vector.
static Result<DeletionVector::Factory> CreateGroupDvFactory(
const std::vector<std::shared_ptr<DataFileMeta>>& group,
- const std::optional<GroupDeletionVector>& group_dv);
+ const std::optional<GroupDeletionVector>& group_dv,
+ const std::optional<std::vector<Range>>& row_ranges);
+
+ /// Removes deleted positions without expanding the selection into row-id
ranges. Both the
+ /// selection and the deletion vector must use the same file-local
positions.
+ static Result<RoaringBitmap32> ExcludeDeletedPositions(
+ RoaringBitmap32 selection, const std::shared_ptr<DeletionVector>&
deletion_vector);
/// Removes the row ids deleted by `group_dv` from the sorted disjoint
`ranges` (absolute
- /// row ids). Probes the vector once per row id, since it exposes no range
API; the cost is
- /// paid once per gap segment while building the reader, not per batch.
+ /// row ids). Iterates over deletions when they are sparser than the
selected rows.
static Result<std::vector<Range>> ExcludeDeletedRowIds(const
std::vector<Range>& ranges,
const
GroupDeletionVector& group_dv);
@@ -318,11 +310,19 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
/// emitting the same surviving rows.
Result<std::unique_ptr<BatchReader>> CreateBlobFallbackReader(
const BinaryRow& partition, const
std::vector<std::shared_ptr<DataFileMeta>>& files,
- const std::shared_ptr<arrow::Schema>& file_read_schema,
+ const Range& logical_range, const std::shared_ptr<arrow::Schema>&
file_read_schema,
const std::optional<std::vector<Range>>& row_ranges,
const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
const DeletionVector::Factory& group_dv_factory,
const std::optional<GroupDeletionVector>& group_dv) const;
+
+ /// Set only on a per-group reader after SelectByFileIndex has evaluated
its indexes.
+ /// Lazy child readers inherit it so no sidecar is reopened during reader
construction.
+ bool file_indexes_evaluated_ = false;
+
+ /// Shared file-local selection on a per-group reader. Present only for a
single-file group
+ /// or normal files with identical row-id ranges; every column reader
consumes the same bitmap.
+ std::optional<RoaringBitmap32> group_selection_;
};
} // namespace paimon
diff --git a/src/paimon/core/operation/data_evolution_split_read_test.cpp
b/src/paimon/core/operation/data_evolution_split_read_test.cpp
index 4bd0e0c2..a68dfdf8 100644
--- a/src/paimon/core/operation/data_evolution_split_read_test.cpp
+++ b/src/paimon/core/operation/data_evolution_split_read_test.cpp
@@ -30,11 +30,14 @@
#include "arrow/ipc/json_simple.h"
#include "gtest/gtest.h"
#include "paimon/common/data/binary_row.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/common/file_index/bitmap/bitmap_file_index.h"
#include "paimon/common/table/special_fields.h"
#include "paimon/common/utils/arrow/status_utils.h"
#include "paimon/common/utils/checked_cast.h"
#include "paimon/common/utils/path_util.h"
#include "paimon/common/utils/vector_store_utils.h"
+#include "paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h"
#include "paimon/core/deletionvectors/bitmap64_deletion_vector.h"
#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
#include "paimon/core/io/data_file_meta.h"
@@ -46,6 +49,7 @@
#include "paimon/core/table/source/data_split_impl.h"
#include "paimon/defs.h"
#include "paimon/executor.h"
+#include "paimon/file_index/file_index_writer.h"
#include "paimon/format/file_format_factory.h"
#include "paimon/format/format_writer.h"
#include "paimon/format/writer_builder.h"
@@ -55,6 +59,8 @@
#include "paimon/predicate/predicate_builder.h"
#include "paimon/read_context.h"
#include "paimon/status.h"
+#include "paimon/testing/mock/mock_file_batch_reader.h"
+#include "paimon/testing/mock/mock_search_file_index.h"
#include "paimon/testing/utils/read_result_collector.h"
#include "paimon/testing/utils/testharness.h"
@@ -206,12 +212,13 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddSingleBlobEntry) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
ASSERT_EQ(blob_bunch->Files().size(), 1);
ASSERT_EQ(blob_bunch->Files()[0], blob_entry);
- ASSERT_EQ(blob_bunch->RowCount(), 100);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 100);
}
TEST_F(DataEvolutionSplitReadTest, TestAddBlobEntryAndTail) {
@@ -224,21 +231,22 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobEntryAndTail) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
ASSERT_OK(blob_bunch->Add(blob_tail));
ASSERT_EQ(blob_bunch->Files().size(), 2);
ASSERT_EQ(blob_bunch->Files()[0], blob_entry);
ASSERT_EQ(blob_bunch->Files()[1], blob_tail);
- ASSERT_EQ(blob_bunch->RowCount(), 300);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 300);
}
TEST_F(DataEvolutionSplitReadTest, TestAddNonBlobFileInvalid) {
auto blob_entry =
CreateDataFileMeta("normal1.parquet", /*first_row_id=*/0,
FileSource::Append());
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_NOK_WITH_MSG(blob_bunch->Add(blob_entry),
"Only blob file can be added to a blob bunch.");
}
@@ -257,7 +265,7 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobWithSameFirstRowId) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
// Files with the same first row id and lower sequence numbers are older
layers of a
// partial update; they are kept for the row-level placeholder fallback,
whether they
@@ -269,8 +277,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobWithSameFirstRowId) {
ASSERT_EQ(blob_bunch->Files()[0], blob_entry);
ASSERT_EQ(blob_bunch->Files()[1], blob_full_tail);
ASSERT_EQ(blob_bunch->Files()[2], blob_short_tail);
- ASSERT_EQ(blob_bunch->RowCount(), 100);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 100);
}
TEST_F(DataEvolutionSplitReadTest, TestAddBlobFileWithOverlappingRowId) {
@@ -283,14 +291,14 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithOverlappingRowId) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
// Overlapping layers with different sequence numbers are kept for the
fallback.
ASSERT_OK(blob_bunch->Add(blob_tail));
ASSERT_EQ(blob_bunch->Files().size(), 2);
- ASSERT_EQ(blob_bunch->RowCount(), 200);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 200);
}
TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithOverlappingRowIdAndHigherSequenceNumber) {
@@ -303,13 +311,13 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithOverlappingRowIdAndHigherS
/*max_sequence_number=*/2,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
ASSERT_OK(blob_bunch->Add(blob_tail));
ASSERT_EQ(blob_bunch->Files().size(), 2);
- ASSERT_EQ(blob_bunch->RowCount(), 200);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 200);
}
TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithOverlappingRowIdInSameLayer) {
@@ -322,12 +330,12 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithOverlappingRowIdInSameLaye
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
- // Files sharing a max sequence number form one layer and must not overlap.
- ASSERT_NOK_WITH_MSG(blob_bunch->Add(blob_tail),
- "Blob files with the same max sequence number should
not have overlapping "
- "row id ranges");
+ // The bunch collects ranges; the fallback reader validates overlaps
within each layer.
+ ASSERT_OK(blob_bunch->Add(blob_tail));
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 200);
}
TEST_F(DataEvolutionSplitReadTest, TestAddBlobFileWithNonContinuousRowId) {
@@ -340,11 +348,11 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithNonContinuousRowId) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
- // Adding file with non-continuous row id should return bad status
- ASSERT_NOK_WITH_MSG(blob_bunch->Add(blob_tail),
- "Blob file first row id should be continuous, expect
100 but got 200");
+ ASSERT_OK(blob_bunch->Add(blob_tail));
+ ASSERT_NOK_WITH_MSG(blob_bunch->RowCount(),
+ "Blob file bunch should always contain a contiguous
row range.");
}
TEST_F(DataEvolutionSplitReadTest, TestAddBlobFileWithDifferentWriteCols) {
@@ -357,13 +365,79 @@ TEST_F(DataEvolutionSplitReadTest,
TestAddBlobFileWithDifferentWriteCols) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"diff_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry));
// Adding file with different write columns should return bad status
ASSERT_NOK_WITH_MSG(blob_bunch->Add(blob_tail),
"All files in a blob bunch should have the same write
columns.");
}
+TEST_F(DataEvolutionSplitReadTest,
TestBlobBunchChecksContinuityAfterCollectingAllFiles) {
+ DataEvolutionSplitRead::BlobBunch bunch(std::nullopt,
/*has_row_ids_selection=*/false);
+ for (int64_t first_row_id : {0, 20, 10}) {
+ ASSERT_OK(bunch.Add(CreateBlobFile(std::to_string(first_row_id),
first_row_id, 10, 1,
+
std::optional<std::vector<std::string>>({"blob_col"}))));
+ }
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, bunch.RowCount());
+ ASSERT_EQ(row_count, 30);
+ ASSERT_OK_AND_ASSIGN(Range range, bunch.LogicalRange());
+ ASSERT_EQ(range, Range(0, 29));
+}
+
+TEST_F(DataEvolutionSplitReadTest,
TestBlobBunchRowCountValidatesExpectedRange) {
+ for (bool has_row_ids_selection : {false, true}) {
+ DataEvolutionSplitRead::BlobBunch bunch(Range(100, 109),
has_row_ids_selection);
+ ASSERT_OK(bunch.Add(CreateBlobFile("blob", 99, 4, 1,
+
std::optional<std::vector<std::string>>({"blob_col"}))));
+ ASSERT_NOK_WITH_MSG(bunch.RowCount(),
+ "Blob file range should be within normal file
range.");
+ ASSERT_OK_AND_ASSIGN(Range range, bunch.LogicalRange());
+ ASSERT_EQ(range, Range(100, 109));
+ }
+}
+
+TEST_F(DataEvolutionSplitReadTest, TestEmptyBlobBunchLogicalRange) {
+ DataEvolutionSplitRead::BlobBunch bunch(std::nullopt,
/*has_row_ids_selection=*/true);
+ ASSERT_NOK_WITH_MSG(bunch.LogicalRange(), "Blob file bunch should not be
empty.");
+}
+
+TEST_F(DataEvolutionSplitReadTest,
TestBlobFallbackValidatesRangesBeforeOpeningFiles) {
+ auto dir = UniqueTestDirectory::Create("local");
+ auto schema = DataField::ConvertDataFieldsToArrowSchema(
+ {DataField(0, arrow::field("id", arrow::int32()))});
+ const std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
"parquet"},
+
{Options::ROW_TRACKING_ENABLED, "true"},
+
{Options::DATA_EVOLUTION_ENABLED, "true"}};
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+ TableSchema::Create(0, schema, {}, {}, options));
+ ReadContextBuilder builder(dir->Str());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReadContext> read_context,
builder.Finish());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InternalReadContext> context,
+ InternalReadContext::Create(read_context,
table_schema, options));
+ DataEvolutionSplitRead split_read(/*path_factory=*/nullptr, context,
GetDefaultPool(),
+ /*executor=*/nullptr);
+ auto blob =
+ CreateBlobFile("blob", 100, 10, 1,
std::optional<std::vector<std::string>>({"blob_col"}));
+ auto overlap =
+ CreateBlobFile("overlap", 105, 5, 1,
std::optional<std::vector<std::string>>({"blob_col"}));
+ auto create_reader = [&](const std::vector<std::shared_ptr<DataFileMeta>>&
files,
+ const Range& range,
+ const std::optional<std::vector<Range>>&
row_ranges) {
+ return split_read.CreateBlobFallbackReader(
+ BinaryRow::EmptyRow(), files, range, schema, row_ranges,
+ /*data_file_path_factory=*/nullptr, /*group_dv_factory=*/nullptr,
+ /*group_dv=*/std::nullopt);
+ };
+ ASSERT_NOK_WITH_MSG(create_reader({}, Range(100, 109), std::nullopt),
+ "Blob bunch should not be empty.");
+ ASSERT_NOK_WITH_MSG(create_reader({blob}, Range(101, 109), std::nullopt),
+ "Blob file range should be within logical range.");
+ ASSERT_NOK_WITH_MSG(create_reader({blob}, Range(200, 209),
std::vector<Range>{Range(200, 200)}),
+ "Blob file range should intersect logical range.");
+ ASSERT_NOK_WITH_MSG(create_reader({overlap, blob}, Range(100, 109),
std::nullopt),
+ "should not have overlapping row id ranges");
+}
+
TEST_F(DataEvolutionSplitReadTest, TestRowIdSelectionWithOverlap) {
auto blob_entry =
CreateBlobFile("blob1", /*first_row_id=*/0, /*row_count=*/10,
@@ -380,7 +454,7 @@ TEST_F(DataEvolutionSplitReadTest,
TestRowIdSelectionWithOverlap) {
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/true);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/true);
ASSERT_OK(blob_bunch->Add(blob_entry));
// blob_sub1 was pruned by the row-ids selection in the scan process;
blob_sub2 is a newer
// layer and both files are kept for the row-level placeholder fallback.
@@ -388,8 +462,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestRowIdSelectionWithOverlap) {
ASSERT_EQ(blob_bunch->Files().size(), 2);
ASSERT_EQ(blob_bunch->Files()[0], blob_entry);
ASSERT_EQ(blob_bunch->Files()[1], blob_sub2);
- ASSERT_EQ(blob_bunch->RowCount(), 10);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 10);
}
TEST_F(DataEvolutionSplitReadTest, TestRowIdSelectionWithOverlap2) {
@@ -408,7 +482,7 @@ TEST_F(DataEvolutionSplitReadTest,
TestRowIdSelectionWithOverlap2) {
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/true);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/true);
ASSERT_OK(blob_bunch->Add(blob_entry));
// blob_sub1 was pruned by the row-ids selection in the scan process;
blob_sub2 is an older
// layer and both files are kept for the row-level placeholder fallback.
@@ -416,8 +490,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestRowIdSelectionWithOverlap2) {
ASSERT_EQ(blob_bunch->Files().size(), 2);
ASSERT_EQ(blob_bunch->Files()[0], blob_entry);
ASSERT_EQ(blob_bunch->Files()[1], blob_sub2);
- ASSERT_EQ(blob_bunch->RowCount(), 10);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 10);
}
TEST_F(DataEvolutionSplitReadTest, TestRowIdSelection) {
@@ -436,14 +510,15 @@ TEST_F(DataEvolutionSplitReadTest, TestRowIdSelection) {
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/true);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/true);
ASSERT_OK(blob_bunch->Add(blob_entry0));
// blob_entry1 will not be added, for it has been skipped by row_ids in
scan process.
ASSERT_OK(blob_bunch->Add(blob_entry2));
ASSERT_EQ(blob_bunch->Files().size(), 2);
ASSERT_EQ(blob_bunch->Files()[0], blob_entry0);
ASSERT_EQ(blob_bunch->Files()[1], blob_entry2);
- ASSERT_EQ(blob_bunch->RowCount(), 20);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 20);
}
TEST_F(DataEvolutionSplitReadTest, TestComplexBlobBunchScenario) {
@@ -464,7 +539,7 @@ TEST_F(DataEvolutionSplitReadTest,
TestComplexBlobBunchScenario) {
/*max_sequence_number=*/1,
/*write_cols=*/std::optional<std::vector<std::string>>({"blob_col"}));
auto blob_bunch = std::make_shared<DataEvolutionSplitRead::BlobBunch>(
- INT64_MAX, /*has_row_ids_selection=*/false);
+ /*expected_row_range=*/std::nullopt, /*has_row_ids_selection=*/false);
ASSERT_OK(blob_bunch->Add(blob_entry1));
ASSERT_OK(blob_bunch->Add(blob_entry2));
ASSERT_OK(blob_bunch->Add(blob_entry3));
@@ -475,7 +550,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestComplexBlobBunchScenario) {
ASSERT_EQ(blob_bunch->Files()[1], blob_entry2);
ASSERT_EQ(blob_bunch->Files()[2], blob_entry3);
ASSERT_EQ(blob_bunch->Files()[3], blob_entry4);
- ASSERT_EQ(blob_bunch->RowCount(), 1000);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 1000);
}
TEST_F(DataEvolutionSplitReadTest, TestComplexBlobBunchScenario2) {
@@ -571,8 +647,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestComplexBlobBunchScenario2) {
ASSERT_EQ(blob_bunch->Files()[6], blob_entry7);
ASSERT_EQ(blob_bunch->Files()[7], blob_entry4);
ASSERT_EQ(blob_bunch->Files()[8], blob_entry8);
- ASSERT_EQ(blob_bunch->RowCount(), 1000);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 1000);
}
TEST_F(DataEvolutionSplitReadTest, TestComplexBlobBunchScenario3) {
@@ -699,8 +775,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestComplexBlobBunchScenario3) {
ASSERT_EQ(blob_bunch->Files()[6], blob_entry7);
ASSERT_EQ(blob_bunch->Files()[7], blob_entry4);
ASSERT_EQ(blob_bunch->Files()[8], blob_entry8);
- ASSERT_EQ(blob_bunch->RowCount(), 1000);
- ASSERT_FALSE(blob_bunch->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, blob_bunch->RowCount());
+ ASSERT_EQ(row_count, 1000);
auto blob_bunch2 =
std::dynamic_pointer_cast<DataEvolutionSplitRead::BlobBunch>(bunch[2]);
ASSERT_EQ(blob_bunch2->Files().size(), 9);
@@ -713,8 +789,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestComplexBlobBunchScenario3) {
ASSERT_EQ(blob_bunch2->Files()[6], blob_entry17);
ASSERT_EQ(blob_bunch2->Files()[7], blob_entry14);
ASSERT_EQ(blob_bunch2->Files()[8], blob_entry18);
- ASSERT_EQ(blob_bunch2->RowCount(), 1000);
- ASSERT_FALSE(blob_bunch2->SequentialReadOptimize());
+ ASSERT_OK_AND_ASSIGN(int64_t row_count2, blob_bunch2->RowCount());
+ ASSERT_EQ(row_count2, 1000);
}
TEST_F(DataEvolutionSplitReadTest, TestDifferentRowIdRange) {
@@ -804,8 +880,10 @@ TEST_F(DataEvolutionSplitReadTest,
TestPlanSequentialVectorRead) {
ASSERT_EQ(bunches[0]->Files(),
std::vector<std::shared_ptr<DataFileMeta>>{main});
ASSERT_EQ(bunches[1]->Files(),
(std::vector<std::shared_ptr<DataFileMeta>>{vector0, vector1,
vector2}));
- ASSERT_EQ(bunches[0]->RowCount(), 10);
- ASSERT_EQ(bunches[1]->RowCount(), 10);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, bunches[0]->RowCount());
+ ASSERT_EQ(row_count, 10);
+ ASSERT_OK_AND_ASSIGN(int64_t row_count2, bunches[1]->RowCount());
+ ASSERT_EQ(row_count2, 10);
ASSERT_EQ(vector1->first_row_id, 103);
ASSERT_EQ(vector1->row_count, 4);
}
@@ -1167,6 +1245,116 @@ TEST_F(DataEvolutionSplitReadTest,
TestSplitWithMultipleBlobFilesPerGroup) {
ASSERT_EQ(expected_metas, split_metas);
}
+TEST_F(DataEvolutionSplitReadTest,
TestFileIndexSelectionOnlyPushesDownNormalGroups) {
+ auto dir = UniqueTestDirectory::Create("local");
+ auto pool = GetDefaultPool();
+ auto schema = DataField::ConvertDataFieldsToArrowSchema(
+ {DataField(0, arrow::field("f0", arrow::int32())),
+ DataField(1, BlobUtils::ToArrowField("b0")),
+ DataField(2, arrow::field("embedding",
arrow::list(arrow::float32())))});
+ const std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
"parquet"},
+
{Options::ROW_TRACKING_ENABLED, "true"},
+
{Options::DATA_EVOLUTION_ENABLED, "true"},
+
{Options::FILE_INDEX_READ_ENABLED, "true"}};
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+ TableSchema::Create(0, schema, {}, {}, options));
+ ReadContextBuilder builder(dir->Str());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReadContext> read_context,
builder.Finish());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InternalReadContext> context,
+ InternalReadContext::Create(read_context,
table_schema, options));
+ DataEvolutionSplitRead split_read(/*path_factory=*/nullptr, context, pool,
+ /*executor=*/nullptr);
+
+ // Use a real embedded bitmap index without creating any data files.
+ auto index_schema = arrow::schema({schema->field(0)});
+ auto data =
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(index_schema->fields()),
+
R"([[0],[1],[2],[3]])")
+ .ValueOrDie();
+ ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*index_schema, &c_schema).ok());
+ BitmapFileIndex index({});
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileIndexWriter> writer,
+ index.CreateWriter(&c_schema, pool));
+ ArrowArray c_array;
+ ASSERT_TRUE(arrow::ExportArray(*data, &c_array).ok());
+ ASSERT_OK(writer->AddBatch(&c_array));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Bytes> payload,
writer->SerializedBytes());
+ FileIndexFormat::ColumnIndexes indexes;
+ indexes["f0"]["bitmap"] = payload;
+ auto normal = CreateNormalFile("normal.parquet", 100, 4, 20);
+ normal->write_cols = std::vector<std::string>{"f0"};
+ ASSERT_OK_AND_ASSIGN(normal->embedded_index,
MockSearchFileIndex::Serialize(indexes, pool));
+
+ auto blob = CreateBlobFile("partial", 101, 2, 30,
std::vector<std::string>{"b0"});
+ auto vector = CreateNormalFile("partial.vector.parquet", 101, 2, 30);
+ vector->write_cols = std::vector<std::string>{"embedding"};
+ auto same_range = CreateNormalFile("same.parquet", 100, 4, 10);
+ same_range->write_cols = std::vector<std::string>{"b0"};
+ auto different_range = CreateNormalFile("different.parquet", 101, 2, 10);
+ different_range->write_cols = std::vector<std::string>{"b0"};
+ auto match = PredicateBuilder::Equal(0, "f0", FieldType::INT, Literal(1));
+ auto miss = PredicateBuilder::Equal(0, "f0", FieldType::INT, Literal(9));
+ auto last_match = PredicateBuilder::Equal(0, "f0", FieldType::INT,
Literal(3));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> sparse_match,
+ PredicateBuilder::Or({match, last_match}));
+
+ for (const auto& other : {blob, vector, different_range}) {
+ ASSERT_OK_AND_ASSIGN(
+ std::optional<RoaringBitmap32> selection,
+ split_read.SelectByFileIndex(match, {normal, other}, schema,
std::nullopt,
+ /*data_file_path_factory=*/nullptr));
+ ASSERT_FALSE(selection.has_value());
+
+ std::vector<Range> row_ranges = {Range(100, 102)};
+ ASSERT_OK_AND_ASSIGN(
+ selection, split_read.SelectByFileIndex(match, {normal, other},
schema, row_ranges,
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_FALSE(selection.has_value());
+
+ // The normal file can still reject the entire group when its index
has no matches.
+ ASSERT_OK_AND_ASSIGN(
+ selection, split_read.SelectByFileIndex(miss, {normal, other},
schema, std::nullopt,
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_TRUE(selection.has_value());
+ ASSERT_TRUE(selection->IsEmpty());
+ }
+ for (const auto& files :
{std::vector<std::shared_ptr<DataFileMeta>>{normal},
+
std::vector<std::shared_ptr<DataFileMeta>>{normal, same_range}}) {
+ ASSERT_OK_AND_ASSIGN(std::optional<RoaringBitmap32> selection,
+ split_read.SelectByFileIndex(match, files,
schema, std::nullopt,
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_EQ(selection,
std::optional<RoaringBitmap32>(RoaringBitmap32::From({1})));
+
+ // Row ranges use absolute IDs; the returned bitmap keeps file-local
positions.
+ const std::vector<std::pair<std::vector<Range>, std::vector<int32_t>>>
cases = {
+ {{Range(101, 101)}, {1}},
+ {{Range(103, 103)}, {3}},
+ {{Range(100, 100)}, {}},
+ {{Range(102, 102)}, {}},
+ {{Range(104, 105)}, {}},
+ {{}, {}},
+ {{Range(103, 103), Range(100, 101), Range(101, 102)}, {1, 3}},
+ {{Range(103, 110), Range(99, 101)}, {1, 3}}};
+ for (const auto& [row_ranges, expected] : cases) {
+ ASSERT_OK_AND_ASSIGN(
+ selection, split_read.SelectByFileIndex(sparse_match, files,
schema, row_ranges,
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_EQ(selection,
std::optional<RoaringBitmap32>(RoaringBitmap32::From(expected)));
+ }
+ ASSERT_OK_AND_ASSIGN(selection,
+
split_read.SelectByFileIndex(/*predicate=*/nullptr, files, schema,
+
std::vector<Range>{Range(100, 102)},
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_FALSE(selection.has_value());
+ ASSERT_OK_AND_ASSIGN(selection,
+
split_read.SelectByFileIndex(/*predicate=*/nullptr, files, schema,
+
std::vector<Range>{Range(200, 201)},
+
/*data_file_path_factory=*/nullptr));
+ ASSERT_TRUE(selection.has_value());
+ ASSERT_TRUE(selection->IsEmpty());
+ }
+}
+
namespace {
std::shared_ptr<DeletionVector> MakeBitmapDv(const std::vector<int64_t>&
deleted_positions) {
RoaringBitmap32 bitmap;
@@ -1188,6 +1376,62 @@ DeletionVector::Factory MakeSingleFileDvFactory(const
std::string& expected_file
}
} // namespace
+TEST_F(DataEvolutionSplitReadTest,
TestExcludeDeletedPositionsKeepsBitmapSelection) {
+ const std::vector<std::pair<std::vector<int32_t>, std::vector<int32_t>>>
cases = {
+ {{0, 1, 2, 3, 4, 5, 6, 7}, {0, 2, 4, 5, 6, 7}}, {{1, 3}, {}}, {{0, 2},
{0, 2}}, {{}, {}}};
+ for (bool bitmap64 : {false, true}) {
+ std::shared_ptr<DeletionVector> dv =
+ bitmap64 ?
std::shared_ptr<DeletionVector>(std::make_shared<Bitmap64DeletionVector>())
+ : MakeBitmapDv({});
+ ASSERT_OK(dv->Delete(1));
+ ASSERT_OK(dv->Delete(3));
+ if (bitmap64) {
+ ASSERT_OK(dv->Delete(1LL << 32));
+ }
+ for (const auto& [selected, expected] : cases) {
+ ASSERT_OK_AND_ASSIGN(RoaringBitmap32 live,
+
DataEvolutionSplitRead::ExcludeDeletedPositions(
+ RoaringBitmap32::From(selected), dv));
+ ASSERT_EQ(live, RoaringBitmap32::From(expected));
+ }
+ }
+}
+
+TEST_F(DataEvolutionSplitReadTest,
TestGroupBitmapCombinesWithRowRangesAndDvInFileReader) {
+ auto dir = UniqueTestDirectory::Create("local");
+ auto schema = DataField::ConvertDataFieldsToArrowSchema(
+ {DataField(0, arrow::field("f0", arrow::int32()))});
+ const std::map<std::string, std::string> options = {{Options::FILE_FORMAT,
"parquet"},
+
{Options::ROW_TRACKING_ENABLED, "true"},
+
{Options::DATA_EVOLUTION_ENABLED, "true"}};
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+ TableSchema::Create(0, schema, {}, {}, options));
+ ReadContextBuilder builder(dir->Str());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReadContext> read_context,
builder.Finish());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InternalReadContext> context,
+ InternalReadContext::Create(read_context,
table_schema, options));
+ DataEvolutionSplitRead split_read(/*path_factory=*/nullptr, context,
GetDefaultPool(),
+ /*executor=*/nullptr);
+ split_read.file_indexes_evaluated_ = true;
+ split_read.group_selection_ = RoaringBitmap32::From({0, 1, 3});
+ auto file = CreateNormalFile("normal.parquet", 100, 4, 20);
+ auto type = arrow::struct_(schema->fields());
+ auto data =
+ arrow::ipc::internal::json::ArrayFromJSON(type,
R"([[0],[1],[2],[3]])").ValueOrDie();
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<FileBatchReader> reader,
+ split_read.ApplyIndexAndDvReaderIfNeeded(
+ std::make_unique<MockFileBatchReader>(data, type, 2), file,
schema, schema,
+ /*predicate=*/nullptr, MakeSingleFileDvFactory(file->file_name,
MakeBitmapDv({1})),
+ std::vector<Range>{Range(101, 103)},
/*data_file_path_factory=*/nullptr,
+ /*file_index_options=*/{}, /*index_scores=*/nullptr));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result,
+
ReadResultCollector::CollectResult(std::move(reader)));
+ auto expected = arrow::ipc::internal::json::ArrayFromJSON(type,
R"([[3]])").ValueOrDie();
+ ASSERT_TRUE(result);
+ ASSERT_TRUE(result->Equals(arrow::ChunkedArray(expected))) <<
result->ToString();
+}
+
TEST_F(DataEvolutionSplitReadTest, TestReadGroupDeletionVector) {
// the anchor is the oldest normal file; blob files never anchor the
deletion vector
std::vector<std::shared_ptr<DataFileMeta>> group = {
@@ -1221,17 +1465,17 @@ TEST_F(DataEvolutionSplitReadTest,
TestReadGroupDeletionVector) {
group, DeletionVector::Factory()));
ASSERT_FALSE(group_dv.has_value());
- // a group of nothing but dedicated storage files has no anchor, so no
deletion vector can be
- // keyed by it. The split's factory is non-null because another group of
the same split
- // carries one, which must not fail this group's read.
+ // Match Java: a split carrying deletion files requires a normal anchor in
every read group.
std::vector<std::shared_ptr<DataFileMeta>> anchorless_group = {
CreateDataFileMeta("blob1.blob", /*first_row_id=*/100,
/*row_count=*/100, /*max_seq=*/5),
CreateDataFileMeta("blob2.blob", /*first_row_id=*/100,
/*row_count=*/100, /*max_seq=*/6),
CreateDataFileMeta("vector-store.vector.parquet",
/*first_row_id=*/100, /*row_count=*/100,
/*max_seq=*/7)};
- ASSERT_OK_AND_ASSIGN(group_dv,
- DataEvolutionSplitRead::ReadGroupDeletionVector(
- anchorless_group,
MakeSingleFileDvFactory("anchor.parquet", dv)));
+ ASSERT_NOK_WITH_MSG(DataEvolutionSplitRead::ReadGroupDeletionVector(
+ anchorless_group,
MakeSingleFileDvFactory("anchor.parquet", dv)),
+ "normal anchor file");
+ ASSERT_OK_AND_ASSIGN(group_dv,
DataEvolutionSplitRead::ReadGroupDeletionVector(
+ anchorless_group,
DeletionVector::Factory()));
ASSERT_FALSE(group_dv.has_value());
}
@@ -1245,8 +1489,9 @@ TEST_F(DataEvolutionSplitReadTest,
TestCreateGroupDvFactoryShiftsPositions) {
// deletion vector positions are anchor-relative: positions {10, 45} are
row ids {110, 145}
DataEvolutionSplitRead::GroupDeletionVector group_dv{MakeBitmapDv({10,
45}), Range(100, 199)};
- ASSERT_OK_AND_ASSIGN(DeletionVector::Factory factory,
- DataEvolutionSplitRead::CreateGroupDvFactory(group,
group_dv));
+ ASSERT_OK_AND_ASSIGN(
+ DeletionVector::Factory factory,
+ DataEvolutionSplitRead::CreateGroupDvFactory(group, group_dv,
/*row_ranges=*/std::nullopt));
ASSERT_TRUE(factory);
// the anchor file reads the deletion vector unshifted
@@ -1285,7 +1530,8 @@ TEST_F(DataEvolutionSplitReadTest,
TestCreateGroupDvFactoryShiftsPositions) {
ASSERT_FALSE(unknown_dv);
ASSERT_OK_AND_ASSIGN(DeletionVector::Factory null_factory,
- DataEvolutionSplitRead::CreateGroupDvFactory(group,
std::nullopt));
+ DataEvolutionSplitRead::CreateGroupDvFactory(group,
std::nullopt,
+
/*row_ranges=*/std::nullopt));
ASSERT_FALSE(null_factory);
}
@@ -1297,8 +1543,72 @@ TEST_F(DataEvolutionSplitReadTest,
TestCreateGroupDvFactoryRejectsFileOutsideAnc
std::vector<std::shared_ptr<DataFileMeta>> group = {anchor, blob};
DataEvolutionSplitRead::GroupDeletionVector group_dv{MakeBitmapDv({10}),
Range(100, 199)};
- ASSERT_NOK_WITH_MSG(DataEvolutionSplitRead::CreateGroupDvFactory(group,
group_dv),
- "should contain row id range");
+ ASSERT_NOK_WITH_MSG(
+ DataEvolutionSplitRead::CreateGroupDvFactory(group, group_dv,
/*row_ranges=*/std::nullopt),
+ "should contain selected row id range");
+}
+
+TEST_F(DataEvolutionSplitReadTest, TestGroupDvChecksOnlySelectedRanges) {
+ auto anchor = CreateNormalFile("anchor.parquet", 100, 100, 10);
+ auto prefix = CreateDataFileMeta("prefix.blob", 90, 30, 20);
+ auto suffix = CreateDataFileMeta("suffix.blob", 180, 30, 20);
+ std::vector<std::shared_ptr<DataFileMeta>> group = {anchor, prefix,
suffix};
+ for (bool bitmap64 : {false, true}) {
+ std::shared_ptr<DeletionVector> dv =
+ bitmap64 ?
std::shared_ptr<DeletionVector>(std::make_shared<Bitmap64DeletionVector>())
+ : MakeBitmapDv({});
+ ASSERT_OK(dv->Delete(5));
+ ASSERT_OK(dv->Delete(85));
+ DataEvolutionSplitRead::GroupDeletionVector group_dv{dv, Range(100,
199)};
+ ASSERT_OK_AND_ASSIGN(
+ DeletionVector::Factory factory,
+ DataEvolutionSplitRead::CreateGroupDvFactory(
+ group, group_dv, std::vector<Range>{Range(180, 199),
Range(100, 119)}));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<DeletionVector> prefix_dv,
factory("prefix.blob"));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<DeletionVector> suffix_dv,
factory("suffix.blob"));
+ ASSERT_TRUE(prefix_dv->IsDeleted(15).value());
+ ASSERT_TRUE(suffix_dv->IsDeleted(5).value());
+ ASSERT_NOK_WITH_MSG(DataEvolutionSplitRead::CreateGroupDvFactory(
+ group, group_dv, std::vector<Range>{Range(99,
105)}),
+ "should contain selected row id range");
+ ASSERT_NOK_WITH_MSG(DataEvolutionSplitRead::CreateGroupDvFactory(
+ group, group_dv, std::vector<Range>{Range(195,
200)}),
+ "should contain selected row id range");
+ ASSERT_OK(DataEvolutionSplitRead::CreateGroupDvFactory(
+ group, group_dv, std::vector<Range>{Range(300, 310)}));
+
+ // A format can return an entire batch with only some rows selected.
The DV wrapper
+ // must never probe unselected prefix/suffix positions outside the
anchor range.
+ auto type = arrow::struct_({arrow::field("f0", arrow::int32())});
+ auto data =
+ arrow::ipc::internal::json::ArrayFromJSON(type,
R"([[0],[1],[2],[3]])").ValueOrDie();
+ auto small_file = CreateDataFileMeta("small.blob", 99, 4, 20);
+ ASSERT_OK_AND_ASSIGN(factory,
+ DataEvolutionSplitRead::CreateGroupDvFactory(
+ {small_file}, group_dv,
std::vector<Range>{Range(100, 101)}));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<DeletionVector> shifted_dv,
factory("small.blob"));
+ RoaringBitmap32 selection = RoaringBitmap32::From({1, 2});
+ ApplyDeletionVectorBatchReader reader(
+ std::make_unique<MockFileBatchReader>(data, type, selection, 4),
shifted_dv);
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result,
+ ReadResultCollector::CollectResult(&reader));
+ ASSERT_TRUE(result);
+ ASSERT_EQ(result->length(), 2);
+ }
+}
+
+TEST_F(DataEvolutionSplitReadTest, TestPartialBlobBunchUsesNormalRange) {
+ auto blob = CreateBlobFile("blob", 103, 2, 20,
std::optional<std::vector<std::string>>({"b0"}));
+ DataEvolutionSplitRead::BlobBunch bunch(Range(100, 109),
/*has_row_ids_selection=*/false);
+ ASSERT_OK(bunch.Add(blob));
+ ASSERT_OK_AND_ASSIGN(int64_t row_count, bunch.RowCount());
+ ASSERT_EQ(row_count, 10);
+ ASSERT_OK_AND_ASSIGN(Range logical_range, bunch.LogicalRange());
+ ASSERT_EQ(logical_range, Range(100, 109));
+ auto tail = CreateBlobFile("tail", 108, 2, 20,
std::optional<std::vector<std::string>>({"b0"}));
+ ASSERT_OK(bunch.Add(tail));
+ ASSERT_OK_AND_ASSIGN(int64_t row_count2, bunch.RowCount());
+ ASSERT_EQ(row_count2, 10);
}
TEST_F(DataEvolutionSplitReadTest, TestExcludeDeletedRowIds) {
@@ -1322,6 +1632,47 @@ TEST_F(DataEvolutionSplitReadTest,
TestExcludeDeletedRowIds) {
ASSERT_TRUE(remaining.empty());
}
+TEST_F(DataEvolutionSplitReadTest,
TestExcludeDeletedRowIdsRestrictsDeletionsToSelectedGaps) {
+ for (bool bitmap64 : {false, true}) {
+ std::shared_ptr<DeletionVector> dv =
+ bitmap64 ?
std::shared_ptr<DeletionVector>(std::make_shared<Bitmap64DeletionVector>())
+ : MakeBitmapDv({});
+ for (int64_t position = 0; position < 200; position += 2) {
+ ASSERT_OK(dv->Delete(position));
+ }
+ DataEvolutionSplitRead::GroupDeletionVector group_dv{dv, Range(100,
599)};
+
+ // Selected gaps exceed the DV cardinality, but all deletions precede
them.
+ ASSERT_OK_AND_ASSIGN(
+ std::vector<Range> remaining,
+ DataEvolutionSplitRead::ExcludeDeletedRowIds({Range(300, 499)},
group_dv));
+ ASSERT_EQ(remaining, (std::vector<Range>{Range(300, 499)}));
+
+ // Deletions between selected ranges and after them must also be
ignored.
+ ASSERT_OK(dv->Delete(450));
+ ASSERT_OK_AND_ASSIGN(remaining,
DataEvolutionSplitRead::ExcludeDeletedRowIds(
+ {Range(100, 110), Range(300,
499)}, group_dv));
+ ASSERT_EQ(remaining,
+ (std::vector<Range>{Range(101, 101), Range(103, 103),
Range(105, 105),
+ Range(107, 107), Range(109, 109),
Range(300, 499)}));
+
+ // Deleting row id 450 (anchor position 350) splits the second
selected gap.
+ ASSERT_OK(dv->Delete(350));
+ ASSERT_OK_AND_ASSIGN(remaining,
DataEvolutionSplitRead::ExcludeDeletedRowIds(
+ {Range(100, 110), Range(400,
499)}, group_dv));
+ ASSERT_EQ(remaining, (std::vector<Range>{Range(101, 101), Range(103,
103), Range(105, 105),
+ Range(107, 107), Range(109,
109), Range(400, 449),
+ Range(451, 499)}));
+
+ // Keep both endpoints of a selected range subject to deletion
filtering.
+ ASSERT_OK(dv->Delete(200));
+ ASSERT_OK(dv->Delete(399));
+ ASSERT_OK_AND_ASSIGN(
+ remaining,
DataEvolutionSplitRead::ExcludeDeletedRowIds({Range(300, 499)}, group_dv));
+ ASSERT_EQ(remaining, (std::vector<Range>{Range(301, 449), Range(451,
498)}));
+ }
+}
+
TEST_F(DataEvolutionSplitReadTest, Bitmap64GroupWindowWithHighOffset) {
const int64_t offset = 1LL << 32;
auto anchor = CreateNormalFile("anchor.parquet", 100, offset + 30, 10);
@@ -1331,8 +1682,8 @@ TEST_F(DataEvolutionSplitReadTest,
Bitmap64GroupWindowWithHighOffset) {
ASSERT_OK(dv->Delete(p));
}
DataEvolutionSplitRead::GroupDeletionVector group_dv{dv, Range(100, 100 +
offset + 29)};
- ASSERT_OK_AND_ASSIGN(auto factory,
- DataEvolutionSplitRead::CreateGroupDvFactory({anchor,
blob}, group_dv));
+ ASSERT_OK_AND_ASSIGN(auto factory,
DataEvolutionSplitRead::CreateGroupDvFactory(
+ {anchor, blob}, group_dv,
/*row_ranges=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto view, factory("blob0.blob"));
ASSERT_TRUE(view);
ASSERT_TRUE(view->IsDeleted(0).value());
@@ -1342,6 +1693,11 @@ TEST_F(DataEvolutionSplitReadTest,
Bitmap64GroupWindowWithHighOffset) {
std::vector<int64_t> positions;
ASSERT_OK(view->ForEachDeletedPosition([&](int64_t p) {
positions.push_back(p); }));
ASSERT_EQ(positions, (std::vector<int64_t>{0, 5}));
+ ASSERT_OK_AND_ASSIGN(std::vector<Range> live,
DataEvolutionSplitRead::ExcludeDeletedRowIds(
+ {group_dv.anchor_range},
group_dv));
+ ASSERT_EQ(live, (std::vector<Range>{
+ Range(100, 100 + offset - 2), Range(101 + offset, 104
+ offset),
+ Range(106 + offset, 119 + offset), Range(121 + offset,
129 + offset)}));
}
} // namespace paimon::test
diff --git a/test/inte/blob_table_inte_test.cpp
b/test/inte/blob_table_inte_test.cpp
index 4cbfda4d..c721658f 100644
--- a/test/inte/blob_table_inte_test.cpp
+++ b/test/inte/blob_table_inte_test.cpp
@@ -2004,6 +2004,132 @@ TEST_P(BlobTableInteTest,
TestDataEvolutionBlobPartialUpdateWithDeletionVectors)
/*predicate=*/nullptr, /*row_ranges=*/{Range(0,
1)}));
}
+TEST_P(BlobTableInteTest,
TestDataEvolutionPartialBlobBackfillWithDeletionVectors) {
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
+ BlobUtils::ToArrowField("b0",
/*nullable=*/true)};
+ CreateTable(fields, {},
+ {{Options::FILE_FORMAT, GetParam()},
+ {Options::FILE_SYSTEM, "local"},
+ {Options::ROW_TRACKING_ENABLED, "true"},
+ {Options::DATA_EVOLUTION_ENABLED, "true"},
+ {Options::DELETION_VECTORS_ENABLED, "true"},
+ {Options::DELETION_VECTOR_BITMAP64, "true"},
+ {Options::READ_BATCH_SIZE, "2"}});
+ std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+ auto normal_type = arrow::struct_({fields[0]});
+ auto normal = arrow::ipc::internal::json::ArrayFromJSON(
+ normal_type,
R"([[0],[1],[2],[3],[4],[5],[6],[7],[8],[9]])")
+ .ValueOrDie();
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> base,
+ WriteArray(table_path, {}, {"f0"}, {normal}));
+ SetFirstRowId(100, base);
+ ASSERT_OK(Commit(table_path, base));
+ auto blobs = MakeBlobUpdateArray(fields[1], {"b3", "b4", "b5"});
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> backfill,
+ WriteArray(table_path, {}, {"b0"}, {blobs}));
+ SetFirstRowId(103, backfill);
+ ASSERT_OK(Commit(table_path, backfill));
+
+ auto full = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
+
R"([[0,null],[1,null],[2,null],[3,"b3"],[4,"b4"],
+
[5,"b5"],[6,null],[7,null],[8,null],[9,null]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "b0"}, full));
+ auto blob_only = checked_pointer_cast<arrow::StructArray>(
+ arrow::StructArray::Make({full->field(1)}, {fields[1]}).ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"b0"}, blob_only));
+
+ ASSERT_OK_AND_ASSIGN(std::string anchor,
+
DeletionVectorTestHelper::RetrieveAnchorFileName(base));
+ // Delete rows in the physical blob file, the leading gap, and the
trailing gap.
+ ASSERT_OK(CommitDeletionVectors(table_path, base[0], {{anchor, {1, 3, 7,
9}}}));
+ arrow::FieldVector read_fields = {fields[0], fields[1],
SpecialFields::RowId().ArrowField()};
+ auto expected = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(
+ arrow::struct_(read_fields),
+
R"([[0,null,100],[2,null,102],[4,"b4",104],[5,"b5",105],[6,null,106],[8,null,108]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "b0", "_ROW_ID"}, expected));
+ blob_only = checked_pointer_cast<arrow::StructArray>(
+ arrow::StructArray::Make({expected->field(1)},
{fields[1]}).ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"b0"}, blob_only));
+ auto selected =
checked_pointer_cast<arrow::StructArray>(blob_only->Slice(1, 4));
+ ASSERT_OK(ScanAndRead(table_path, {"b0"}, selected, nullptr, {Range(102,
106)}));
+ auto gap_only =
checked_pointer_cast<arrow::StructArray>(blob_only->Slice(4, 2));
+ ASSERT_OK(ScanAndRead(table_path, {"b0"}, gap_only, nullptr, {Range(106,
108)}));
+ ASSERT_OK(ScanAndRead(table_path, {"b0"}, nullptr, nullptr, {Range(101,
101)}));
+}
+
+TEST_P(BlobTableInteTest, TestDataEvolutionDvValidatesSelectedBlobRange) {
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
+ BlobUtils::ToArrowField("b0",
/*nullable=*/true)};
+ CreateTable(fields, {},
+ {{Options::FILE_FORMAT, GetParam()},
+ {Options::FILE_SYSTEM, "local"},
+ {Options::ROW_TRACKING_ENABLED, "true"},
+ {Options::DATA_EVOLUTION_ENABLED, "true"},
+ {Options::DELETION_VECTORS_ENABLED, "true"}});
+ std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+ auto normal =
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[0]}),
+
R"([[0],[1],[2],[3]])")
+ .ValueOrDie();
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> base,
+ WriteArray(table_path, {}, {"f0"}, {normal}));
+ SetFirstRowId(100, base);
+ ASSERT_OK(Commit(table_path, base));
+ auto blobs = MakeBlobUpdateArray(fields[1], {"b0", "b1", "b2", "b3", "b4",
"b5", "b6", "b7"});
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> backfill,
+ WriteArray(table_path, {}, {"b0"}, {blobs}));
+ SetFirstRowId(98, backfill);
+ ASSERT_OK(Commit(table_path, backfill));
+ ASSERT_OK_AND_ASSIGN(std::string anchor,
+
DeletionVectorTestHelper::RetrieveAnchorFileName(base));
+ ASSERT_OK(CommitDeletionVectors(table_path, base[0], {{anchor, {1}}}));
+ auto expected = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
+
R"([[0,"b2"],[2,"b4"],[3,"b5"]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "b0"}, expected, nullptr,
{Range(100, 103)}));
+ ASSERT_NOK_WITH_MSG(ScanAndRead(table_path, {"f0", "b0"}, nullptr),
+ "should contain selected row id range");
+ ASSERT_NOK_WITH_MSG(ScanAndRead(table_path, {"f0", "b0"}, expected,
nullptr, {Range(99, 100)}),
+ "should contain selected row id range");
+}
+
+TEST_P(BlobTableInteTest,
TestDataEvolutionRejectsAnchorlessGroupWithDeletionVectors) {
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
+ BlobUtils::ToArrowField("b0",
/*nullable=*/true)};
+ CreateTable(fields, {},
+ {{Options::FILE_FORMAT, GetParam()},
+ {Options::FILE_SYSTEM, "local"},
+ {Options::ROW_TRACKING_ENABLED, "true"},
+ {Options::DATA_EVOLUTION_ENABLED, "true"},
+ {Options::DELETION_VECTORS_ENABLED, "true"}});
+ std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+ auto normal =
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[0]}),
R"([[0],[1],[2]])")
+ .ValueOrDie();
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> base,
+ WriteArray(table_path, {}, {"f0"}, {normal}));
+ SetFirstRowId(100, base);
+ ASSERT_OK(Commit(table_path, base));
+ auto blobs = MakeBlobUpdateArray(fields[1], {"b0", "b1"});
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> dedicated,
+ WriteArray(table_path, {}, {"b0"}, {blobs}));
+ SetFirstRowId(200, dedicated);
+ ASSERT_OK(Commit(table_path, dedicated));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> rows,
+ ScanAndReadResult(table_path, {"b0", "_ROW_ID"}));
+ ASSERT_TRUE(rows);
+ ASSERT_EQ(rows->length(), 5);
+ ASSERT_OK_AND_ASSIGN(std::string anchor,
+
DeletionVectorTestHelper::RetrieveAnchorFileName(base));
+ ASSERT_OK(CommitDeletionVectors(table_path, base[0], {{anchor, {1}}}));
+ // The same split now carries a DV. Match Java by requiring a normal
anchor in each group.
+ ASSERT_NOK_WITH_MSG(ScanAndReadResult(table_path, {"b0", "_ROW_ID"}),
"normal anchor file");
+}
+
TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateCompactedLayers) {
arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::utf8()),
BlobUtils::ToArrowField("b0")};
@@ -2350,11 +2476,10 @@ TEST_P(BlobTableInteTest,
TestBlobSentinelValueInBaseLayerDegradesToNull) {
ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
}
-TEST_P(BlobTableInteTest, TestUserSuppliedInternalPlaceholderOptionsIgnored) {
+TEST_P(BlobTableInteTest, TestSingleBlobLayerPlaceholderBecomesNull) {
// blob.internal.emit-placeholder-sentinel is reserved for the
data-evolution fallback read
- // path. Set in the user table options it must be ignored: were it
honored, a read of a
- // placeholder entry that no older layer can resolve would return the
sentinel bytes as the
- // blob value instead of failing.
+ // path. A single layer also uses fallback: an unresolved placeholder
becomes null, and the
+ // internal sentinel must never be exposed as the blob value.
arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::utf8()),
BlobUtils::ToArrowField("b0",
/*nullable=*/true)};
@@ -2376,9 +2501,11 @@ TEST_P(BlobTableInteTest,
TestUserSuppliedInternalPlaceholderOptionsIgnored) {
WriteArray(table_path, {}, schema->field_names(),
{src_array}));
ASSERT_OK(Commit(table_path, commit_msgs));
- // the user-supplied key was stripped, so the single-layer read rejects
the placeholder
- ASSERT_NOK_WITH_MSG(ScanAndReadResult(table_path, schema->field_names()),
- "contains a placeholder entry");
+ auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
+ R"([[1, "a", null], [2, "b",
"blob_b"]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
}
TEST_P(BlobTableInteTest,
TestDataEvolutionBlobOnlyFirstCommitFailsWithoutFirstRowId) {
diff --git a/test/inte/data_evolution_table_test.cpp
b/test/inte/data_evolution_table_test.cpp
index 395de88d..475ae9a6 100644
--- a/test/inte/data_evolution_table_test.cpp
+++ b/test/inte/data_evolution_table_test.cpp
@@ -107,10 +107,14 @@ class DataEvolutionTableTest : public ::testing::Test,
Result<std::vector<std::shared_ptr<CommitMessage>>> WriteArrays(
const std::string& table_path, const std::map<std::string,
std::string>& partition,
const std::vector<std::string>& write_cols,
- const std::vector<std::shared_ptr<arrow::Array>>& write_arrays) const {
+ const std::vector<std::shared_ptr<arrow::Array>>& write_arrays,
+ const std::map<std::string, std::string>& write_options = {}) const {
// write
WriteContextBuilder write_builder(table_path, "commit_user_1");
write_builder.WithWriteSchema(write_cols);
+ for (const auto& [key, value] : write_options) {
+ write_builder.AddOption(key, value);
+ }
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<WriteContext> write_context,
write_builder.Finish());
PAIMON_ASSIGN_OR_RAISE(auto file_store_write,
FileStoreWrite::Create(std::move(write_context)));
@@ -132,8 +136,9 @@ class DataEvolutionTableTest : public ::testing::Test,
Result<std::vector<std::shared_ptr<CommitMessage>>> WriteArray(
const std::string& table_path, const std::map<std::string,
std::string>& partition,
const std::vector<std::string>& write_cols,
- const std::shared_ptr<arrow::Array>& write_array) const {
- return WriteArrays(table_path, partition, write_cols, {write_array});
+ const std::shared_ptr<arrow::Array>& write_array,
+ const std::map<std::string, std::string>& write_options = {}) const {
+ return WriteArrays(table_path, partition, write_cols, {write_array},
write_options);
}
Result<std::vector<std::shared_ptr<CommitMessage>>> WriteArray(
@@ -213,7 +218,8 @@ class DataEvolutionTableTest : public ::testing::Test,
/// free to serve f2 from the full-row write instead.
Result<std::vector<std::shared_ptr<CommitMessage>>> WriteAndCommitGroup(
const std::string& table_path, int64_t first_row_id, const
std::vector<int32_t>& f0_values,
- const std::map<std::string, std::string>& partition = {}) const {
+ const std::map<std::string, std::string>& partition = {},
+ const std::map<std::string, std::string>& partial_write_options = {})
const {
auto partition_f1 = partition.find("f1");
std::string base_json = "[";
std::string f2_json = "[";
@@ -245,8 +251,9 @@ class DataEvolutionTableTest : public ::testing::Test,
auto f2_array = std::dynamic_pointer_cast<arrow::StructArray>(
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(f2_fields), f2_json)
.ValueOrDie());
- PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<CommitMessage>>
f2_msgs,
- WriteArray(table_path, partition, {"f2"},
f2_array));
+ PAIMON_ASSIGN_OR_RAISE(
+ std::vector<std::shared_ptr<CommitMessage>> f2_msgs,
+ WriteArray(table_path, partition, {"f2"}, f2_array,
partial_write_options));
SetFirstRowId(first_row_id, f2_msgs);
PAIMON_RETURN_NOT_OK(Commit(table_path, f2_msgs));
@@ -2859,15 +2866,12 @@ TEST_P(DataEvolutionTableTest,
TestScanAndReadWithIndex) {
expected_array));
}
{
- // The old file's f2 index does not contain 102, but the newer file
owns f2.
+ // The newer file owns f2; its bitmap selects the same row in every
merged reader.
auto predicate = PredicateBuilder::Equal(/*field_index=*/2,
/*field_name=*/"f2",
FieldType::INT, Literal(102));
auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
- ["Lily", 2, 102, 2.1],
- ["Alice", 4, 104, 3.1],
- ["Bob", 6, 106, 4.1],
- ["David", 8, 108, 5.1]
+ ["Lily", 2, 102, 2.1]
])")
.ValueOrDie());
ASSERT_OK(ScanAndRead(table_path,
arrow::schema(arrow_data_type->fields())->field_names(),
@@ -2926,15 +2930,13 @@ TEST_P(DataEvolutionTableTest,
TestScanAndReadWithIndex) {
{
// test row id with predicate
std::vector<Range> row_ranges = {Range(0l, 2l)};
- // A merged group keeps all selected row ids to preserve column
alignment.
+ // Intersect the group's shared bitmap with row ranges while
preserving column alignment.
auto predicate = PredicateBuilder::Equal(/*field_index=*/2,
/*field_name=*/"f2",
FieldType::INT, Literal(106));
CheckScanResult(table_path, /*predicate=*/predicate,
/*row_ranges=*/row_ranges,
/*expected_first_row_ids=*/{0, 0},
/*expected_row_counts=*/{4, 4});
auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
arrow::ipc::internal::json::ArrayFromJSON(arrow_data_type, R"([
- ["Lily", 2, 102, 2.1],
- ["Alice", 4, 104, 3.1],
["Bob", 6, 106, 4.1]
])")
.ValueOrDie());
@@ -3657,6 +3659,143 @@ TEST_P(DataEvolutionDvTableTest,
TestReadWithDeletionVectors) {
/*predicate=*/nullptr, /*row_ranges=*/{Range(1,
2)}));
}
+TEST_P(DataEvolutionDvTableTest, TestIndexSkipsBeforeReadingDeletionVector) {
+ CreateDataEvolutionTable(
+ /*deletion_vectors_enabled=*/true,
+ {{"file-index.bitmap.columns", "f2"},
{Options::FILE_INDEX_IN_MANIFEST_THRESHOLD, "1B"}});
+ std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+ auto data = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_),
+
R"([[0,"a0","x0"],[1,"a1","x1"],
+
[2,"a2","x2"],[3,"a3","x3"]])")
+ .ValueOrDie());
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> messages,
+ WriteArray(table_path, {}, {"f0", "f1", "f2"}, data));
+ SetFirstRowId(100, messages);
+ ASSERT_OK(Commit(table_path, messages));
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>>
merged_messages,
+ WriteAndCommitGroup(table_path, 200, {0, 1, 2, 3}));
+ ASSERT_OK_AND_ASSIGN(std::vector<std::string> anchors,
PlannedAnchorFileNames(table_path));
+ ASSERT_EQ(anchors.size(), 2);
+ ASSERT_OK(CommitDeletionVectors(table_path, messages[0], {{anchors[0],
{2}}}));
+ ASSERT_OK(CommitDeletionVectors(table_path, merged_messages[0],
{{anchors[1], {2}}}));
+
+ {
+ // Indexes keep positions 1 and 2 in both groups; their DVs remove
position 2.
+ auto predicate = PredicateBuilder::In(
+ 2, "f2", FieldType::STRING,
+ {Literal(FieldType::STRING, "x1", 2), Literal(FieldType::STRING,
"x2", 2),
+ Literal(FieldType::STRING, "y1", 2), Literal(FieldType::STRING,
"y2", 2)});
+ auto expected = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields_),
+
R"([[1,"a1","x1"],[1,"a1","y1"]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2"}, expected,
predicate, {},
+ /*check_scan_plan_when_empty_result=*/true,
+ /*apply_predicate_to_scan=*/false));
+ }
+
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
PlanSplits(table_path));
+ std::set<std::string> dv_paths;
+ for (const auto& split : splits) {
+ auto data_split = checked_pointer_cast<DataSplitImpl>(split);
+ for (const auto& deletion_file : data_split->DeletionFiles()) {
+ if (deletion_file) {
+ dv_paths.insert(deletion_file->path);
+ }
+ }
+ }
+ ASSERT_FALSE(dv_paths.empty());
+ for (const auto& path : dv_paths) {
+ ASSERT_OK(dir_->GetFileSystem()->Delete(path, /*recursive=*/false));
+ }
+ auto missing = PredicateBuilder::Equal(2, "f2", FieldType::STRING,
+ Literal(FieldType::STRING,
"missing", 7));
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2"}, nullptr, missing, {},
+ /*check_scan_plan_when_empty_result=*/false,
+ /*apply_predicate_to_scan=*/false));
+ auto single_match =
+ PredicateBuilder::Equal(2, "f2", FieldType::STRING,
Literal(FieldType::STRING, "x1", 2));
+ auto merged_match =
+ PredicateBuilder::Equal(2, "f2", FieldType::STRING,
Literal(FieldType::STRING, "y1", 2));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> match,
+ PredicateBuilder::Or({single_match, merged_match}));
+ // Both single-file and merged groups are rejected by rowRanges
intersected with the bitmap.
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2"}, nullptr, match,
+ {Range(100, 100), Range(200, 200)},
+ /*check_scan_plan_when_empty_result=*/false,
+ /*apply_predicate_to_scan=*/false));
+ // A selected matching row still needs its DV; do not hide an actual
missing sidecar.
+ ASSERT_NOK(ScanAndRead(table_path, {"f0", "f1", "f2"}, nullptr, match,
{Range(101, 101)},
+ /*check_scan_plan_when_empty_result=*/false,
+ /*apply_predicate_to_scan=*/false));
+}
+
+TEST_P(DataEvolutionDvTableTest,
TestSharedBitmapSelectionComposesWithDeletionVectors) {
+ CreateDataEvolutionTable(/*deletion_vectors_enabled=*/true,
+ {{"file-index.bitmap.columns", "f0,f2"},
+ {Options::FILE_INDEX_IN_MANIFEST_THRESHOLD,
"1B"},
+ {Options::READ_BATCH_SIZE, "2"}});
+ std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> first,
+ WriteAndCommitGroup(table_path, 100, {0, 1, 2, 3}, {},
+ {{"file-index.bitmap.columns",
"f2"}}));
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> second,
+ WriteAndCommitGroup(table_path, 200, {0, 1, 2, 3}, {},
+ {{"file-index.bitmap.columns",
"f2"}}));
+ ASSERT_OK_AND_ASSIGN(std::vector<std::string> anchors,
PlannedAnchorFileNames(table_path));
+ ASSERT_EQ(anchors.size(), 2);
+ ASSERT_OK(CommitDeletionVectors(table_path, first[0], {{anchors[0],
{2}}}));
+ ASSERT_OK(CommitDeletionVectors(table_path, second[0], {{anchors[1],
{1}}}));
+ auto one =
+ PredicateBuilder::Equal(2, "f2", FieldType::STRING,
Literal(FieldType::STRING, "y1", 2));
+ auto two =
+ PredicateBuilder::Equal(2, "f2", FieldType::STRING,
Literal(FieldType::STRING, "y2", 2));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
PredicateBuilder::Or({one, two}));
+ arrow::FieldVector fields = fields_;
+ fields.push_back(SpecialFields::RowId().ArrowField());
+ auto expected = checked_pointer_cast<arrow::StructArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
+
R"([[1,"a1","y1",101],[2,"a2","y2",202]])")
+ .ValueOrDie());
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID"}, expected,
predicate, {},
+ /*check_scan_plan_when_empty_result=*/true,
+ /*apply_predicate_to_scan=*/false));
+
+ // The newer f2 index must win over its stale copy in the anchor file.
+ auto newer =
+ PredicateBuilder::Equal(2, "f2", FieldType::STRING,
Literal(FieldType::STRING, "y1", 2));
+ auto expected_one =
checked_pointer_cast<arrow::StructArray>(expected->Slice(0, 1));
+ auto small = PredicateBuilder::Equal(0, "f0", FieldType::INT, Literal(1));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> combined,
+ PredicateBuilder::And({small, predicate}));
+ // f0 and f2 are won by different files. Intersect both indexes before
applying the DV.
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID"},
expected_one, combined, {},
+ /*check_scan_plan_when_empty_result=*/true,
+ /*apply_predicate_to_scan=*/false));
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID"},
expected_one, newer, {},
+ /*check_scan_plan_when_empty_result=*/true,
+ /*apply_predicate_to_scan=*/false));
+
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
PlanSplits(table_path));
+ for (const auto& split : splits) {
+ auto data_split = checked_pointer_cast<DataSplitImpl>(split);
+ for (const auto& file : data_split->DataFiles()) {
+ if (file->first_row_id == 200) {
+ ASSERT_OK(dir_->GetFileSystem()->Delete(
+ PathUtil::JoinPath(data_split->BucketPath(),
file->file_name), false));
+ }
+ }
+ }
+ // The second group's only bitmap hit is deleted: skip it before opening
any data file.
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2", "_ROW_ID"},
expected_one, one, {},
+ /*check_scan_plan_when_empty_result=*/true,
+ /*apply_predicate_to_scan=*/false));
+ ASSERT_OK(ScanAndRead(table_path, {"f0", "f1", "f2"}, nullptr, one,
{Range(200, 203)},
+ /*check_scan_plan_when_empty_result=*/false,
+ /*apply_predicate_to_scan=*/false));
+}
+
TEST_P(DataEvolutionDvTableTest, TestReadWithDeletionVectorsAcrossReadBatches)
{
// the 12 rows below span several read batches, so the deletion vector
empties a whole
// batch of every file of the group: each file reader then skips that
batch entirely and