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 0a9ed795 perf(vector): reuse main readers across sequential vector 
files (#453)
0a9ed795 is described below

commit 0a9ed795bed3426f4cba868721c83101fe416116
Author: 小明同学 <[email protected]>
AuthorDate: Fri Oct 23 23:03:30 2026 +0800

    perf(vector): reuse main readers across sequential vector files (#453)
---
 .../core/operation/data_evolution_split_read.cpp   | 149 ++++++++++++-
 .../core/operation/data_evolution_split_read.h     |  32 +++
 .../operation/data_evolution_split_read_test.cpp   | 231 +++++++++++++++++++++
 test/inte/data_evolution_table_test.cpp            | 133 +++++++++++-
 4 files changed, 528 insertions(+), 17 deletions(-)

diff --git a/src/paimon/core/operation/data_evolution_split_read.cpp 
b/src/paimon/core/operation/data_evolution_split_read.cpp
index 6d065b46..5fea0f21 100644
--- a/src/paimon/core/operation/data_evolution_split_read.cpp
+++ b/src/paimon/core/operation/data_evolution_split_read.cpp
@@ -777,6 +777,65 @@ DataEvolutionSplitRead::PlanVectorReadRanges(
     return result;
 }
 
+Result<std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>>>
+DataEvolutionSplitRead::TryPlanSequentialVectorRead(
+    const std::vector<std::shared_ptr<DataFileMeta>>& files,
+    const std::function<Result<std::vector<int32_t>>(const 
std::shared_ptr<DataFileMeta>&)>&
+        file_to_field_ids) {
+    std::vector<std::shared_ptr<FieldBunch>> no_match;
+    std::shared_ptr<DataFileMeta> main_file;
+    std::vector<std::shared_ptr<DataFileMeta>> vector_files;
+    for (const auto& file : files) {
+        if (VectorStoreUtils::IsVectorStoreFile(file->file_name)) {
+            vector_files.push_back(file);
+        } else if (DataEvolutionUtils::IsNormalFile(file->file_name) && 
!main_file) {
+            main_file = file;
+        } else {
+            return no_match;
+        }
+    }
+    if (!main_file || vector_files.empty()) {
+        return no_match;
+    }
+    for (const auto& file : files) {
+        PAIMON_ASSIGN_OR_RAISE(int64_t first, file->NonNullFirstRowId());
+        if (first < 0 || file->row_count <= 0 ||
+            first > std::numeric_limits<int64_t>::max() - file->row_count) {
+            return Status::Invalid("Invalid row id range in VECTOR merge 
group");
+        }
+    }
+    std::sort(vector_files.begin(), vector_files.end(), [](const auto& left, 
const auto& right) {
+        return left->first_row_id.value() < right->first_row_id.value();
+    });
+    const auto& first_vector = vector_files[0];
+    if (!first_vector->write_cols || first_vector->write_cols->size() != 1) {
+        return no_match;
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::string format, first_vector->FileFormat());
+    int64_t next_row_id = main_file->first_row_id.value();
+    for (const auto& file : vector_files) {
+        PAIMON_ASSIGN_OR_RAISE(std::string current_format, file->FileFormat());
+        if (file->schema_id != first_vector->schema_id || current_format != 
format ||
+            file->write_cols != first_vector->write_cols || file->first_row_id 
!= next_row_id) {
+            return no_match;
+        }
+        next_row_id += file->row_count;
+    }
+    if (next_row_id != main_file->first_row_id.value() + main_file->row_count) 
{
+        return no_match;
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<int32_t> vector_ids, 
file_to_field_ids(first_vector));
+    PAIMON_ASSIGN_OR_RAISE(std::vector<int32_t> main_ids, 
file_to_field_ids(main_file));
+    // An inline VECTOR in a historical main file can have different 
precedence in each range.
+    if (vector_ids.size() != 1 ||
+        std::find(main_ids.begin(), main_ids.end(), vector_ids[0]) != 
main_ids.end()) {
+        return no_match;
+    }
+    return std::vector<std::shared_ptr<FieldBunch>>{
+        std::make_shared<DataBunch>(main_file),
+        std::make_shared<VectorBunch>(std::move(vector_files), 
main_file->row_count)};
+}
+
 Result<std::unique_ptr<BatchReader>> DataEvolutionSplitRead::CreateUnionReader(
     const BinaryRow& partition, const 
std::vector<std::shared_ptr<DataFileMeta>>& need_merge_files,
     const std::optional<std::vector<Range>>& row_ranges,
@@ -802,13 +861,16 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateUnionReader(
     }
     auto file_to_field_ids =
         [&](const std::shared_ptr<DataFileMeta>& file) -> 
Result<std::vector<int32_t>> {
-        if (!file->write_cols) {
+        if (VectorStoreUtils::IsVectorStoreFile(file->file_name) && 
!file->write_cols) {
             return Status::Invalid("VECTOR files must declare their write 
columns");
         }
         auto data_schema = context_->GetTableSchema();
         if (file->schema_id != data_schema->Id()) {
             PAIMON_ASSIGN_OR_RAISE(data_schema, 
schema_manager_->ReadSchema(file->schema_id));
         }
+        if (!file->write_cols) {
+            return DataField::GetAllFieldIds(data_schema->Fields());
+        }
         std::vector<int32_t> ids;
         for (const auto& name : file->write_cols.value()) {
             if (SpecialFields::IsSystemField(name)) {
@@ -819,19 +881,31 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateUnionReader(
         }
         return ids;
     };
-    PAIMON_ASSIGN_OR_RAISE(
-        std::vector<VectorReadRange> ranges,
-        PlanVectorReadRanges(need_merge_files, read_field_ids, 
file_to_field_ids));
-    // Each range creates a separate field-union reader, reopening a main file 
that spans ranges.
-    // TODO(ChaomingZhangCN): Keep one main reader and concatenate VECTOR 
readers for contiguous,
-    // non-overlapping files whose field providers can be merged sequentially.
-    std::vector<LazyConcatBatchReader::Source> sources;
-    auto split_read =
-        std::make_shared<DataEvolutionSplitRead>(path_factory_, context_, 
pool_, executor_);
     std::optional<std::vector<Range>> normalized_ranges;
     if (row_ranges) {
         normalized_ranges = Range::SortAndMergeOverlap(row_ranges.value(), 
/*adjacent=*/true);
     }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<FieldBunch>> 
sequential_bunches,
+                           TryPlanSequentialVectorRead(need_merge_files, 
file_to_field_ids));
+    auto split_read =
+        std::make_shared<DataEvolutionSplitRead>(path_factory_, context_, 
pool_, executor_);
+    if (!sequential_bunches.empty()) {
+        std::vector<LazyConcatBatchReader::Source> sources;
+        sources.push_back(LazyConcatBatchReader::Source{
+            sequential_bunches[0]->Files()[0]->file_name,
+            [split_read, partition, sequential_bunches = 
std::move(sequential_bunches),
+             normalized_ranges, data_file_path_factory, group_dv_factory, 
group_dv,
+             read_schema]() -> Result<std::unique_ptr<BatchReader>> {
+                return split_read->CreateFieldUnionReader(partition, 
sequential_bunches,
+                                                          normalized_ranges, 
data_file_path_factory,
+                                                          group_dv_factory, 
group_dv, read_schema);
+            }});
+        return std::make_unique<LazyConcatBatchReader>(std::move(sources), 
arrow_pool_);
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::vector<VectorReadRange> ranges,
+        PlanVectorReadRanges(need_merge_files, read_field_ids, 
file_to_field_ids));
+    std::vector<LazyConcatBatchReader::Source> sources;
     for (const auto& range : ranges) {
         std::vector<Range> selected = {range.range};
         if (normalized_ranges) {
@@ -884,6 +958,17 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateFieldUnionRea
         SplitFieldBunches(need_merge_files, blob_field_to_field_id,
                           
/*has_row_ranges_selection=*/row_ranges.has_value()));
 
+    return CreateFieldUnionReader(partition, fields_files, row_ranges, 
data_file_path_factory,
+                                  group_dv_factory, group_dv, read_schema);
+}
+
+Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateFieldUnionReader(
+    const BinaryRow& partition, const 
std::vector<std::shared_ptr<FieldBunch>>& fields_files,
+    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 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());
@@ -912,6 +997,10 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateFieldUnionRea
     std::vector<int32_t> reader_offsets(all_read_fields.size(), -1);
     // which field index in the reading row
     std::vector<int32_t> field_offsets(all_read_fields.size(), -1);
+    const bool has_normal_file =
+        std::any_of(fields_files.begin(), fields_files.end(), [](const auto& 
bunch) {
+            return 
DataEvolutionUtils::IsNormalFile(bunch->Files()[0]->file_name);
+        });
 
     for (int32_t file_idx = 0; file_idx < 
static_cast<int32_t>(fields_files.size()); file_idx++) {
         const auto& bunch = fields_files[file_idx];
@@ -932,6 +1021,11 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateFieldUnionRea
             if (reader_offsets[read_field_idx] != -1) {
                 continue;
             }
+            // Java takes sequence numbers from normal files before dedicated 
column files.
+            if (has_normal_file && 
!DataEvolutionUtils::IsNormalFile(first_file->file_name) &&
+                read_field_ids[read_field_idx] == 
SpecialFields::SequenceNumber().Id()) {
+                continue;
+            }
             for (int32_t data_field_id : data_field_ids) {
                 // Check if the read field index matches the file field
                 // index
@@ -950,7 +1044,40 @@ Result<std::unique_ptr<BatchReader>> 
DataEvolutionSplitRead::CreateFieldUnionRea
             // create new FieldMappingReader for read partial fields
             auto file_read_schema = 
DataField::ConvertDataFieldsToArrowSchema(read_fields_in_file);
             auto blob_bunch = std::dynamic_pointer_cast<BlobBunch>(bunch);
-            if (blob_bunch && !blob_bunch->SequentialReadOptimize()) {
+            if (std::dynamic_pointer_cast<VectorBunch>(bunch)) {
+                std::vector<LazyConcatBatchReader::Source> sources;
+                auto split_read = 
std::make_shared<DataEvolutionSplitRead>(path_factory_, context_,
+                                                                           
pool_, executor_);
+                for (const auto& file : bunch->Files()) {
+                    std::optional<std::vector<Range>> selected = row_ranges;
+                    if (row_ranges) {
+                        PAIMON_ASSIGN_OR_RAISE(int64_t first, 
file->NonNullFirstRowId());
+                        selected = Range::And({Range(first, first + 
file->row_count - 1)},
+                                              row_ranges.value());
+                        if (selected->empty()) {
+                            continue;
+                        }
+                    }
+                    sources.push_back(LazyConcatBatchReader::Source{
+                        file->file_name,
+                        [split_read, partition, file, file_read_schema, 
selected,
+                         data_file_path_factory,
+                         group_dv_factory]() -> 
Result<std::unique_ptr<BatchReader>> {
+                            PAIMON_ASSIGN_OR_RAISE(
+                                std::vector<std::unique_ptr<FileBatchReader>> 
readers,
+                                split_read->CreateRawFileReaders(
+                                    partition, {file}, file_read_schema, 
/*predicate=*/nullptr,
+                                    group_dv_factory, selected, 
data_file_path_factory,
+                                    /*extra_format_options=*/{}));
+                            return std::make_unique<ConcatBatchReader>(
+                                
ObjectUtils::MoveVector<std::unique_ptr<BatchReader>>(
+                                    std::move(readers)),
+                                split_read->arrow_pool_);
+                        }});
+                }
+                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
                 PAIMON_ASSIGN_OR_RAISE(
diff --git a/src/paimon/core/operation/data_evolution_split_read.h 
b/src/paimon/core/operation/data_evolution_split_read.h
index b51dd447..e0406495 100644
--- a/src/paimon/core/operation/data_evolution_split_read.h
+++ b/src/paimon/core/operation/data_evolution_split_read.h
@@ -26,6 +26,7 @@
 #include <string>
 #include <unordered_map>
 #include <unordered_set>
+#include <utility>
 #include <vector>
 
 #include "paimon/common/data/blob_view_struct.h"
@@ -123,6 +124,23 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
         std::vector<std::shared_ptr<DataFileMeta>> data_files_;
     };
 
+    /// Compatible VECTOR files tiling one main file's row range without gaps 
or overlaps.
+    class VectorBunch : public FieldBunch {
+     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 {
+            return row_count_;
+        }
+        const std::vector<std::shared_ptr<DataFileMeta>>& Files() const 
override {
+            return files_;
+        }
+
+     private:
+        std::vector<std::shared_ptr<DataFileMeta>> files_;
+        int64_t row_count_;
+    };
+
     /// All blob files of one blob field in a merge group. Unlike data files, 
every file is kept
     /// (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
@@ -227,6 +245,12 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
         const std::function<Result<std::vector<int32_t>>(const 
std::shared_ptr<DataFileMeta>&)>&
             file_to_field_ids);
 
+    /// Empty means the layout needs range planning. Errors resolving 
providers still propagate.
+    static Result<std::vector<std::shared_ptr<FieldBunch>>> 
TryPlanSequentialVectorRead(
+        const std::vector<std::shared_ptr<DataFileMeta>>& files,
+        const std::function<Result<std::vector<int32_t>>(const 
std::shared_ptr<DataFileMeta>&)>&
+            file_to_field_ids);
+
     static 
Result<std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>>>
     SplitFieldBunches(const std::vector<std::shared_ptr<DataFileMeta>>& 
need_merge_files,
                       const std::function<Result<int32_t>(const 
std::shared_ptr<DataFileMeta>&)>&
@@ -278,6 +302,14 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
         const std::optional<GroupDeletionVector>& group_dv,
         const std::shared_ptr<arrow::Schema>& read_schema) const;
 
+    Result<std::unique_ptr<BatchReader>> CreateFieldUnionReader(
+        const BinaryRow& partition, const 
std::vector<std::shared_ptr<FieldBunch>>& fields_files,
+        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 std::shared_ptr<arrow::Schema>& read_schema) const;
+
     /// Builds the row-level fallback reader for a blob bunch spanning 
multiple max sequence
     /// number layers: groups the files by max sequence number, pads uncovered 
row id ranges of
     /// each layer with placeholder gap segments, and resolves each row to the 
newest
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 0b113725..4bd0e0c2 100644
--- a/src/paimon/core/operation/data_evolution_split_read_test.cpp
+++ b/src/paimon/core/operation/data_evolution_split_read_test.cpp
@@ -19,14 +19,21 @@
 #include "paimon/core/operation/data_evolution_split_read.h"
 
 #include <algorithm>
+#include <atomic>
 #include <cstdint>
 #include <map>
 #include <optional>
 #include <utility>
 
+#include "arrow/api.h"
+#include "arrow/c/bridge.h"
+#include "arrow/ipc/json_simple.h"
 #include "gtest/gtest.h"
 #include "paimon/common/data/binary_row.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/bitmap64_deletion_vector.h"
 #include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
@@ -39,15 +46,42 @@
 #include "paimon/core/table/source/data_split_impl.h"
 #include "paimon/defs.h"
 #include "paimon/executor.h"
+#include "paimon/format/file_format_factory.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/writer_builder.h"
 #include "paimon/fs/local/local_file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/read_context.h"
 #include "paimon/status.h"
+#include "paimon/testing/utils/read_result_collector.h"
 #include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
+namespace {
+class CountingLocalFileSystem : public LocalFileSystem {
+ public:
+    explicit CountingLocalFileSystem(std::string counted_path)
+        : counted_path_(std::move(counted_path)) {}
+
+    Result<std::unique_ptr<InputStream>> Open(const std::string& path) const 
override {
+        if (path == counted_path_) {
+            ++open_count_;
+        }
+        return LocalFileSystem::Open(path);
+    }
+
+    int32_t OpenCount() const {
+        return open_count_.load();
+    }
+
+ private:
+    std::string counted_path_;
+    mutable std::atomic<int32_t> open_count_{0};
+};
+}  // namespace
+
 class DataEvolutionSplitReadTest : public ::testing::Test {
  public:
     std::shared_ptr<DataFileMeta> CreateDataFileMeta(
@@ -751,6 +785,203 @@ TEST_F(DataEvolutionSplitReadTest, 
TestPlanIndependentlyRolledVectorReadRanges)
     ASSERT_EQ(vector1->row_count, 4);
 }
 
+TEST_F(DataEvolutionSplitReadTest, TestPlanSequentialVectorRead) {
+    auto main = CreateNormalFile("main.parquet", 100, 10, 0);
+    auto vector0 = CreateDataFileMeta("vector0.vector.vortex", 100, 3, 1);
+    auto vector1 = CreateDataFileMeta("vector1.vector.vortex", 103, 4, 2);
+    auto vector2 = CreateDataFileMeta("vector2.vector.vortex", 107, 3, 3);
+    for (const auto& file : {vector0, vector1, vector2}) {
+        file->write_cols = std::vector<std::string>{"embedding"};
+    }
+    auto field_ids =
+        [main](const std::shared_ptr<DataFileMeta>& file) -> 
Result<std::vector<int32_t>> {
+        return file == main ? std::vector<int32_t>{0, 2} : 
std::vector<int32_t>{1};
+    };
+    
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>>
 bunches,
+                         DataEvolutionSplitRead::TryPlanSequentialVectorRead(
+                             {vector2, main, vector0, vector1}, field_ids));
+    ASSERT_EQ(bunches.size(), 2);
+    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_EQ(vector1->first_row_id, 103);
+    ASSERT_EQ(vector1->row_count, 4);
+}
+
+TEST_F(DataEvolutionSplitReadTest, 
TestSequentialVectorReadFallsBackForUnsupportedLayouts) {
+    auto main = CreateNormalFile("main.parquet", 100, 10, 0);
+    auto vector0 = CreateDataFileMeta("vector0.vector.vortex", 100, 4, 1);
+    auto vector1 = CreateDataFileMeta("vector1.vector.vortex", 104, 6, 1);
+    vector0->write_cols = vector1->write_cols = 
std::vector<std::string>{"embedding"};
+    auto field_ids =
+        [main](const std::shared_ptr<DataFileMeta>& file) -> 
Result<std::vector<int32_t>> {
+        return file == main ? std::vector<int32_t>{0, 2} : 
std::vector<int32_t>{1};
+    };
+    auto check_fallback = [&](const 
std::vector<std::shared_ptr<DataFileMeta>>& files) {
+        ASSERT_OK_AND_ASSIGN(
+            std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>> 
bunches,
+            DataEvolutionSplitRead::TryPlanSequentialVectorRead(files, 
field_ids));
+        ASSERT_TRUE(bunches.empty());
+    };
+    check_fallback({vector0, vector1});
+    check_fallback({main, main, vector0, vector1});
+    check_fallback({main, vector0, vector1, CreateBlobFile("payload", 100, 10, 
1, {{"payload"}})});
+    check_fallback({main, vector1});
+    vector1->first_row_id = 103;
+    check_fallback({main, vector0, vector1});
+    vector1->first_row_id = 105;
+    check_fallback({main, vector0, vector1});
+    vector1->first_row_id = 104;
+    vector1->row_count = 7;
+    check_fallback({main, vector0, vector1});
+    vector1->row_count = 6;
+    vector1->schema_id = 1;
+    check_fallback({main, vector0, vector1});
+    vector1->schema_id = 0;
+    vector1->file_name = "vector1.vector.parquet";
+    check_fallback({main, vector0, vector1});
+    vector1->file_name = "vector1.vector.vortex";
+    vector1->write_cols = std::vector<std::string>{"another_embedding"};
+    check_fallback({main, vector0, vector1});
+    vector1->write_cols = vector0->write_cols =
+        std::vector<std::string>{"embedding", "another_embedding"};
+    check_fallback({main, vector0, vector1});
+
+    vector1->write_cols = vector0->write_cols = 
std::vector<std::string>{"embedding"};
+    auto inline_field_ids =
+        [main](const std::shared_ptr<DataFileMeta>& file) -> 
Result<std::vector<int32_t>> {
+        return file == main ? std::vector<int32_t>{0, 1, 2} : 
std::vector<int32_t>{1};
+    };
+    
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<DataEvolutionSplitRead::FieldBunch>>
 bunches,
+                         DataEvolutionSplitRead::TryPlanSequentialVectorRead(
+                             {main, vector0, vector1}, inline_field_ids));
+    ASSERT_TRUE(bunches.empty());
+}
+
+TEST_F(DataEvolutionSplitReadTest, 
TestSequentialVectorReadPropagatesMetadataErrors) {
+    auto main = CreateNormalFile("main.parquet", 100, 10, 0);
+    auto vector = CreateDataFileMeta("vector.vector.vortex", 100, 10, 1);
+    vector->write_cols = std::vector<std::string>{"embedding"};
+    auto failed_schema = [](const std::shared_ptr<DataFileMeta>&) -> 
Result<std::vector<int32_t>> {
+        return Status::IOError("schema unavailable");
+    };
+    auto result =
+        DataEvolutionSplitRead::TryPlanSequentialVectorRead({main, vector}, 
failed_schema);
+    ASSERT_NOK_WITH_MSG(result, "schema unavailable");
+    ASSERT_TRUE(result.status().IsIOError());
+}
+
+TEST_F(DataEvolutionSplitReadTest, TestSequentialVectorReadReusesMainReader) {
+    auto dir = UniqueTestDirectory::Create("local");
+    auto fs = std::make_shared<LocalFileSystem>();
+    auto schema = DataField::ConvertDataFieldsToArrowSchema(
+        {DataField(0, arrow::field("id", arrow::int32())),
+         DataField(1, arrow::field("embedding", 
arrow::fixed_size_list(arrow::float32(), 2)))});
+    const std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
"parquet"},
+                                                        
{Options::ROW_TRACKING_ENABLED, "true"},
+                                                        
{Options::DATA_EVOLUTION_ENABLED, "true"},
+                                                        
{Options::READ_BATCH_SIZE, "2"}};
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+                         TableSchema::Create(0, schema, {}, {}, options));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<FileStorePathFactory> path_factory,
+        FileStorePathFactory::Create(dir->Str(), schema, {}, "default", 
"parquet", "data-",
+                                     /*legacy_partition_name_enabled=*/true, 
{}, std::nullopt,
+                                     /*index_file_in_data_file_dir=*/false, 
GetDefaultPool()));
+    ASSERT_OK_AND_ASSIGN(std::string bucket_path,
+                         path_factory->BucketPath(BinaryRow::EmptyRow(), 0));
+    ASSERT_OK(fs->Mkdirs(bucket_path));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileFormat> format,
+                         FileFormatFactory::Get("parquet", options));
+    auto write_file = [&](const std::string& file_name, int64_t first_row_id,
+                          const std::shared_ptr<arrow::Array>& array,
+                          int64_t sequence) -> 
Result<std::shared_ptr<DataFileMeta>> {
+        ArrowSchema c_schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportType(*array->type(), 
&c_schema));
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<WriterBuilder> builder,
+                               format->CreateWriterBuilder(&c_schema, 2));
+        const std::string path = PathUtil::JoinPath(bucket_path, file_name);
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<OutputStream> out, 
fs->Create(path, false));
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatWriter> writer, 
builder->Build(out, "zstd"));
+        ArrowArray c_array;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*array, &c_array));
+        PAIMON_RETURN_NOT_OK(writer->AddBatch(&c_array));
+        PAIMON_RETURN_NOT_OK(writer->Finish());
+        PAIMON_RETURN_NOT_OK(out->Close());
+        PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, 
fs->GetFileStatus(path));
+        std::vector<std::string> write_cols;
+        for (const auto& field : array->type()->fields()) {
+            write_cols.push_back(field->name());
+        }
+        return DataFileMeta::ForAppend(file_name, file_status.GetLen(), 
array->length(),
+                                       SimpleStats::EmptyStats(), sequence, 
sequence,
+                                       /*schema_id=*/0, FileSource::Append(), 
std::nullopt,
+                                       std::nullopt, first_row_id, write_cols);
+    };
+    auto main_data = 
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({schema->field(0)}),
+                                                               "[[0], [1], 
[2], [3], [4], [5]]")
+                         .ValueOrDie();
+    auto vector_type = arrow::struct_(
+        {DataField(1, arrow::field("embedding", 
arrow::list(arrow::float32()))).ArrowField()});
+    auto vector0_data =
+        arrow::ipc::internal::json::ArrayFromJSON(vector_type, "[[[0, 1]], 
[null], [[2, 3]]]")
+            .ValueOrDie();
+    auto vector1_data =
+        arrow::ipc::internal::json::ArrayFromJSON(vector_type, "[[[3, 4]], 
[[4, 5]], [[5, 6]]]")
+            .ValueOrDie();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<DataFileMeta> main,
+                         write_file("main.parquet", 0, main_data, 3));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<DataFileMeta> vector0,
+                         write_file("vector0.vector.parquet", 0, vector0_data, 
4));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<DataFileMeta> vector1,
+                         write_file("vector1.vector.parquet", 3, vector1_data, 
5));
+    DataSplitImpl::Builder split_builder(BinaryRow::EmptyRow(), 0, bucket_path,
+                                         {vector1, main, vector0});
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<DataSplitImpl> split,
+                         split_builder.RawConvertible(false).Build());
+    auto expected_type = 
arrow::struct_({SpecialFields::ValueKind().ArrowField(), schema->field(0),
+                                         schema->field(1), 
SpecialFields::RowId().ArrowField(),
+                                         
SpecialFields::SequenceNumber().ArrowField()});
+    auto expected = checked_pointer_cast<arrow::StructArray>(
+        arrow::ipc::internal::json::ArrayFromJSON(expected_type, R"([
+            [0, 0, [0, 1], 0, 3], [0, 1, null, 1, 3], [0, 2, [2, 3], 2, 3],
+            [0, 3, [3, 4], 3, 3], [0, 4, [4, 5], 4, 3], [0, 5, [5, 6], 5, 3]
+        ])")
+            .ValueOrDie());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Executor> executor, 
CreateDefaultExecutor(2));
+    for (const std::vector<std::string>& projection :
+         {std::vector<std::string>{"id", "embedding"},
+          std::vector<std::string>{"id", "embedding", "_ROW_ID", 
"_SEQUENCE_NUMBER"},
+          std::vector<std::string>{"id", "_ROW_ID", "_SEQUENCE_NUMBER"},
+          std::vector<std::string>{"_ROW_ID", "_SEQUENCE_NUMBER"}}) {
+        auto counting_fs = std::make_shared<CountingLocalFileSystem>(
+            PathUtil::JoinPath(bucket_path, main->file_name));
+        ReadContextBuilder builder(dir->Str());
+        
builder.WithFileSystem(counting_fs).EnablePrefetch(false).SetReadFieldNames(projection);
+        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));
+        auto split_read = 
std::make_unique<DataEvolutionSplitRead>(path_factory, context,
+                                                                   
GetDefaultPool(), executor);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<BatchReader> reader, 
split_read->CreateReader(split));
+        split_read.reset();
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result,
+                             
ReadResultCollector::CollectResult(std::move(reader)));
+        ASSERT_NE(result, nullptr);
+        arrow::ArrayVector columns = {expected->field(0)};
+        std::vector<std::string> names = {"_VALUE_KIND"};
+        for (const auto& name : projection) {
+            columns.push_back(expected->GetFieldByName(name));
+            names.push_back(name);
+        }
+        auto projected = arrow::StructArray::Make(columns, names).ValueOrDie();
+        ASSERT_TRUE(result->Equals(arrow::ChunkedArray(projected))) << 
result->ToString();
+        ASSERT_EQ(counting_fs->OpenCount(), 1);
+    }
+}
+
 TEST_F(DataEvolutionSplitReadTest, 
TestPlanVectorReadRangesPreservesOldPrefixAndSuffix) {
     auto main = CreateNormalFile("main.parquet", 0, 10, 0);
     auto old_vector = CreateDataFileMeta("old.vector.vortex", 0, 10, 1);
diff --git a/test/inte/data_evolution_table_test.cpp 
b/test/inte/data_evolution_table_test.cpp
index 2924e541..395de88d 100644
--- a/test/inte/data_evolution_table_test.cpp
+++ b/test/inte/data_evolution_table_test.cpp
@@ -18,6 +18,7 @@
  */
 #include <algorithm>
 #include <iterator>
+#include <set>
 #include <tuple>
 
 #include "arrow/type.h"
@@ -2027,6 +2028,77 @@ TEST_P(DataEvolutionTableTest, 
TestReadJavaIndependentlyRolledVectorStore) {
         ASSERT_TRUE(result->Equals(arrow::ChunkedArray(expected_rows)))
             << "actual=" << result->ToString() << "\nexpected=" << 
expected_rows->ToString();
     }
+
+    // Like Java, the normal file supplies sequence numbers even when VECTOR 
files are newer.
+    auto sequence_main = std::make_shared<DataFileMeta>(*main_file);
+    sequence_main->AssignSequenceNumber(3, 3);
+    std::vector<std::shared_ptr<DataFileMeta>> sequence_files = 
{sequence_main};
+    int64_t sequence = 2;
+    for (const auto& [first, file] : vector_files) {
+        auto sequence_vector = std::make_shared<DataFileMeta>(*file);
+        sequence_vector->AssignSequenceNumber(sequence, sequence);
+        sequence_files.push_back(sequence_vector);
+        ++sequence;
+    }
+    auto sequence_tail = sequence_files.back();
+    DataSplitImpl::Builder sequence_builder(original_split->Partition(), 
original_split->Bucket(),
+                                            original_split->BucketPath(),
+                                            std::move(sequence_files));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<DataSplitImpl> sequence_split,
+                         sequence_builder.RawConvertible(false).Build());
+    auto tracking = PrepareBulkData(
+        kRowCount, [](int32_t id) { return fmt::format("0, {}, 3", id); },
+        {SpecialFields::ValueKind().ArrowField(), 
SpecialFields::RowId().ArrowField(),
+         SpecialFields::SequenceNumber().ArrowField()});
+    for (bool sequential : {true, false}) {
+        SCOPED_TRACE(sequential);
+        std::shared_ptr<Split> split = sequence_split;
+        std::shared_ptr<arrow::Array> vectors = expected->field(1);
+        if (!sequential) {
+            // A newer VECTOR covering only the tail forces the range-reader 
fallback.
+            const auto& tail = sequence_tail;
+            ASSERT_GT(tail->max_sequence_number, 
sequence_main->max_sequence_number);
+            DataSplitImpl::Builder tail_builder(
+                original_split->Partition(), original_split->Bucket(), 
original_split->BucketPath(),
+                std::vector<std::shared_ptr<DataFileMeta>>{sequence_main, 
tail});
+            ASSERT_OK_AND_ASSIGN(split, 
tail_builder.RawConvertible(false).Build());
+            const int64_t first = tail->first_row_id.value();
+            auto missing = arrow::MakeArrayOfNull(vector_type, 
first).ValueOrDie();
+            vectors = arrow::Concatenate({missing, 
vectors->Slice(first)}).ValueOrDie();
+        }
+        auto sequence_expected =
+            arrow::StructArray::Make(
+                {tracking->field(0), expected->field(0), vectors, 
tracking->field(1),
+                 tracking->field(2)},
+                {"_VALUE_KIND", "id", "embedding", "_ROW_ID", 
"_SEQUENCE_NUMBER"})
+                .ValueOrDie();
+        for (const std::vector<std::string>& projection :
+             {std::vector<std::string>{"id", "embedding", "_ROW_ID", 
"_SEQUENCE_NUMBER"},
+              std::vector<std::string>{"id", "_ROW_ID", "_SEQUENCE_NUMBER"},
+              std::vector<std::string>{"_ROW_ID", "_SEQUENCE_NUMBER"}}) {
+            ReadContextBuilder sequence_read_builder(table_path);
+            sequence_read_builder.SetReadFieldNames(projection);
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> sequence_context,
+                                 sequence_read_builder.Finish());
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableRead> sequence_read,
+                                 
TableRead::Create(std::move(sequence_context)));
+            ASSERT_OK_AND_ASSIGN(std::unique_ptr<BatchReader> sequence_reader,
+                                 sequence_read->CreateReader(split));
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> 
sequence_result,
+                                 
ReadResultCollector::CollectResult(std::move(sequence_reader)));
+            ASSERT_NE(sequence_result, nullptr);
+            arrow::ArrayVector projected_arrays = {tracking->field(0)};
+            std::vector<std::string> projected_names = {"_VALUE_KIND"};
+            for (const auto& name : projection) {
+                
projected_arrays.push_back(sequence_expected->GetFieldByName(name));
+                projected_names.push_back(name);
+            }
+            auto projected_expected =
+                arrow::StructArray::Make(projected_arrays, 
projected_names).ValueOrDie();
+            
ASSERT_TRUE(sequence_result->Equals(arrow::ChunkedArray(projected_expected)))
+                << sequence_result->ToString();
+        }
+    }
 }
 
 #endif
