| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| #include "paimon/common/reader/blob_fallback_batch_reader.h" |
| |
| #include <optional> |
| #include <string> |
| #include <vector> |
| |
| #include "arrow/api.h" |
| #include "arrow/util/range.h" |
| #include "gtest/gtest.h" |
| #include "paimon/common/data/blob_defs.h" |
| #include "paimon/common/data/blob_utils.h" |
| #include "paimon/common/table/special_fields.h" |
| #include "paimon/common/utils/checked_cast.h" |
| #include "paimon/memory/memory_pool.h" |
| #include "paimon/testing/mock/mock_file_batch_reader.h" |
| #include "paimon/testing/utils/read_result_collector.h" |
| #include "paimon/testing/utils/testharness.h" |
| |
| namespace paimon::test { |
| |
| /// "PH" stands for a placeholder row (the sentinel bytes emitted by the placeholder-aware blob |
| /// reader), std::nullopt for null. |
| using BlobRows = std::vector<std::optional<std::string>>; |
| |
| class BlobFallbackBatchReaderTest : public ::testing::Test { |
| public: |
| void SetUp() override { |
| pool_ = GetDefaultPool(); |
| struct_type_ = arrow::struct_({BlobUtils::ToArrowField("blob_col", true)}); |
| read_schema_ = arrow::schema(struct_type_->fields()); |
| } |
| |
| static std::string Sentinel() { |
| return std::string(BlobDefs::PlaceholderSentinelView()); |
| } |
| |
| std::shared_ptr<arrow::Array> MakeBlobStruct(const BlobRows& rows) const { |
| arrow::StructBuilder struct_builder(struct_type_, arrow::default_memory_pool(), |
| {std::make_shared<arrow::LargeBinaryBuilder>()}); |
| auto blob_builder = |
| checked_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0)); |
| for (const auto& row : rows) { |
| EXPECT_TRUE(struct_builder.Append().ok()); |
| if (!row) { |
| EXPECT_TRUE(blob_builder->AppendNull().ok()); |
| } else if (*row == "PH") { |
| std::string sentinel = Sentinel(); |
| EXPECT_TRUE(blob_builder->Append(sentinel.data(), sentinel.size()).ok()); |
| } else { |
| EXPECT_TRUE(blob_builder->Append(row->data(), row->size()).ok()); |
| } |
| } |
| std::shared_ptr<arrow::Array> array; |
| EXPECT_TRUE(struct_builder.Finish(&array).ok()); |
| return array; |
| } |
| |
| /// One segment of a group: File(rows) for a file segment, Gap(n) for a placeholder gap |
| /// of n selected rows (with synthetic row ids when the schema does not read them, or the |
| /// given ranges via GapRanges). |
| struct SegmentSpec { |
| std::vector<Range> gap_ranges; |
| std::optional<BlobRows> file_rows; |
| static SegmentSpec Gap(int64_t rows) { |
| return SegmentSpec{{Range(0, rows - 1)}, std::nullopt}; |
| } |
| static SegmentSpec GapRanges(std::vector<Range> ranges) { |
| return SegmentSpec{std::move(ranges), std::nullopt}; |
| } |
| static SegmentSpec File(BlobRows rows) { |
| return SegmentSpec{{}, std::move(rows)}; |
| } |
| }; |
| |
| std::vector<BlobFallbackBatchReader::Segment> MakeGroup(const std::vector<SegmentSpec>& specs, |
| int32_t file_batch_size) const { |
| std::vector<BlobFallbackBatchReader::Segment> segments; |
| for (const auto& spec : specs) { |
| if (spec.file_rows) { |
| auto reader = std::make_unique<MockFileBatchReader>(MakeBlobStruct(*spec.file_rows), |
| struct_type_, file_batch_size); |
| segments.push_back(BlobFallbackBatchReader::Segment{std::move(reader), {}}); |
| } else { |
| segments.push_back(BlobFallbackBatchReader::Segment{nullptr, spec.gap_ranges}); |
| } |
| } |
| return segments; |
| } |
| |
| /// Runs the fallback over the groups with several batch sizes and compares to expected rows. |
| void CheckFallback(const std::vector<std::vector<SegmentSpec>>& group_specs, |
| const BlobRows& expected_rows) const { |
| auto expected_array = MakeBlobStruct(expected_rows); |
| for (auto batch_size : arrow::internal::Iota(1, 8)) { |
| for (auto file_batch_size : {1, 3, 1024}) { |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> groups; |
| groups.reserve(group_specs.size()); |
| for (const auto& specs : group_specs) { |
| groups.push_back(MakeGroup(specs, file_batch_size)); |
| } |
| ASSERT_OK_AND_ASSIGN( |
| auto reader, BlobFallbackBatchReader::Create(std::move(groups), read_schema_, |
| batch_size, pool_)); |
| ASSERT_OK_AND_ASSIGN( |
| auto result, paimon::test::ReadResultCollector::CollectResult(reader.get())); |
| reader->Close(); |
| auto expected_chunk_array = std::make_shared<arrow::ChunkedArray>(expected_array); |
| ASSERT_TRUE(result->Equals(expected_chunk_array)) |
| << "batch_size=" << batch_size << " file_batch_size=" << file_batch_size |
| << "\nresult: " << result->ToString() |
| << "\nexpected: " << expected_chunk_array->ToString(); |
| } |
| } |
| } |
| |
| protected: |
| std::shared_ptr<MemoryPool> pool_; |
| std::shared_ptr<arrow::DataType> struct_type_; |
| std::shared_ptr<arrow::Schema> read_schema_; |
| }; |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestBasicFallback) { |
| // newer layer updates row 1 only; rows 0 and 2 fall back to the older layer |
| CheckFallback( |
| {{SegmentSpec::File({"PH", "u1", "PH"})}, {SegmentSpec::File({"b0", "b1", "b2"})}}, |
| {"b0", "u1", "b2"}); |
| } |
| |
| 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"})}, |
| {SegmentSpec::File({"b0", "b1", "b2", "b3"})}}, |
| {"b0", "b1", "u2", "b3"}); |
| // trailing gap |
| CheckFallback({{SegmentSpec::File({"PH", "u1"}), SegmentSpec::Gap(2)}, |
| {SegmentSpec::File({"b0", "b1", "b2", "b3"})}}, |
| {"b0", "u1", "b2", "b3"}); |
| // middle gap between two files of one layer |
| CheckFallback({{SegmentSpec::File({"u0"}), SegmentSpec::Gap(2), SegmentSpec::File({"u3"})}, |
| {SegmentSpec::File({"b0", "b1", "b2", "b3"})}}, |
| {"u0", "b1", "b2", "u3"}); |
| // a gap segment covering multiple disjoint selected ranges |
| CheckFallback({{SegmentSpec::GapRanges({Range(0, 0), Range(2, 2)}), SegmentSpec::File({"u3"})}, |
| {SegmentSpec::File({"b0", "b2", "b3"})}}, |
| {"b0", "b2", "u3"}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestAllPlaceholdersBecomesNull) { |
| // a row that is a placeholder in every layer degrades to null |
| CheckFallback({{SegmentSpec::File({"PH", "PH"})}, {SegmentSpec::File({"b0", "PH"})}}, |
| {"b0", std::nullopt}); |
| CheckFallback({{SegmentSpec::Gap(2)}, {SegmentSpec::File({"PH", "PH"})}}, |
| {std::nullopt, std::nullopt}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestNullIsNotPlaceholder) { |
| // a real null in a newer layer wins: null means "updated to null", not "not updated" |
| CheckFallback({{SegmentSpec::File({std::nullopt, "u1"})}, {SegmentSpec::File({"b0", "b1"})}}, |
| {std::nullopt, "u1"}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestSentinelPrefixedValueIsNotPlaceholder) { |
| // placeholders are identified by exact equality with the sentinel bytes only: a real value |
| // that merely starts with them passes through unchanged, whether it falls back or wins as |
| // the newest layer |
| std::string prefixed = Sentinel() + "suffix"; |
| CheckFallback({{SegmentSpec::File({"PH", "u1"})}, {SegmentSpec::File({prefixed, "b1"})}}, |
| {prefixed, "u1"}); |
| CheckFallback({{SegmentSpec::File({prefixed, "PH"})}, {SegmentSpec::File({"b0", "b1"})}}, |
| {prefixed, "b1"}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestThreeLayers) { |
| CheckFallback({{SegmentSpec::File({"PH", "PH", "u2"})}, |
| {SegmentSpec::File({"PH", "m1", "PH"})}, |
| {SegmentSpec::File({"b0", "b1", "b2"})}}, |
| {"b0", "m1", "u2"}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestLayeredFilesAndGaps) { |
| // mirrors the compacted-sequence-groups shape: layers partially cover [0, 9] |
| CheckFallback( |
| {{SegmentSpec::Gap(6), SegmentSpec::File({"u66", "PH"}), SegmentSpec::Gap(1), |
| SegmentSpec::File({"u69"})}, |
| {SegmentSpec::File({"u40", "PH", "PH", "PH"}), SegmentSpec::Gap(4), |
| SegmentSpec::File({"u48", "PH"})}, |
| {SegmentSpec::File({"b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9"})}}, |
| {"u40", "b1", "b2", "b3", "b4", "b5", "u66", "b7", "u48", "u69"}); |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestRowTrackingFieldsPreserved) { |
| // Row-tracking projections: resolved rows keep their layer's row id and sequence number; |
| // an all-placeholder row keeps its row id (here provided by the newest group's gap |
| // segment), reports -1 as its sequence number, and degrades the blob to null. Covers the |
| // schema variants {blob, _ROW_ID, _SEQUENCE_NUMBER}, {blob, _ROW_ID} and |
| // {blob, _SEQUENCE_NUMBER}. |
| struct RowSpec { |
| std::optional<std::string> blob; |
| int64_t row_id; |
| int64_t seq_num; |
| }; |
| for (bool with_row_id : {true, false}) { |
| for (bool with_seq_num : {true, false}) { |
| if (!with_row_id && !with_seq_num) { |
| continue; |
| } |
| arrow::FieldVector fields = {BlobUtils::ToArrowField("blob_col", true)}; |
| if (with_row_id) { |
| fields.push_back(SpecialFields::RowId().field_); |
| } |
| if (with_seq_num) { |
| fields.push_back(SpecialFields::SequenceNumber().field_); |
| } |
| auto struct_type = arrow::struct_(fields); |
| auto schema = arrow::schema(fields); |
| |
| auto make_rows = [&](const std::vector<RowSpec>& rows) { |
| std::vector<std::shared_ptr<arrow::ArrayBuilder>> field_builders = { |
| std::make_shared<arrow::LargeBinaryBuilder>()}; |
| for (size_t i = 1; i < fields.size(); i++) { |
| field_builders.push_back(std::make_shared<arrow::Int64Builder>()); |
| } |
| arrow::StructBuilder struct_builder(struct_type, arrow::default_memory_pool(), |
| std::move(field_builders)); |
| auto blob_builder = |
| checked_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0)); |
| for (const auto& row : rows) { |
| EXPECT_TRUE(struct_builder.Append().ok()); |
| if (!row.blob) { |
| EXPECT_TRUE(blob_builder->AppendNull().ok()); |
| } else if (*row.blob == "PH") { |
| std::string sentinel = Sentinel(); |
| EXPECT_TRUE(blob_builder->Append(sentinel.data(), sentinel.size()).ok()); |
| } else { |
| EXPECT_TRUE(blob_builder->Append(row.blob->data(), row.blob->size()).ok()); |
| } |
| int32_t next_field = 1; |
| if (with_row_id) { |
| auto builder = checked_cast<arrow::Int64Builder*>( |
| struct_builder.field_builder(next_field++)); |
| EXPECT_TRUE(builder->Append(row.row_id).ok()); |
| } |
| if (with_seq_num) { |
| auto builder = checked_cast<arrow::Int64Builder*>( |
| struct_builder.field_builder(next_field)); |
| EXPECT_TRUE(builder->Append(row.seq_num).ok()); |
| } |
| } |
| std::shared_ptr<arrow::Array> array; |
| EXPECT_TRUE(struct_builder.Finish(&array).ok()); |
| return array; |
| }; |
| |
| for (auto batch_size : arrow::internal::Iota(1, 5)) { |
| for (auto file_batch_size : {1, 1024}) { |
| // newest layer (seq 20) covers only row 2; rows 0-1 are a gap |
| std::vector<BlobFallbackBatchReader::Segment> newest; |
| newest.push_back(BlobFallbackBatchReader::Segment{nullptr, {Range(0, 1)}}); |
| newest.push_back(BlobFallbackBatchReader::Segment{ |
| std::make_unique<MockFileBatchReader>(make_rows({{"u2", 2, 20}}), |
| struct_type, file_batch_size), |
| {}}); |
| // oldest layer (seq 10) covers rows 0-2, row 1 is a placeholder there too |
| std::vector<BlobFallbackBatchReader::Segment> oldest; |
| oldest.push_back(BlobFallbackBatchReader::Segment{ |
| std::make_unique<MockFileBatchReader>( |
| make_rows({{"b0", 0, 10}, {"PH", 1, 10}, {"PH", 2, 10}}), struct_type, |
| file_batch_size), |
| {}}); |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> groups; |
| groups.push_back(std::move(newest)); |
| groups.push_back(std::move(oldest)); |
| |
| ASSERT_OK_AND_ASSIGN(auto reader, |
| BlobFallbackBatchReader::Create(std::move(groups), schema, |
| batch_size, pool_)); |
| ASSERT_OK_AND_ASSIGN( |
| auto result, |
| paimon::test::ReadResultCollector::CollectResult(reader.get())); |
| reader->Close(); |
| |
| // row 0 falls back to seq 10, row 1 is all-placeholder (null blob, row id |
| // kept, seq -1), row 2 takes seq 20 |
| auto expected_array = |
| make_rows({{"b0", 0, 10}, {std::nullopt, 1, -1}, {"u2", 2, 20}}); |
| auto expected_chunk_array = |
| std::make_shared<arrow::ChunkedArray>(expected_array); |
| ASSERT_TRUE(result->Equals(expected_chunk_array)) |
| << "with_row_id=" << with_row_id << " with_seq_num=" << with_seq_num |
| << " batch_size=" << batch_size << " file_batch_size=" << file_batch_size |
| << "\nresult: " << result->ToString() |
| << "\nexpected: " << expected_chunk_array->ToString(); |
| } |
| } |
| } |
| } |
| } |
| |
| TEST_F(BlobFallbackBatchReaderTest, TestMisalignedGroupsFail) { |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> groups; |
| groups.push_back(MakeGroup({SegmentSpec::File({"PH", "u1", "PH"})}, 1024)); |
| groups.push_back(MakeGroup({SegmentSpec::File({"b0", "b1"})}, 1024)); |
| ASSERT_OK_AND_ASSIGN( |
| auto reader, BlobFallbackBatchReader::Create(std::move(groups), read_schema_, 1024, pool_)); |
| ASSERT_NOK_WITH_MSG(reader->NextBatch(), "same number of rows"); |
| } |
| |
| 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, pool_), |
| "at least two sequence groups"); |
| |
| // the read schema must contain a blob field |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> groups; |
| groups.push_back(MakeGroup({SegmentSpec::File({"b0"})}, 1024)); |
| groups.push_back(MakeGroup({SegmentSpec::File({"b1"})}, 1024)); |
| auto plain_schema = |
| arrow::schema({arrow::field("not_blob", arrow::large_binary(), /*nullable=*/true)}); |
| ASSERT_NOK_WITH_MSG( |
| BlobFallbackBatchReader::Create(std::move(groups), plain_schema, 1024, pool_), |
| "should contain a blob field"); |
| |
| // groups must not be empty |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> with_empty_group; |
| with_empty_group.push_back(MakeGroup({SegmentSpec::File({"b0"})}, 1024)); |
| with_empty_group.emplace_back(); |
| ASSERT_NOK_WITH_MSG( |
| BlobFallbackBatchReader::Create(std::move(with_empty_group), read_schema_, 1024, pool_), |
| "should not be empty"); |
| |
| // a gap segment must cover at least one selected row id |
| std::vector<std::vector<BlobFallbackBatchReader::Segment>> with_empty_gap; |
| with_empty_gap.push_back(MakeGroup({SegmentSpec::File({"b0"})}, 1024)); |
| with_empty_gap.push_back(MakeGroup({SegmentSpec::GapRanges({})}, 1024)); |
| ASSERT_NOK_WITH_MSG( |
| BlobFallbackBatchReader::Create(std::move(with_empty_gap), read_schema_, 1024, pool_), |
| "at least one selected row id"); |
| } |
| |
| } // namespace paimon::test |