@@ -2137,12 +2209,19 @@ TEST_P(DedicatedVectorTableTest, 
TestIndependentVectorRolling) {
                                  arrow::field("embedding", vector_type),
                                  arrow::field("tag", arrow::utf8())};
     const std::map<std::string, std::string> options = {
-        {Options::FILE_FORMAT, "parquet"},       {Options::VECTOR_FILE_FORMAT, 
FileFormat()},
-        {Options::FILE_SYSTEM, "local"},         {Options::BUCKET, "-1"},
-        {Options::ROW_TRACKING_ENABLED, "true"}, 
{Options::DATA_EVOLUTION_ENABLED, "true"},
-        {Options::WRITE_ONLY, "true"},           {Options::TARGET_FILE_SIZE, 
"128 mb"},
-        {Options::TARGET_FILE_ROW_NUM, "2500"},  
{Options::VECTOR_TARGET_FILE_SIZE, "1 b"},
-        {Options::WRITE_BATCH_SIZE, "100"},      {Options::READ_BATCH_SIZE, 
"127"}};
+        {Options::FILE_FORMAT, "parquet"},
+        {Options::VECTOR_FILE_FORMAT, FileFormat()},
+        {Options::FILE_SYSTEM, "local"},
+        {Options::BUCKET, "-1"},
+        {Options::ROW_TRACKING_ENABLED, "true"},
+        {Options::DATA_EVOLUTION_ENABLED, "true"},
+        {Options::WRITE_ONLY, "true"},
+        {Options::TARGET_FILE_SIZE, "128 mb"},
+        {Options::TARGET_FILE_ROW_NUM, "2500"},
+        {Options::VECTOR_TARGET_FILE_SIZE, "1 b"},
+        {Options::WRITE_BATCH_SIZE, "100"},
+        {Options::READ_BATCH_SIZE, "127"},
+        {Options::DELETION_VECTORS_ENABLED, "true"}};
     CreateTable(fields, /*partition_keys=*/{}, options);
     const std::string table_path = PathUtil::JoinPath(dir_->Str(), 
"foo.db/bar");
     auto input = PrepareBulkData(
@@ -2234,6 +2313,48 @@ TEST_P(DedicatedVectorTableTest, 
TestIndependentVectorRolling) {
                               /*predicate=*/nullptr, /*row_ranges=*/{range}));
     }
 
+    // Delete across main/VECTOR boundaries, including a whole VECTOR file and 
the last row.
+    std::set<int64_t> deleted_ids = {0, 999, 1000, 2499, 2500, 2999, 3000, 
4999, 6000};
+    if (FileFormat() != "lance") {
+        const auto& deleted_file = std::next(vector_files.begin())->second;
+        for (int64_t i = 0; i < deleted_file->row_count; ++i) {
+            deleted_ids.insert(deleted_file->first_row_id.value() + i);
+        }
+    }
+    std::map<std::string, std::vector<int64_t>> deleted_positions;
+    for (const auto& [first, file] : main_files) {
+        for (int64_t id : deleted_ids) {
+            if (id >= first && id < first + file->row_count) {
+                deleted_positions[file->file_name].push_back(id - first);
+            }
+        }
+    }
+    ASSERT_OK(CommitDeletionVectors(table_path, messages[0], 
deleted_positions));
+    for (const std::vector<Range>& selection :
+         {std::vector<Range>{Range(0, 6000)},
+          std::vector<Range>{Range(998, 1002), Range(2498, 2502), Range(5998, 
6000)}}) {
+        arrow::ArrayVector surviving_rows;
+        for (const auto& range : selection) {
+            int64_t next = range.from;
+            for (int64_t id : deleted_ids) {
+                if (id < next || id > range.to) {
+                    continue;
+                }
+                if (next < id) {
+                    surviving_rows.push_back(input->Slice(next, id - next));
+                }
+                next = id + 1;
+            }
+            if (next <= range.to) {
+                surviving_rows.push_back(input->Slice(next, range.to - next + 
1));
+            }
+        }
+        auto expected = checked_pointer_cast<arrow::StructArray>(
+            arrow::Concatenate(surviving_rows).ValueOrDie());
+        ASSERT_OK(ScanAndRead(table_path, {"id", "embedding", "tag"}, expected,
+                              /*predicate=*/nullptr, selection));
+    }
+
     ASSERT_OK_AND_ASSIGN(messages, WriteArray(table_path, {}, input->Slice(0, 
2)));
     ASSERT_OK(Commit(table_path, messages));
     auto appended = checked_pointer_cast<arrow::StructArray>(input->Slice(0, 
2));

Reply via email to