This is an automated email from the ASF dual-hosted git repository.

JingsongLi 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 337fccc  feat(btree): add btree global index reader/writer/factory 
(#89)
337fccc is described below

commit 337fccc22b0fa1217321da82b03793749ddbdc4c
Author: lszskye <[email protected]>
AuthorDate: Mon Jun 22 02:32:45 2026 -0700

    feat(btree): add btree global index reader/writer/factory (#89)
---
 .../btree/btree_compatibility_test.cpp             | 931 +++++++++++++++++++++
 .../btree/btree_global_index_factory.cpp           |  36 +
 .../btree/btree_global_index_factory.h             |  42 +
 .../btree/btree_global_index_reader.cpp            | 450 ++++++++++
 .../global_index/btree/btree_global_index_reader.h | 122 +++
 .../btree/btree_global_index_writer.cpp            | 209 +++++
 .../global_index/btree/btree_global_index_writer.h | 109 +++
 .../global_index/btree/btree_global_indexer.cpp    | 125 +++
 .../global_index/btree/btree_global_indexer.h      |  79 ++
 .../btree/lazy_filtered_btree_reader.cpp           | 297 +++++++
 .../btree/lazy_filtered_btree_reader.h             | 107 +++
 .../btree/lazy_filtered_btree_reader_test.cpp      | 466 +++++++++++
 12 files changed, 2973 insertions(+)

diff --git a/src/paimon/common/global_index/btree/btree_compatibility_test.cpp 
b/src/paimon/common/global_index/btree/btree_compatibility_test.cpp
new file mode 100644
index 0000000..e5e95e9
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_compatibility_test.cpp
@@ -0,0 +1,931 @@
+/*
+ * 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 <algorithm>
+#include <set>
+#include <sstream>
+#include <string>
+#include <vector>
+
+#include "arrow/c/bridge.h"
+#include "gtest/gtest.h"
+#include "paimon/common/global_index/btree/btree_global_indexer.h"
+#include "paimon/common/global_index/btree/btree_index_meta.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/fs/file_system_factory.h"
+#include "paimon/global_index/io/global_index_file_reader.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/testing/utils/testharness.h"
+namespace paimon::test {
+class BTreeCompatibilityTest : public ::testing::Test {
+ public:
+    void SetUp() override {
+        pool_ = GetDefaultPool();
+        ASSERT_OK_AND_ASSIGN(fs_, FileSystemFactory::Get("local", "/", {}));
+        data_dir_ = GetDataDir() + 
"/global_index/btree/btree_compatibility_data";
+    }
+
+    struct CsvRecord {
+        int64_t row_id;
+        std::string key;  // "NULL" if is_null
+        bool is_null;
+    };
+
+    std::string ReadFileAsString(const std::string& path) const {
+        EXPECT_OK_AND_ASSIGN(auto input, fs_->Open(path));
+        EXPECT_OK_AND_ASSIGN(auto length, input->Length());
+        std::string buffer(static_cast<size_t>(length), '\0');
+        EXPECT_OK_AND_ASSIGN([[maybe_unused]] auto bytes_read,
+                             input->Read(buffer.data(), 
static_cast<uint32_t>(length)));
+        return buffer;
+    }
+
+    // Parse a CSV file into a vector of CsvRecord
+    std::vector<CsvRecord> ParseCsvFile(const std::string& csv_path) const {
+        std::vector<CsvRecord> records;
+        std::string content = ReadFileAsString(csv_path);
+        if (content.empty()) {
+            return records;
+        }
+
+        std::istringstream iss(content);
+        std::string line;
+        // Skip header line: "row_id,key,is_null"
+        std::getline(iss, line);
+
+        while (std::getline(iss, line)) {
+            if (line.empty()) {
+                continue;
+            }
+            std::istringstream ss(line);
+            std::string row_id_str, key_str, is_null_str;
+            std::getline(ss, row_id_str, ',');
+            std::getline(ss, key_str, ',');
+            std::getline(ss, is_null_str, ',');
+
+            CsvRecord rec;
+            rec.row_id = std::stoll(row_id_str);
+            rec.key = key_str;
+            rec.is_null = (is_null_str == "true");
+            records.push_back(rec);
+        }
+        return records;
+    }
+
+    std::set<int64_t> CollectRowIds(const std::shared_ptr<GlobalIndexResult>& 
result) const {
+        std::set<int64_t> ids;
+        EXPECT_OK_AND_ASSIGN(auto iter, result->CreateIterator());
+        while (iter->HasNext()) {
+            ids.insert(iter->Next());
+        }
+        return ids;
+    }
+
+    std::set<int64_t> CollectMatchingRows(const std::vector<CsvRecord>& 
records,
+                                          std::function<bool(const 
CsvRecord&)> predicate) const {
+        std::set<int64_t> ids;
+        for (const auto& rec : records) {
+            if (predicate(rec)) {
+                ids.insert(rec.row_id);
+            }
+        }
+        return ids;
+    }
+
+    Result<std::shared_ptr<GlobalIndexReader>> CreateReaderFromFiles(
+        const std::string& bin_path, const std::string& meta_path,
+        const std::shared_ptr<arrow::DataType>& arrow_type) const {
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+        PAIMON_ASSIGN_OR_RAISE(auto file_status, fs_->GetFileStatus(bin_path));
+        auto file_size = static_cast<int64_t>(file_status->GetLen());
+
+        GlobalIndexIOMeta io_meta(bin_path, file_size, meta_bytes);
+        std::vector<GlobalIndexIOMeta> metas = {io_meta};
+
+        auto schema = arrow::schema({arrow::field("testField", arrow_type)});
+        auto c_schema = std::make_unique<ArrowSchema>();
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema, 
c_schema.get()));
+
+        auto file_reader = std::make_shared<LocalGlobalIndexFileReader>(fs_);
+        std::map<std::string, std::string> options;
+        PAIMON_ASSIGN_OR_RAISE(auto indexer, 
BTreeGlobalIndexer::Create(options));
+        return indexer->CreateReader(c_schema.get(), file_reader, metas, 
pool_);
+    }
+
+    void RunIntQueries(const std::shared_ptr<GlobalIndexReader>& reader,
+                       const std::vector<CsvRecord>& records) const {
+        // VisitIsNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitIsNotNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
!r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitEqual for the first non-null key
+        for (const auto& rec : records) {
+            if (!rec.is_null) {
+                int32_t key_val = std::stoi(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stoi(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for the last non-null key
+        for (auto it = records.rbegin(); it != records.rend(); ++it) {
+            if (!it->is_null) {
+                int32_t key_val = std::stoi(it->key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stoi(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for a non-existent key
+        {
+            Literal literal(static_cast<int32_t>(-999));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            ASSERT_TRUE(actual_ids.empty());
+        }
+
+        int32_t mid_key = -1;
+        {
+            std::vector<int32_t> non_null_keys;
+            for (const auto& rec : records) {
+                if (!rec.is_null) {
+                    non_null_keys.push_back(std::stoi(rec.key));
+                }
+            }
+            ASSERT_FALSE(non_null_keys.empty());
+            std::sort(non_null_keys.begin(), non_null_keys.end());
+            mid_key = non_null_keys[non_null_keys.size() / 2];
+        }
+        // VisitLessThan for a mid-range key
+        {
+            Literal literal(mid_key);
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [mid_key](const 
CsvRecord& r) {
+                return !r.is_null && std::stoi(r.key) < mid_key;
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitGreaterOrEqual for a mid-range key
+        {
+            Literal literal(mid_key);
+            ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitGreaterOrEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [mid_key](const 
CsvRecord& r) {
+                return !r.is_null && std::stoi(r.key) >= mid_key;
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitIn for multiple keys
+        {
+            std::set<int32_t> unique_keys;
+            for (const auto& rec : records) {
+                if (!rec.is_null) {
+                    unique_keys.insert(std::stoi(rec.key));
+                }
+            }
+            ASSERT_GE(unique_keys.size(), 3);
+            auto it = unique_keys.begin();
+            int32_t k1 = *it++;
+            int32_t k2 = *it++;
+            int32_t k3 = *it++;
+            std::vector<Literal> in_literals = {Literal(k1), Literal(k2), 
Literal(k3)};
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(in_literals));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [k1, k2, 
k3](const CsvRecord& r) {
+                if (r.is_null) {
+                    return false;
+                }
+                int32_t v = std::stoi(r.key);
+                return v == k1 || v == k2 || v == k3;
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitNotEqual for the first non-null key
+        for (const auto& rec : records) {
+            if (!rec.is_null) {
+                int32_t key_val = std::stoi(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitNotEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stoi(r.key) != key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+    }
+
+    // Run string-type queries against a reader with CSV records as ground 
truth
+    void RunStringQueries(const std::shared_ptr<GlobalIndexReader>& reader,
+                          const std::vector<CsvRecord>& records) const {
+        // VisitIsNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitIsNotNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
!r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitEqual for the first non-null key
+        for (const auto& rec : records) {
+            if (!rec.is_null) {
+                Literal literal(FieldType::STRING, rec.key.c_str(),
+                                static_cast<int32_t>(rec.key.size()));
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(
+                    records, [&rec](const CsvRecord& r) { return !r.is_null && 
r.key == rec.key; });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for the last non-null key
+        for (auto it = records.rbegin(); it != records.rend(); ++it) {
+            if (!it->is_null) {
+                Literal literal(FieldType::STRING, it->key.c_str(),
+                                static_cast<int32_t>(it->key.size()));
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(
+                    records, [&it](const CsvRecord& r) { return !r.is_null && 
r.key == it->key; });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for a non-existent key
+        {
+            std::string non_existent = "zzz_non_existent_key";
+            Literal literal(FieldType::STRING, non_existent.c_str(),
+                            static_cast<int32_t>(non_existent.size()));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            ASSERT_TRUE(actual_ids.empty());
+        }
+
+        std::string mid_key;
+        {
+            std::vector<std::string> non_null_keys;
+            for (const auto& rec : records) {
+                if (!rec.is_null) {
+                    non_null_keys.push_back(rec.key);
+                }
+            }
+            ASSERT_FALSE(non_null_keys.empty());
+            std::sort(non_null_keys.begin(), non_null_keys.end());
+            mid_key = non_null_keys[non_null_keys.size() / 2];
+        }
+        // VisitLessThan for a mid-range key
+        {
+            Literal literal(FieldType::STRING, mid_key.c_str(),
+                            static_cast<int32_t>(mid_key.size()));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [&mid_key](const CsvRecord& r) { return !r.is_null && 
r.key < mid_key; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitGreaterOrEqual for a mid-range key
+        {
+            Literal literal(FieldType::STRING, mid_key.c_str(),
+                            static_cast<int32_t>(mid_key.size()));
+            ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitGreaterOrEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [&mid_key](const CsvRecord& r) { return !r.is_null && 
r.key >= mid_key; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitStartsWith
+        {
+            std::string prefix_str = "test_000";
+            Literal literal(FieldType::STRING, prefix_str.c_str(),
+                            static_cast<int32_t>(prefix_str.size()));
+            ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitStartsWith(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, 
[&prefix_str](const CsvRecord& r) {
+                return !r.is_null && StringUtils::StartsWith(r.key, 
prefix_str);
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+    }
+
+    // Run float-type queries against a reader with CSV records as ground truth
+    void RunFloatQueries(const std::shared_ptr<GlobalIndexReader>& reader,
+                         const std::vector<CsvRecord>& records) const {
+        // VisitIsNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitIsNotNull
+        {
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids =
+                CollectMatchingRows(records, [](const CsvRecord& r) { return 
!r.is_null; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+        // check special value
+        {
+            float value = -INFINITY;
+            Literal literal(value);
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [](const CsvRecord& r) { return !r.is_null && r.key 
== "-infinity"; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+        {
+            float value = INFINITY;
+            Literal literal(value);
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [](const CsvRecord& r) { return !r.is_null && r.key 
== "+infinity"; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+        {
+            Literal literal(static_cast<float>(std::nan("")));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [](const CsvRecord& r) { return !r.is_null && r.key 
== "NaN"; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+        {
+            Literal literal(static_cast<float>(-0.00f));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [](const CsvRecord& r) { return !r.is_null && r.key 
== "-0.00f"; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+        {
+            Literal literal(static_cast<float>(0.00f));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(
+                records, [](const CsvRecord& r) { return !r.is_null && r.key 
== "0.00f"; });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitEqual for the first non-null key
+        for (const auto& rec : records) {
+            if (!rec.is_null && rec.key != "-infinity") {
+                float key_val = std::stof(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stof(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for the last non-null key
+        for (auto it = records.rbegin(); it != records.rend(); ++it) {
+            if (!it->is_null && it->key != "+infinity" && it->key != "NaN") {
+                float key_val = std::stof(it->key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stof(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+
+        // VisitEqual for a non-existent key
+        {
+            Literal literal(static_cast<float>(-99.99));
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            ASSERT_TRUE(actual_ids.empty());
+        }
+
+        float mid_key = -1;
+        {
+            std::vector<float> non_null_keys;
+            for (const auto& rec : records) {
+                if (!rec.is_null) {
+                    non_null_keys.push_back(std::stof(rec.key));
+                }
+            }
+            ASSERT_FALSE(non_null_keys.empty());
+            mid_key = non_null_keys[non_null_keys.size() / 2];
+        }
+        // VisitLessThan for a mid-range key
+        {
+            Literal literal(mid_key);
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [mid_key](const 
CsvRecord& r) {
+                return !r.is_null && std::stof(r.key) < mid_key;
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitGreaterOrEqual for a mid-range key
+        {
+            Literal literal(mid_key);
+            ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitGreaterOrEqual(literal));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [mid_key](const 
CsvRecord& r) {
+                return !r.is_null && (std::stof(r.key) >= mid_key || r.key == 
"NaN");
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitIn for multiple keys
+        {
+            float k1 = -9.27f;
+            float k2 = 62.91f;
+            float k3 = 108.17f;
+            std::vector<Literal> in_literals = {Literal(k1), Literal(k2), 
Literal(k3)};
+            ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(in_literals));
+            auto actual_ids = CollectRowIds(result);
+            auto expected_ids = CollectMatchingRows(records, [k1, k2, 
k3](const CsvRecord& r) {
+                if (r.is_null) {
+                    return false;
+                }
+                float v = std::stof(r.key);
+                return v == k1 || v == k2 || v == k3;
+            });
+            ASSERT_EQ(actual_ids, expected_ids);
+        }
+
+        // VisitNotEqual for the first non-null key
+        for (const auto& rec : records) {
+            if (!rec.is_null) {
+                float key_val = std::stof(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, 
reader->VisitNotEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(
+                    records, [rec](const CsvRecord& r) { return !r.is_null && 
r.key != rec.key; });
+                ASSERT_EQ(actual_ids, expected_ids);
+                break;
+            }
+        }
+    }
+
+    class LocalGlobalIndexFileReader : public GlobalIndexFileReader {
+     public:
+        explicit LocalGlobalIndexFileReader(const std::shared_ptr<FileSystem>& 
fs) : fs_(fs) {}
+
+        Result<std::unique_ptr<InputStream>> GetInputStream(
+            const std::string& file_path) const override {
+            return fs_->Open(file_path);
+        }
+
+     private:
+        std::shared_ptr<FileSystem> fs_;
+    };
+
+ private:
+    std::shared_ptr<MemoryPool> pool_;
+    std::shared_ptr<FileSystem> fs_;
+    std::string data_dir_;
+};
+
+TEST_F(BTreeCompatibilityTest, ReadAndQueryIntData) {
+    for (int32_t count : {50, 100, 500, 1000, 5000}) {
+        std::string prefix = "btree_test_int_" + std::to_string(count);
+        std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+        std::string meta_path = bin_path + ".meta";
+        std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+        auto records = ParseCsvFile(csv_path);
+        ASSERT_EQ(static_cast<int32_t>(records.size()), count);
+
+        ASSERT_OK_AND_ASSIGN(auto reader,
+                             CreateReaderFromFiles(bin_path, meta_path, 
arrow::int32()));
+        RunIntQueries(reader, records);
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, ReadAndQueryStringData) {
+    for (int32_t count : {50, 100, 500, 1000, 5000}) {
+        std::string prefix = "btree_test_string_" + std::to_string(count);
+        std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+        std::string meta_path = bin_path + ".meta";
+        std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+        auto records = ParseCsvFile(csv_path);
+        ASSERT_EQ(static_cast<int32_t>(records.size()), count);
+
+        ASSERT_OK_AND_ASSIGN(auto reader,
+                             CreateReaderFromFiles(bin_path, meta_path, 
arrow::utf8()));
+        RunStringQueries(reader, records);
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, ReadAndQueryFloatData) {
+    int32_t count = 50;
+    std::string prefix = "btree_test_float_" + std::to_string(count);
+    std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+    std::string meta_path = bin_path + ".meta";
+    std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+    auto records = ParseCsvFile(csv_path);
+    ASSERT_EQ(static_cast<int32_t>(records.size()), count);
+
+    ASSERT_OK_AND_ASSIGN(auto reader, CreateReaderFromFiles(bin_path, 
meta_path, arrow::float32()));
+    RunFloatQueries(reader, records);
+}
+
+TEST_F(BTreeCompatibilityTest, AllNulls) {
+    std::string prefix = "btree_test_int_all_nulls";
+    std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+    std::string meta_path = bin_path + ".meta";
+    std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+    auto records = ParseCsvFile(csv_path);
+    ASSERT_FALSE(records.empty());
+    auto count = static_cast<int32_t>(records.size());
+
+    ASSERT_OK_AND_ASSIGN(auto reader, CreateReaderFromFiles(bin_path, 
meta_path, arrow::int32()));
+
+    // All rows should be null
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_EQ(static_cast<int32_t>(actual_ids.size()), count);
+        for (int32_t i = 0; i < count; ++i) {
+            ASSERT_TRUE(actual_ids.count(i));
+        }
+    }
+
+    // No rows should be non-null
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_TRUE(actual_ids.empty());
+    }
+
+    // VisitEqual should return empty for any key
+    {
+        Literal literal(static_cast<int32_t>(42));
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_TRUE(actual_ids.empty());
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, NoNulls) {
+    std::string prefix = "btree_test_int_no_nulls";
+    std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+    std::string meta_path = bin_path + ".meta";
+    std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+    auto records = ParseCsvFile(csv_path);
+    ASSERT_FALSE(records.empty());
+    auto count = static_cast<int32_t>(records.size());
+
+    ASSERT_OK_AND_ASSIGN(auto reader, CreateReaderFromFiles(bin_path, 
meta_path, arrow::int32()));
+
+    // No rows should be null
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_TRUE(actual_ids.empty());
+    }
+
+    // All rows should be non-null
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_EQ(static_cast<int32_t>(actual_ids.size()), count);
+    }
+
+    // VisitEqual for each unique key
+    {
+        std::set<std::string> tested_keys;
+        for (const auto& rec : records) {
+            if (!rec.is_null && tested_keys.find(rec.key) == 
tested_keys.end()) {
+                tested_keys.insert(rec.key);
+                int32_t key_val = std::stoi(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stoi(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+            }
+        }
+    }
+
+    int32_t max_key = 0;
+    for (const auto& rec : records) {
+        if (!rec.is_null) {
+            max_key = std::max(max_key, std::stoi(rec.key));
+        }
+    }
+
+    // VisitLessOrEqual for the max key should return all rows
+    {
+        Literal literal(max_key);
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessOrEqual(literal));
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_EQ(static_cast<int32_t>(actual_ids.size()), count);
+    }
+
+    // VisitGreaterThan for the max key should return empty
+    {
+        Literal literal(max_key);
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitGreaterThan(literal));
+        auto actual_ids = CollectRowIds(result);
+        ASSERT_TRUE(actual_ids.empty());
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, DuplicateKeys) {
+    std::string prefix = "btree_test_int_duplicates";
+    std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+    std::string meta_path = bin_path + ".meta";
+    std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+    auto records = ParseCsvFile(csv_path);
+    ASSERT_FALSE(records.empty());
+
+    ASSERT_OK_AND_ASSIGN(auto reader, CreateReaderFromFiles(bin_path, 
meta_path, arrow::int32()));
+
+    // VisitIsNull
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+        auto actual_ids = CollectRowIds(result);
+        auto expected_ids =
+            CollectMatchingRows(records, [](const CsvRecord& r) { return 
r.is_null; });
+        ASSERT_EQ(actual_ids, expected_ids);
+    }
+
+    // VisitIsNotNull
+    {
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+        auto actual_ids = CollectRowIds(result);
+        auto expected_ids =
+            CollectMatchingRows(records, [](const CsvRecord& r) { return 
!r.is_null; });
+        ASSERT_EQ(actual_ids, expected_ids);
+    }
+
+    // VisitEqual for each unique key
+    {
+        std::set<std::string> tested_keys;
+        for (const auto& rec : records) {
+            if (!rec.is_null && tested_keys.find(rec.key) == 
tested_keys.end()) {
+                tested_keys.insert(rec.key);
+                int32_t key_val = std::stoi(rec.key);
+                Literal literal(key_val);
+                ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal));
+                auto actual_ids = CollectRowIds(result);
+                auto expected_ids = CollectMatchingRows(records, 
[key_val](const CsvRecord& r) {
+                    return !r.is_null && std::stoi(r.key) == key_val;
+                });
+                ASSERT_EQ(actual_ids, expected_ids);
+            }
+        }
+    }
+
+    // VisitIn for keys 0, 5, 9
+    {
+        std::vector<Literal> in_literals = {Literal(static_cast<int32_t>(0)),
+                                            Literal(static_cast<int32_t>(5)),
+                                            Literal(static_cast<int32_t>(9))};
+        ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(in_literals));
+        auto actual_ids = CollectRowIds(result);
+        auto expected_ids = CollectMatchingRows(records, [](const CsvRecord& 
r) {
+            if (r.is_null) {
+                return false;
+            }
+            int32_t v = std::stoi(r.key);
+            return v == 0 || v == 5 || v == 9;
+        });
+        ASSERT_EQ(actual_ids, expected_ids);
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, MetaDeserialization) {
+    // Test int_50 meta
+    {
+        std::string meta_path = data_dir_ + "/btree_test_int_50.bin.meta";
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+
+        auto meta = BTreeIndexMeta::Deserialize(meta_bytes, pool_.get());
+        ASSERT_TRUE(meta);
+
+        ASSERT_TRUE(meta->HasNulls());
+        ASSERT_FALSE(meta->OnlyNulls());
+
+        ASSERT_TRUE(meta->FirstKey());
+        ASSERT_OK_AND_ASSIGN(auto min_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->FirstKey()),
+                                                           arrow::int32(), 
pool_.get()));
+        ASSERT_EQ(min_key, Literal(3));
+
+        ASSERT_TRUE(meta->LastKey());
+        ASSERT_OK_AND_ASSIGN(auto max_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->LastKey()),
+                                                           arrow::int32(), 
pool_.get()));
+        ASSERT_EQ(max_key, Literal(143));
+    }
+
+    // Test float_50 meta
+    {
+        std::string meta_path = data_dir_ + "/btree_test_float_50.bin.meta";
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+
+        auto meta = BTreeIndexMeta::Deserialize(meta_bytes, pool_.get());
+        ASSERT_TRUE(meta);
+
+        ASSERT_TRUE(meta->HasNulls());
+        ASSERT_FALSE(meta->OnlyNulls());
+
+        ASSERT_TRUE(meta->FirstKey());
+        ASSERT_OK_AND_ASSIGN(auto min_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->FirstKey()),
+                                                           arrow::float32(), 
pool_.get()));
+        ASSERT_EQ(min_key, Literal(static_cast<float>(-INFINITY)));
+
+        ASSERT_TRUE(meta->LastKey());
+        ASSERT_OK_AND_ASSIGN(auto max_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->LastKey()),
+                                                           arrow::float32(), 
pool_.get()));
+        ASSERT_EQ(max_key, Literal(static_cast<float>(std::nan(""))));
+    }
+
+    // Test all_nulls meta
+    {
+        std::string meta_path = data_dir_ + 
"/btree_test_int_all_nulls.bin.meta";
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+
+        auto meta = BTreeIndexMeta::Deserialize(meta_bytes, pool_.get());
+        ASSERT_TRUE(meta);
+
+        ASSERT_TRUE(meta->HasNulls());
+        ASSERT_TRUE(meta->OnlyNulls());
+        ASSERT_FALSE(meta->FirstKey());
+        ASSERT_FALSE(meta->LastKey());
+    }
+
+    // Test no_nulls meta
+    {
+        std::string meta_path = data_dir_ + 
"/btree_test_int_no_nulls.bin.meta";
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+
+        auto meta = BTreeIndexMeta::Deserialize(meta_bytes, pool_.get());
+        ASSERT_TRUE(meta);
+
+        ASSERT_TRUE(meta->FirstKey());
+        ASSERT_OK_AND_ASSIGN(auto min_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->FirstKey()),
+                                                           arrow::int32(), 
pool_.get()));
+        ASSERT_EQ(min_key, Literal(4));
+
+        ASSERT_TRUE(meta->LastKey());
+        ASSERT_OK_AND_ASSIGN(auto max_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->LastKey()),
+                                                           arrow::int32(), 
pool_.get()));
+        ASSERT_EQ(max_key, Literal(158));
+    }
+
+    // Test string_50 meta
+    {
+        std::string meta_path = data_dir_ + "/btree_test_string_50.bin.meta";
+        auto meta_str = ReadFileAsString(meta_path);
+        std::shared_ptr<Bytes> meta_bytes = Bytes::AllocateBytes(meta_str, 
pool_.get());
+
+        auto meta = BTreeIndexMeta::Deserialize(meta_bytes, pool_.get());
+        ASSERT_TRUE(meta);
+
+        ASSERT_TRUE(meta->HasNulls());
+        ASSERT_FALSE(meta->OnlyNulls());
+
+        ASSERT_TRUE(meta->FirstKey());
+        ASSERT_OK_AND_ASSIGN(auto min_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->FirstKey()),
+                                                           arrow::utf8(), 
pool_.get()));
+        std::string min_key_str = "test_00000";
+        ASSERT_EQ(min_key, Literal(FieldType::STRING, min_key_str.data(), 
min_key_str.size()));
+
+        ASSERT_TRUE(meta->LastKey());
+        ASSERT_OK_AND_ASSIGN(auto max_key,
+                             
KeySerializer::DeserializeKey(MemorySlice::Wrap(meta->LastKey()),
+                                                           arrow::utf8(), 
pool_.get()));
+        std::string max_key_str = "test_00049";
+        ASSERT_EQ(max_key, Literal(FieldType::STRING, max_key_str.data(), 
max_key_str.size()));
+    }
+}
+
+TEST_F(BTreeCompatibilityTest, RowCountConsistency) {
+    std::vector<std::pair<std::string, std::shared_ptr<arrow::DataType>>> 
test_cases = {
+        {"btree_test_int_50", arrow::int32()},       {"btree_test_int_100", 
arrow::int32()},
+        {"btree_test_int_500", arrow::int32()},      {"btree_test_string_50", 
arrow::utf8()},
+        {"btree_test_string_100", arrow::utf8()},    
{"btree_test_int_all_nulls", arrow::int32()},
+        {"btree_test_int_no_nulls", arrow::int32()}, 
{"btree_test_int_duplicates", arrow::int32()},
+    };
+
+    for (const auto& [prefix, arrow_type] : test_cases) {
+        std::string bin_path = data_dir_ + "/" + prefix + ".bin";
+        std::string meta_path = bin_path + ".meta";
+        std::string csv_path = data_dir_ + "/" + prefix + ".csv";
+
+        auto records = ParseCsvFile(csv_path);
+        ASSERT_FALSE(records.empty());
+        auto count = static_cast<int32_t>(records.size());
+
+        ASSERT_OK_AND_ASSIGN(auto reader, CreateReaderFromFiles(bin_path, 
meta_path, arrow_type));
+
+        ASSERT_OK_AND_ASSIGN(auto null_result, reader->VisitIsNull());
+        auto null_ids = CollectRowIds(null_result);
+
+        ASSERT_OK_AND_ASSIGN(auto non_null_result, reader->VisitIsNotNull());
+        auto non_null_ids = CollectRowIds(non_null_result);
+
+        // Null and non-null should be disjoint
+        for (auto id : null_ids) {
+            ASSERT_EQ(non_null_ids.count(id), 0u);
+        }
+
+        // Total should equal record count
+        ASSERT_EQ(static_cast<int32_t>(null_ids.size() + non_null_ids.size()), 
count);
+    }
+}
+
+}  // namespace paimon::test
diff --git 
a/src/paimon/common/global_index/btree/btree_global_index_factory.cpp 
b/src/paimon/common/global_index/btree/btree_global_index_factory.cpp
new file mode 100644
index 0000000..3b4faa8
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_factory.cpp
@@ -0,0 +1,36 @@
+/*
+ * 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/global_index/btree/btree_global_index_factory.h"
+
+#include <utility>
+
+#include "paimon/common/global_index/btree/btree_global_indexer.h"
+namespace paimon {
+
+const char BTreeGlobalIndexerFactory::IDENTIFIER[] = "btree-global";
+
+Result<std::unique_ptr<GlobalIndexer>> BTreeGlobalIndexerFactory::Create(
+    const std::map<std::string, std::string>& options) const {
+    return BTreeGlobalIndexer::Create(options);
+}
+
+REGISTER_PAIMON_FACTORY(BTreeGlobalIndexerFactory);
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_index_factory.h 
b/src/paimon/common/global_index/btree/btree_global_index_factory.h
new file mode 100644
index 0000000..0d8c875
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_factory.h
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+#include "paimon/global_index/global_indexer_factory.h"
+
+namespace paimon {
+/// Factory for creating btree global indexers.
+class BTreeGlobalIndexerFactory : public GlobalIndexerFactory {
+ public:
+    static const char IDENTIFIER[];
+
+    const char* Identifier() const override {
+        return IDENTIFIER;
+    }
+
+    Result<std::unique_ptr<GlobalIndexer>> Create(
+        const std::map<std::string, std::string>& options) const override;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_index_reader.cpp 
b/src/paimon/common/global_index/btree/btree_global_index_reader.cpp
new file mode 100644
index 0000000..2ed8798
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_reader.cpp
@@ -0,0 +1,450 @@
+/*
+ * 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/global_index/btree/btree_global_index_reader.h"
+
+#include "fmt/format.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/memory/memory_slice.h"
+#include "paimon/common/memory/memory_slice_input.h"
+#include "paimon/global_index/bitmap_global_index_result.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/predicate/literal.h"
+namespace paimon {
+
+Result<std::shared_ptr<BTreeGlobalIndexReader>> BTreeGlobalIndexReader::Create(
+    const std::shared_ptr<SstFileReader>& sst_file_reader, RoaringBitmap64&& 
null_bitmap,
+    const std::optional<MemorySlice>& min_key_slice,
+    const std::optional<MemorySlice>& max_key_slice,
+    const std::shared_ptr<arrow::DataType>& key_type, const 
std::shared_ptr<MemoryPool>& pool) {
+    std::optional<Literal> min_key;
+    std::optional<Literal> max_key;
+    if (min_key_slice) {
+        PAIMON_ASSIGN_OR_RAISE(
+            min_key, KeySerializer::DeserializeKey(min_key_slice.value(), 
key_type, pool.get()));
+    }
+    if (max_key_slice) {
+        PAIMON_ASSIGN_OR_RAISE(
+            max_key, KeySerializer::DeserializeKey(max_key_slice.value(), 
key_type, pool.get()));
+    }
+    return std::shared_ptr<BTreeGlobalIndexReader>(new BTreeGlobalIndexReader(
+        sst_file_reader, std::move(null_bitmap), std::move(min_key), 
std::move(max_key),
+        min_key_slice, max_key_slice, key_type, pool));
+}
+
+BTreeGlobalIndexReader::BTreeGlobalIndexReader(
+    const std::shared_ptr<SstFileReader>& sst_file_reader, RoaringBitmap64&& 
null_bitmap,
+    std::optional<Literal> min_key, std::optional<Literal> max_key,
+    std::optional<MemorySlice> min_key_slice, std::optional<MemorySlice> 
max_key_slice,
+    const std::shared_ptr<arrow::DataType>& key_type, const 
std::shared_ptr<MemoryPool>& pool)
+    : pool_(pool),
+      sst_file_reader_(sst_file_reader),
+      null_bitmap_(std::move(null_bitmap)),
+      min_key_(std::move(min_key)),
+      max_key_(std::move(max_key)),
+      min_key_slice_(std::move(min_key_slice)),
+      max_key_slice_(std::move(max_key_slice)),
+      key_type_(key_type),
+      comparator_(KeySerializer::CreateComparator(key_type, pool)) {}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitIsNotNull() {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->AllNonNullRows();
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitIsNull() {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->null_bitmap_;
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitStartsWith(
+    const Literal& prefix) {
+    if (prefix.IsNull()) {
+        return Status::Invalid("StartsWith pattern cannot be null");
+    }
+
+    if (prefix.GetType() == FieldType::STRING) {
+        auto prefix_str = prefix.GetValue<std::string>();
+        if (prefix_str.empty()) {
+            return std::make_shared<BitmapGlobalIndexResult>(
+                [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+                    return reader->AllNonNullRows();
+                });
+        }
+
+        // Compute the exclusive upper bound for the prefix range.
+        // Increment the last byte; carry over if it overflows 0xFF.
+        std::string upper_str = prefix_str;
+        bool overflow = true;
+        for (int32_t i = static_cast<int32_t>(upper_str.size()) - 1; i >= 0 && 
overflow; --i) {
+            auto c = static_cast<unsigned char>(upper_str[i]);
+            if (c < 0xFF) {
+                upper_str[i] = static_cast<char>(c + 1);
+                overflow = false;
+            } else {
+                upper_str[i] = 0x00;
+            }
+        }
+
+        if (!overflow) {
+            Literal upper_bound(FieldType::STRING, upper_str.data(), 
upper_str.size());
+            return std::make_shared<BitmapGlobalIndexResult>(
+                [reader = shared_from_this(), prefix = prefix,
+                 upper_bound = std::move(upper_bound)]() -> 
Result<RoaringBitmap64> {
+                    return reader->RangeQuery(prefix, upper_bound, 
/*from_inclusive=*/true,
+                                              /*to_inclusive=*/false);
+                });
+        }
+
+        // All bytes were 0xFF, use max_key_ as upper bound
+        return std::make_shared<BitmapGlobalIndexResult>(
+            [reader = shared_from_this(), prefix = prefix]() -> 
Result<RoaringBitmap64> {
+                return reader->RangeQuery(prefix, reader->max_key_, 
/*from_inclusive=*/true,
+                                          /*to_inclusive=*/true);
+            });
+    }
+
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->AllNonNullRows();
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitEndsWith(
+    const Literal& suffix) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->AllNonNullRows();
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitContains(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->AllNonNullRows();
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> BTreeGlobalIndexReader::VisitLike(
+    const Literal& literal) {
+    if (literal.IsNull()) {
+        return Status::Invalid("LIKE pattern cannot be null");
+    }
+    if (literal.GetType() == FieldType::STRING) {
+        auto pattern = literal.GetValue<std::string>();
+
+        bool is_prefix_pattern = false;
+        std::string prefix;
+
+        size_t first_wildcard = pattern.find_first_of("_%");
+
+        if (first_wildcard != std::string::npos && pattern[first_wildcard] == 
'%' &&
+            first_wildcard == pattern.length() - 1) {
+            is_prefix_pattern = true;
+            prefix = pattern.substr(0, first_wildcard);
+        }
+
+        if (is_prefix_pattern) {
+            Literal prefix_literal(FieldType::STRING, prefix.data(), 
prefix.length());
+            return VisitStartsWith(prefix_literal);
+        }
+    }
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this()]() -> Result<RoaringBitmap64> {
+            return reader->AllNonNullRows();
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitLessThan(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            return reader->RangeQuery(reader->min_key_, literal, 
/*from_inclusive=*/true,
+                                      /*to_inclusive=*/false);
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitGreaterOrEqual(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            return reader->RangeQuery(literal, reader->max_key_, 
/*from_inclusive=*/true,
+                                      /*to_inclusive=*/true);
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitNotEqual(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            PAIMON_ASSIGN_OR_RAISE(RoaringBitmap64 result, 
reader->AllNonNullRows());
+            PAIMON_ASSIGN_OR_RAISE(RoaringBitmap64 equal_result,
+                                   reader->RangeQuery(literal, literal, 
/*from_inclusive=*/true,
+                                                      /*to_inclusive=*/true));
+            result -= equal_result;
+            return result;
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitLessOrEqual(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            return reader->RangeQuery(reader->min_key_, literal, 
/*from_inclusive=*/true,
+                                      /*to_inclusive=*/true);
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> BTreeGlobalIndexReader::VisitEqual(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            return reader->RangeQuery(literal, literal, 
/*from_inclusive=*/true,
+                                      /*to_inclusive=*/true);
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitGreaterThan(
+    const Literal& literal) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literal = literal]() -> 
Result<RoaringBitmap64> {
+            return reader->RangeQuery(literal, reader->max_key_, 
/*from_inclusive=*/false,
+                                      /*to_inclusive=*/true);
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> BTreeGlobalIndexReader::VisitIn(
+    const std::vector<Literal>& literals) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literals = literals]() -> 
Result<RoaringBitmap64> {
+            RoaringBitmap64 result;
+            for (const auto& literal : literals) {
+                PAIMON_ASSIGN_OR_RAISE(RoaringBitmap64 literal_result,
+                                       reader->RangeQuery(literal, literal, 
/*from_inclusive=*/true,
+                                                          
/*to_inclusive=*/true));
+                result |= literal_result;
+            }
+            return result;
+        });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> BTreeGlobalIndexReader::VisitNotIn(
+    const std::vector<Literal>& literals) {
+    return std::make_shared<BitmapGlobalIndexResult>(
+        [reader = shared_from_this(), literals = literals]() -> 
Result<RoaringBitmap64> {
+            PAIMON_ASSIGN_OR_RAISE(RoaringBitmap64 result, 
reader->AllNonNullRows());
+            PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexResult> 
in_result,
+                                   reader->VisitIn(literals));
+            auto* typed_in_result = 
dynamic_cast<BitmapGlobalIndexResult*>(in_result.get());
+            if (!typed_in_result) {
+                return Status::Invalid(
+                    "VisitIn should return BitmapGlobalIndexResult in 
BTreeGlobalIndexReader");
+            }
+            PAIMON_ASSIGN_OR_RAISE(const RoaringBitmap64* in_bitmap, 
typed_in_result->GetBitmap());
+            result -= (*in_bitmap);
+            return result;
+        });
+}
+
+Result<std::shared_ptr<ScoredGlobalIndexResult>> 
BTreeGlobalIndexReader::VisitVectorSearch(
+    const std::shared_ptr<VectorSearch>& vector_search) {
+    return Status::Invalid("Vector search not supported in BTree index");
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
BTreeGlobalIndexReader::VisitFullTextSearch(
+    const std::shared_ptr<FullTextSearch>& full_text_search) {
+    return Status::Invalid("Full text search not supported in BTree index");
+}
+
+Result<RoaringBitmap64> BTreeGlobalIndexReader::RangeQuery(const 
std::optional<Literal>& from,
+                                                           const 
std::optional<Literal>& to,
+                                                           bool 
from_inclusive, bool to_inclusive) {
+    if (!from || !to) {
+        return RoaringBitmap64();
+    }
+
+    // Create an index block iterator to iterate through data blocks
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<Bytes> from_bytes,
+                           KeySerializer::SerializeKey(from.value(), 
key_type_, pool_.get()));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<Bytes> to_bytes,
+                           KeySerializer::SerializeKey(to.value(), key_type_, 
pool_.get()));
+    MemorySlice from_slice = MemorySlice::Wrap(from_bytes);
+    MemorySlice to_slice = MemorySlice::Wrap(to_bytes);
+
+    // Determine if we can skip lower/upper bound checks using cached 
serialized min/max keys.
+    // When from == min_key_, all entries are >= from, so skip lower bound 
comparison.
+    // When to == max_key_, all entries are <= to, so skip upper bound 
comparison.
+    bool skip_from_check = false;
+    bool skip_to_check = false;
+    if (min_key_slice_) {
+        PAIMON_ASSIGN_OR_RAISE(int32_t cmp_min, comparator_(from_slice, 
min_key_slice_.value()));
+        skip_from_check = (cmp_min == 0) && from_inclusive;
+    }
+    if (max_key_slice_) {
+        PAIMON_ASSIGN_OR_RAISE(int32_t cmp_max, comparator_(to_slice, 
max_key_slice_.value()));
+        skip_to_check = (cmp_max == 0) && to_inclusive;
+    }
+
+    RoaringBitmap64 result;
+
+    // Per-data-block row-id buffer. Collect row-ids within a single data
+    // block and flush them with one AddMany call.
+    std::vector<int64_t> block_row_ids;
+
+    auto index_iterator = sst_file_reader_->CreateIndexIterator();
+    PAIMON_ASSIGN_OR_RAISE([[maybe_unused]] bool seek_result, 
index_iterator->SeekTo(from_slice));
+
+    bool first_block = true;
+    // After SeekTo positions at the first entry >= from, only entries in the 
first
+    // block before the seek position could be < from. Once we pass them, all 
subsequent
+    // entries are guaranteed >= from, so we can skip the lower bound check.
+    bool passed_from_bound = skip_from_check;
+
+    while (index_iterator->HasNext()) {
+        // Get the next data block
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<BlockIterator> data_iterator,
+                               sst_file_reader_->GetNextBlock(index_iterator));
+
+        if (!data_iterator || !data_iterator->HasNext()) {
+            assert(false);
+            break;
+        }
+
+        // For the first block, we need to seek within the block to the exact 
position
+        if (first_block) {
+            PAIMON_ASSIGN_OR_RAISE([[maybe_unused]] bool found, 
data_iterator->SeekTo(from_slice));
+            first_block = false;
+        }
+
+        block_row_ids.clear();
+
+        if (skip_to_check && passed_from_bound) {
+            // Fast path: no boundary checks needed, skip key parsing entirely
+            while (data_iterator->HasNext()) {
+                PAIMON_ASSIGN_OR_RAISE(MemorySlice value, 
data_iterator->SkipKeyAndReadValue());
+                PAIMON_RETURN_NOT_OK(DeserializeRowIds(value, &block_row_ids));
+            }
+        } else if (skip_to_check) {
+            // Only need to check lower bound (first few entries in first 
block)
+            while (data_iterator->HasNext()) {
+                PAIMON_ASSIGN_OR_RAISE(BlockEntry entry, 
data_iterator->Next());
+                if (!passed_from_bound) {
+                    PAIMON_ASSIGN_OR_RAISE(int32_t cmp_from, 
comparator_(entry.key, from_slice));
+                    if (!from_inclusive && cmp_from == 0) {
+                        continue;
+                    }
+                    if (cmp_from > 0) {
+                        passed_from_bound = true;
+                    }
+                }
+                PAIMON_RETURN_NOT_OK(DeserializeRowIds(entry.value, 
&block_row_ids));
+            }
+            passed_from_bound = true;
+        } else if (passed_from_bound) {
+            // Only need to check upper bound
+            bool reached_end = false;
+            while (data_iterator->HasNext()) {
+                PAIMON_ASSIGN_OR_RAISE(BlockEntry entry, 
data_iterator->Next());
+                PAIMON_ASSIGN_OR_RAISE(int32_t cmp_to, comparator_(entry.key, 
to_slice));
+                if (cmp_to > 0 || (!to_inclusive && cmp_to == 0)) {
+                    reached_end = true;
+                    break;
+                }
+                PAIMON_RETURN_NOT_OK(DeserializeRowIds(entry.value, 
&block_row_ids));
+            }
+            if (!block_row_ids.empty()) {
+                result.AddMany(block_row_ids.size(), block_row_ids.data());
+            }
+            if (reached_end) {
+                return result;
+            }
+            continue;
+        } else {
+            // Need to check both bounds
+            bool reached_end = false;
+            while (data_iterator->HasNext()) {
+                PAIMON_ASSIGN_OR_RAISE(BlockEntry entry, 
data_iterator->Next());
+                if (!passed_from_bound) {
+                    PAIMON_ASSIGN_OR_RAISE(int32_t cmp_from, 
comparator_(entry.key, from_slice));
+                    if (!from_inclusive && cmp_from == 0) {
+                        continue;
+                    }
+                    if (cmp_from > 0) {
+                        passed_from_bound = true;
+                    }
+                }
+                PAIMON_ASSIGN_OR_RAISE(int32_t cmp_to, comparator_(entry.key, 
to_slice));
+                if (cmp_to > 0 || (!to_inclusive && cmp_to == 0)) {
+                    reached_end = true;
+                    break;
+                }
+                PAIMON_RETURN_NOT_OK(DeserializeRowIds(entry.value, 
&block_row_ids));
+            }
+            passed_from_bound = true;
+            if (!block_row_ids.empty()) {
+                result.AddMany(block_row_ids.size(), block_row_ids.data());
+            }
+            if (reached_end) {
+                return result;
+            }
+            continue;
+        }
+
+        // Flush this block's row-ids in one batched call
+        if (!block_row_ids.empty()) {
+            result.AddMany(block_row_ids.size(), block_row_ids.data());
+        }
+    }
+
+    return result;
+}
+
+Status BTreeGlobalIndexReader::DeserializeRowIds(const MemorySlice& slice,
+                                                 std::vector<int64_t>* out) 
const {
+    auto input = slice.ToInput();
+    PAIMON_ASSIGN_OR_RAISE(int32_t num_row_ids, input.ReadVarLenInt());
+    if (num_row_ids <= 0) {
+        return Status::Invalid(fmt::format(
+            "Invalid row id length {} in DeserializeRowIds for 
BTreeGlobalIndexReader, must > 0",
+            num_row_ids));
+    }
+    out->reserve(out->size() + static_cast<size_t>(num_row_ids));
+    for (int32_t i = 0; i < num_row_ids; i++) {
+        PAIMON_ASSIGN_OR_RAISE(int64_t row_id, input.ReadVarLenLong());
+        out->push_back(row_id);
+    }
+    return Status::OK();
+}
+
+Result<RoaringBitmap64> BTreeGlobalIndexReader::AllNonNullRows() {
+    // Traverse all data to avoid returning null values, which is very 
advantageous in
+    // situations where there are many null values
+    // TODO(xinyu.lxy) do not traverse all data if less null values
+    if (!min_key_) {
+        return RoaringBitmap64();
+    }
+    return RangeQuery(min_key_, max_key_, /*from_inclusive=*/true, 
/*to_inclusive=*/true);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_index_reader.h 
b/src/paimon/common/global_index/btree/btree_global_index_reader.h
new file mode 100644
index 0000000..1bdbe71
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_reader.h
@@ -0,0 +1,122 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "arrow/api.h"
+#include "paimon/common/global_index/btree/btree_defs.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/sst/sst_file_reader.h"
+#include "paimon/global_index/global_index_io_meta.h"
+#include "paimon/global_index/global_index_reader.h"
+#include "paimon/utils/roaring_bitmap64.h"
+namespace paimon {
+
+/// Reader for BTree Global Index files.
+/// This reader evaluates filter predicates against a BTree-based SST file
+/// where each key maps to a list of row IDs.
+class BTreeGlobalIndexReader : public GlobalIndexReader,
+                               public 
std::enable_shared_from_this<BTreeGlobalIndexReader> {
+ public:
+    static Result<std::shared_ptr<BTreeGlobalIndexReader>> Create(
+        const std::shared_ptr<SstFileReader>& sst_file_reader, 
RoaringBitmap64&& null_bitmap,
+        const std::optional<MemorySlice>& min_key_slice,
+        const std::optional<MemorySlice>& max_key_slice,
+        const std::shared_ptr<arrow::DataType>& key_type, const 
std::shared_ptr<MemoryPool>& pool);
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIsNull() override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitEqual(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitNotEqual(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLessThan(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLessOrEqual(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitGreaterThan(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitGreaterOrEqual(const 
Literal& literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIn(
+        const std::vector<Literal>& literals) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitNotIn(
+        const std::vector<Literal>& literals) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitStartsWith(const Literal& 
prefix) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitEndsWith(const Literal& 
suffix) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitContains(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLike(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
+        const std::shared_ptr<VectorSearch>& vector_search) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitFullTextSearch(
+        const std::shared_ptr<FullTextSearch>& full_text_search) override;
+
+    bool IsThreadSafe() const override {
+        return false;
+    }
+
+    std::string GetIndexType() const override {
+        return BtreeDefs::kIdentifier;
+    }
+
+ private:
+    BTreeGlobalIndexReader(const std::shared_ptr<SstFileReader>& 
sst_file_reader,
+                           RoaringBitmap64&& null_bitmap, 
std::optional<Literal> min_key,
+                           std::optional<Literal> max_key, 
std::optional<MemorySlice> min_key_slice,
+                           std::optional<MemorySlice> max_key_slice,
+                           const std::shared_ptr<arrow::DataType>& key_type,
+                           const std::shared_ptr<MemoryPool>& pool);
+
+    Result<RoaringBitmap64> RangeQuery(const std::optional<Literal>& from,
+                                       const std::optional<Literal>& to, bool 
from_inclusive,
+                                       bool to_inclusive);
+
+    Status DeserializeRowIds(const MemorySlice& slice, std::vector<int64_t>* 
out) const;
+
+    Result<RoaringBitmap64> AllNonNullRows();
+
+    std::shared_ptr<MemoryPool> pool_;
+    std::shared_ptr<SstFileReader> sst_file_reader_;
+    RoaringBitmap64 null_bitmap_;
+    std::optional<Literal> min_key_;
+    std::optional<Literal> max_key_;
+    /// Cached serialized min/max key slices to avoid repeated serialization 
in RangeQuery.
+    std::optional<MemorySlice> min_key_slice_;
+    std::optional<MemorySlice> max_key_slice_;
+
+    std::shared_ptr<arrow::DataType> key_type_;
+    MemorySlice::SliceComparator comparator_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_index_writer.cpp 
b/src/paimon/common/global_index/btree/btree_global_index_writer.cpp
new file mode 100644
index 0000000..2e3c99a
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_writer.cpp
@@ -0,0 +1,209 @@
+/*
+ * 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/global_index/btree/btree_global_index_writer.h"
+
+#include <map>
+
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "fmt/format.h"
+#include "paimon/common/compression/block_compression_factory.h"
+#include "paimon/common/global_index/btree/btree_defs.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/global_index/global_index_utils.h"
+#include "paimon/common/memory/memory_slice_output.h"
+#include "paimon/common/predicate/literal_converter.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/crc32c.h"
+#include "paimon/common/utils/preconditions.h"
+#include "paimon/memory/bytes.h"
+namespace paimon {
+Result<std::shared_ptr<BTreeGlobalIndexWriter>> BTreeGlobalIndexWriter::Create(
+    const std::string& field_name, const std::shared_ptr<arrow::StructType>& 
arrow_type,
+    const std::shared_ptr<GlobalIndexFileWriter>& file_writer, int32_t 
block_size,
+    const std::shared_ptr<paimon::BlockCompressionFactory>& 
compression_factory,
+    const std::shared_ptr<MemoryPool>& pool) {
+    auto key_field = arrow_type->GetFieldByName(field_name);
+    PAIMON_RETURN_NOT_OK(Preconditions::CheckNotNull(
+        key_field,
+        fmt::format("field {} not in arrow_array when Create 
BTreeGlobalIndexWriter", field_name)));
+    PAIMON_ASSIGN_OR_RAISE(std::string index_file_name,
+                           file_writer->NewFileName(BtreeDefs::kIdentifier));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<OutputStream> output_stream,
+                           file_writer->NewOutputStream(index_file_name));
+    auto sst_file_writer = std::make_unique<SstFileWriter>(output_stream, 
/*bloom_filter=*/nullptr,
+                                                           block_size, 
compression_factory, pool);
+    return std::shared_ptr<BTreeGlobalIndexWriter>(new BTreeGlobalIndexWriter(
+        field_name, arrow_type, key_field->type(), file_writer, 
index_file_name, output_stream,
+        std::move(sst_file_writer), pool));
+}
+
+BTreeGlobalIndexWriter::BTreeGlobalIndexWriter(
+    const std::string& field_name, const std::shared_ptr<arrow::DataType>& 
arrow_type,
+    const std::shared_ptr<arrow::DataType>& key_type,
+    const std::shared_ptr<GlobalIndexFileWriter>& file_writer, const 
std::string& index_file_name,
+    const std::shared_ptr<OutputStream>& output_stream, 
std::unique_ptr<SstFileWriter>&& sst_writer,
+    const std::shared_ptr<MemoryPool>& pool)
+    : field_name_(field_name),
+      arrow_type_(arrow_type),
+      key_type_(key_type),
+      pool_(pool),
+      file_writer_(file_writer),
+      index_file_name_(index_file_name),
+      output_stream_(output_stream),
+      sst_writer_(std::move(sst_writer)) {}
+
+Status BTreeGlobalIndexWriter::AddBatch(::ArrowArray* arrow_array,
+                                        std::vector<int64_t>&& 
relative_row_ids) {
+    PAIMON_RETURN_NOT_OK(GlobalIndexUtils::CheckRelativeRowIds(
+        arrow_array, relative_row_ids, /*expected_next_row_id=*/std::nullopt));
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                      arrow::ImportArray(arrow_array, 
arrow_type_));
+    auto struct_array = std::dynamic_pointer_cast<arrow::StructArray>(array);
+    PAIMON_RETURN_NOT_OK(Preconditions::CheckNotNull(
+        struct_array, "arrow array must be struct array when AddBatch to 
BTreeGlobalIndexWriter"));
+    auto value_array = struct_array->GetFieldByName(field_name_);
+    PAIMON_RETURN_NOT_OK(Preconditions::CheckNotNull(
+        value_array,
+        fmt::format("field {} not in arrow_array when AddBatch to 
BTreeGlobalIndexWriter",
+                    field_name_)));
+
+    // Process each element in the array
+    PAIMON_ASSIGN_OR_RAISE(std::vector<Literal> literals,
+                           
LiteralConverter::ConvertLiteralsFromArray(*value_array,
+                                                                      
/*own_data=*/true));
+    for (size_t i = 0; i < literals.size(); ++i) {
+        int64_t row_id = relative_row_ids[i];
+        const auto& literal = literals[i];
+        if (literal.IsNull()) {
+            // Track null values
+            null_bitmap_.Add(row_id);
+            continue;
+        }
+        if (last_key_) {
+            PAIMON_ASSIGN_OR_RAISE(int32_t cmp, 
literal.CompareTo(last_key_.value()));
+            if (cmp > 0) {
+                PAIMON_RETURN_NOT_OK(Flush());
+            } else if (cmp < 0) {
+                return Status::Invalid(
+                    fmt::format("Users must keep written keys monotonically 
incremental in "
+                                "BTreeGlobalIndexWriter, current literal {}, 
last_key {}",
+                                literal.ToString(), 
last_key_.value().ToString()));
+            }
+        }
+        last_key_ = literal;
+        current_row_ids_.push_back(row_id);
+        if (!first_key_) {
+            first_key_ = literal;
+        }
+    }
+    return Status::OK();
+}
+
+Status BTreeGlobalIndexWriter::Flush() {
+    if (current_row_ids_.empty()) {
+        return Status::OK();
+    }
+    MemorySliceOutput output(current_row_ids_.size() * 9 + 5, pool_.get());
+    if (current_row_ids_.size() > INT32_MAX) {
+        return Status::Invalid("invalid row id numbers, exceed INT32_MAX");
+    }
+    
PAIMON_RETURN_NOT_OK(output.WriteVarLenInt(static_cast<int32_t>(current_row_ids_.size())));
+    for (int64_t row_id : current_row_ids_) {
+        PAIMON_RETURN_NOT_OK(output.WriteVarLenLong(row_id));
+    }
+    current_row_ids_.clear();
+    assert(last_key_);
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<Bytes> key_bytes,
+                           KeySerializer::SerializeKey(last_key_.value(), 
key_type_, pool_.get()));
+    return sst_writer_->Write(std::move(key_bytes), 
output.ToSlice().CopyBytes(pool_.get()));
+}
+
+Result<std::optional<BlockHandle>> BTreeGlobalIndexWriter::WriteNullBitmap(
+    const std::shared_ptr<OutputStream>& out) {
+    if (null_bitmap_.IsEmpty()) {
+        return std::optional<BlockHandle>();
+    }
+    std::shared_ptr<Bytes> bitmap_bytes = null_bitmap_.Serialize(pool_.get());
+    uint32_t crc = CRC32C::calculate(bitmap_bytes->data(), 
bitmap_bytes->size());
+
+    MemorySliceOutput slice_out(bitmap_bytes->size() + 4, pool_.get());
+    slice_out.WriteBytes(bitmap_bytes);
+    slice_out.WriteValue<int32_t>(static_cast<int32_t>(crc));
+
+    // Get current position for the block handle
+    PAIMON_ASSIGN_OR_RAISE(int64_t offset, out->GetPos());
+    PAIMON_RETURN_NOT_OK(sst_writer_->WriteSlice(slice_out.ToSlice()));
+    return std::optional<BlockHandle>(BlockHandle(offset, 
bitmap_bytes->size()));
+}
+
+Result<std::vector<GlobalIndexIOMeta>> BTreeGlobalIndexWriter::Finish() {
+    // write remaining row ids
+    PAIMON_RETURN_NOT_OK(Flush());
+
+    // Flush any remaining data in the data block writer
+    PAIMON_RETURN_NOT_OK(sst_writer_->Flush());
+
+    // Write null bitmap first
+    PAIMON_ASSIGN_OR_RAISE(std::optional<BlockHandle> null_bitmap_handle,
+                           WriteNullBitmap(output_stream_));
+    // write bloom filter (currently is always null, but we could add it for 
equal
+    // and in condition.)
+    PAIMON_ASSIGN_OR_RAISE(std::optional<BloomFilterHandle> 
bloom_filter_handle,
+                           sst_writer_->WriteBloomFilter());
+    // Write index block
+    PAIMON_ASSIGN_OR_RAISE(BlockHandle index_block_handle, 
sst_writer_->WriteIndexBlock());
+
+    // Write BTree file footer
+    auto footer = std::make_shared<BTreeFileFooter>(bloom_filter_handle, 
index_block_handle,
+                                                    null_bitmap_handle);
+    auto footer_slice = BTreeFileFooter::Write(footer, pool_.get());
+    PAIMON_RETURN_NOT_OK(sst_writer_->WriteSlice(footer_slice));
+
+    PAIMON_RETURN_NOT_OK(output_stream_->Close());
+
+    if (!first_key_ && null_bitmap_.IsEmpty()) {
+        return Status::Invalid("Should never write an empty btree index 
file.");
+    }
+
+    // Get file size
+    std::shared_ptr<Bytes> first_key_bytes;
+    std::shared_ptr<Bytes> last_key_bytes;
+    if (first_key_) {
+        PAIMON_ASSIGN_OR_RAISE(first_key_bytes, KeySerializer::SerializeKey(
+                                                    first_key_.value(), 
key_type_, pool_.get()));
+    }
+    if (last_key_) {
+        PAIMON_ASSIGN_OR_RAISE(
+            last_key_bytes, KeySerializer::SerializeKey(last_key_.value(), 
key_type_, pool_.get()));
+    }
+    // Create index meta
+    auto index_meta =
+        std::make_shared<BTreeIndexMeta>(first_key_bytes, last_key_bytes, 
!null_bitmap_.IsEmpty());
+    auto meta_bytes = index_meta->Serialize(pool_.get());
+
+    // Create GlobalIndexIOMeta
+    std::string file_path = file_writer_->ToPath(index_file_name_);
+    PAIMON_ASSIGN_OR_RAISE(int64_t file_size, 
file_writer_->GetFileSize(index_file_name_));
+    GlobalIndexIOMeta io_meta(file_path, file_size, meta_bytes);
+    return std::vector<GlobalIndexIOMeta>{io_meta};
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_index_writer.h 
b/src/paimon/common/global_index/btree/btree_global_index_writer.h
new file mode 100644
index 0000000..f8c9ff9
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_index_writer.h
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include "arrow/api.h"
+#include "paimon/common/global_index/btree/btree_file_footer.h"
+#include "paimon/common/global_index/btree/btree_index_meta.h"
+#include "paimon/common/sst/sst_file_writer.h"
+#include "paimon/global_index/global_index_writer.h"
+#include "paimon/global_index/io/global_index_file_writer.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/utils/roaring_bitmap64.h"
+namespace paimon {
+
+/// Writer for BTree Global Index files.
+/// This writer builds an SST file where each key maps to a list of row IDs.
+/// Note that users must keep written keys monotonically incremental. All null 
keys are stored in a
+/// separate bitmap, which will be serialized and appended to the file end on 
close. The layout is
+/// as below:
+///
+///    +-----------------------------------+------+
+///    |             Footer                |      |
+///    +-----------------------------------+      |
+///    |           Index Block             |      +--> Loaded on open
+///    +-----------------------------------+      |
+///    |        Bloom Filter Block         |      |
+///    +-----------------------------------+------+
+///    |         Null Bitmap Block         |      |
+///    +-----------------------------------+      |
+///    |            Data Block             |      |
+///    +-----------------------------------+      +--> Loaded on requested
+///    |              ......               |      |
+///    +-----------------------------------+      |
+///    |            Data Block             |      |
+///    +-----------------------------------+------+
+///
+/// For efficiency, we combine entries with the same keys and store a compact 
list of row ids for
+/// each key.
+class BTreeGlobalIndexWriter : public GlobalIndexWriter {
+ public:
+    /// Factory method that may fail during initialization (e.g.,
+    /// Arrow schema import). Use this instead of the constructor.
+    static Result<std::shared_ptr<BTreeGlobalIndexWriter>> Create(
+        const std::string& field_name, const 
std::shared_ptr<arrow::StructType>& arrow_type,
+        const std::shared_ptr<GlobalIndexFileWriter>& file_writer, int32_t 
block_size,
+        const std::shared_ptr<paimon::BlockCompressionFactory>& 
compression_factory,
+        const std::shared_ptr<MemoryPool>& pool);
+
+    ~BTreeGlobalIndexWriter() override = default;
+
+    Status AddBatch(::ArrowArray* arrow_array, std::vector<int64_t>&& 
relative_row_ids) override;
+
+    /// Finish writing and return the index metadata.
+    Result<std::vector<GlobalIndexIOMeta>> Finish() override;
+
+ private:
+    BTreeGlobalIndexWriter(const std::string& field_name,
+                           const std::shared_ptr<arrow::DataType>& arrow_type,
+                           const std::shared_ptr<arrow::DataType>& key_type,
+                           const std::shared_ptr<GlobalIndexFileWriter>& 
file_writer,
+                           const std::string& index_file_name,
+                           const std::shared_ptr<OutputStream>& output_stream,
+                           std::unique_ptr<SstFileWriter>&& sst_writer,
+                           const std::shared_ptr<MemoryPool>& pool);
+
+    Status Flush();
+
+    Result<std::optional<BlockHandle>> WriteNullBitmap(const 
std::shared_ptr<OutputStream>& out);
+
+ private:
+    std::string field_name_;
+    std::shared_ptr<arrow::DataType> arrow_type_;
+    std::shared_ptr<arrow::DataType> key_type_;
+    std::shared_ptr<MemoryPool> pool_;
+
+    std::shared_ptr<GlobalIndexFileWriter> file_writer_;
+    std::string index_file_name_;
+    std::shared_ptr<OutputStream> output_stream_;
+    std::unique_ptr<SstFileWriter> sst_writer_;
+
+    std::optional<Literal> first_key_;
+    std::optional<Literal> last_key_;
+
+    // Null bitmap tracking
+    RoaringBitmap64 null_bitmap_;
+    std::vector<int64_t> current_row_ids_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_indexer.cpp 
b/src/paimon/common/global_index/btree/btree_global_indexer.cpp
new file mode 100644
index 0000000..fd41008
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_indexer.cpp
@@ -0,0 +1,125 @@
+/*
+ * 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/global_index/btree/btree_global_indexer.h"
+
+#include <memory>
+#include <string>
+
+#include "arrow/c/bridge.h"
+#include "paimon/common/compression/block_compression_factory.h"
+#include "paimon/common/global_index/btree/btree_file_footer.h"
+#include "paimon/common/global_index/btree/btree_global_index_writer.h"
+#include "paimon/common/global_index/btree/btree_index_meta.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/global_index/btree/lazy_filtered_btree_reader.h"
+#include "paimon/common/memory/memory_slice.h"
+#include "paimon/common/memory/memory_slice_input.h"
+#include "paimon/common/options/memory_size.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/crc32c.h"
+#include "paimon/common/utils/options_utils.h"
+#include "paimon/common/utils/preconditions.h"
+#include "paimon/core/options/compress_options.h"
+#include "paimon/executor.h"
+#include "paimon/global_index/bitmap_global_index_result.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/utils/roaring_bitmap64.h"
+namespace paimon {
+Result<std::unique_ptr<BTreeGlobalIndexer>> BTreeGlobalIndexer::Create(
+    const std::map<std::string, std::string>& options) {
+    // parse cache options
+    PAIMON_ASSIGN_OR_RAISE(std::string cache_size_str, 
OptionsUtils::GetValueFromMap<std::string>(
+                                                           options, 
BtreeDefs::kBtreeIndexCacheSize,
+                                                           
BtreeDefs::kDefaultBtreeIndexCacheSize));
+    PAIMON_ASSIGN_OR_RAISE(int64_t cache_size, 
MemorySize::ParseBytes(cache_size_str));
+
+    PAIMON_ASSIGN_OR_RAISE(
+        double high_priority_pool_ratio,
+        OptionsUtils::GetValueFromMap<double>(options, 
BtreeDefs::kBtreeIndexHighPriorityPoolRatio,
+                                              
BtreeDefs::kDefaultBtreeIndexHighPriorityPoolRatio));
+    auto cache_manager = std::make_shared<CacheManager>(cache_size, 
high_priority_pool_ratio);
+    return std::unique_ptr<BTreeGlobalIndexer>(new 
BTreeGlobalIndexer(cache_manager, options));
+}
+
+Result<std::shared_ptr<GlobalIndexWriter>> BTreeGlobalIndexer::CreateWriter(
+    const std::string& field_name, ::ArrowSchema* arrow_schema,
+    const std::shared_ptr<GlobalIndexFileWriter>& file_writer,
+    const std::shared_ptr<MemoryPool>& pool) const {
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::DataType> 
arrow_type,
+                                      arrow::ImportType(arrow_schema));
+    // check data type
+    auto struct_type = 
std::dynamic_pointer_cast<arrow::StructType>(arrow_type);
+    PAIMON_RETURN_NOT_OK(Preconditions::CheckNotNull(
+        struct_type, "arrow schema must be struct type when create 
BTreeGlobalIndexWriter"));
+
+    // parse options
+    PAIMON_ASSIGN_OR_RAISE(
+        std::string block_size_str,
+        OptionsUtils::GetValueFromMap<std::string>(options_, 
BtreeDefs::kBtreeIndexBlockSize,
+                                                   
BtreeDefs::kDefaultBtreeIndexBlockSize));
+    PAIMON_ASSIGN_OR_RAISE(int64_t block_size, 
MemorySize::ParseBytes(block_size_str));
+    if (block_size > INT32_MAX) {
+        return Status::Invalid("invalid block size, exceed INT32_MAX");
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::string compress_str,
+        OptionsUtils::GetValueFromMap<std::string>(options_, 
BtreeDefs::kBtreeIndexCompression,
+                                                   
BtreeDefs::kDefaultBtreeIndexCompression));
+    PAIMON_ASSIGN_OR_RAISE(
+        int32_t compress_level,
+        OptionsUtils::GetValueFromMap<int32_t>(options_, 
BtreeDefs::kBtreeIndexCompressionLevel,
+                                               
BtreeDefs::kDefaultBtreeIndexCompressionLevel));
+    CompressOptions compress_options{compress_str, compress_level};
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<paimon::BlockCompressionFactory> 
compression_factory,
+                           BlockCompressionFactory::Create(compress_options));
+    return BTreeGlobalIndexWriter::Create(field_name, struct_type, file_writer,
+                                          static_cast<int32_t>(block_size), 
compression_factory,
+                                          pool);
+}
+
+Result<std::shared_ptr<GlobalIndexReader>> BTreeGlobalIndexer::CreateReader(
+    ::ArrowSchema* arrow_schema, const std::shared_ptr<GlobalIndexFileReader>& 
file_reader,
+    const std::vector<GlobalIndexIOMeta>& files, const 
std::shared_ptr<MemoryPool>& pool) const {
+    // Get field type from arrow schema
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema> schema,
+                                      arrow::ImportSchema(arrow_schema));
+    if (schema->num_fields() != 1) {
+        return Status::Invalid(
+            "invalid schema for BTreeGlobalIndexReader, supposed to have 
single field.");
+    }
+    auto key_type = schema->field(0)->type();
+
+    std::optional<int32_t> read_buffer_size;
+    if (auto iter = options_.find(BtreeDefs::kBtreeIndexReadBufferSize); iter 
!= options_.end()) {
+        PAIMON_ASSIGN_OR_RAISE(int64_t tmp_buffer_size, 
MemorySize::ParseBytes(iter->second));
+        if (tmp_buffer_size <= 0 || tmp_buffer_size > INT_MAX) {
+            return Status::Invalid(
+                fmt::format("In BTreeGlobalIndexer::CreateReader: option {} is 
{}, exceed INT_MAX "
+                            "or less than 0",
+                            BtreeDefs::kBtreeIndexReadBufferSize, 
iter->second));
+        }
+        read_buffer_size = static_cast<int32_t>(tmp_buffer_size);
+    }
+    // TODO(lisizhuo.lsz): Allow users to specify an executor
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    return std::make_shared<LazyFilteredBTreeReader>(read_buffer_size, files, 
key_type, file_reader,
+                                                     cache_manager_, pool, 
executor);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/btree_global_indexer.h 
b/src/paimon/common/global_index/btree/btree_global_indexer.h
new file mode 100644
index 0000000..d1f5e92
--- /dev/null
+++ b/src/paimon/common/global_index/btree/btree_global_indexer.h
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "paimon/common/global_index/btree/btree_defs.h"
+#include "paimon/common/global_index/btree/btree_global_index_reader.h"
+#include "paimon/common/sst/block_cache.h"
+#include "paimon/common/sst/block_handle.h"
+#include "paimon/global_index/global_indexer.h"
+#include "paimon/global_index/io/global_index_file_reader.h"
+#include "paimon/utils/roaring_bitmap64.h"
+namespace paimon {
+/// The indexer for btree index. We do not build a B-tree directly in memory, 
instead, we form a
+/// logical B-tree via multi-level metadata over SST files that store the 
actual data, as below:
+///
+///                                             BTree-Index
+///                                             /           |
+///                                            /    ...     |
+///                                           /             |
+///     +--------------------------------------+           +------------+
+///     |               SST File               |           |            |
+///     +--------------------------------------+           |            |
+///     |              Root Index              |           |            |
+///     |             /   ...    |             |    ...    |  SST File  |
+///     |     Leaf Index  ...  Leaf Index      |           |            |
+///     |     /  ...   |       /  ...   |      |           |            |
+///     | DataBlock       ...        DataBlock |           |            |
+///     +--------------------------------------+           +------------+
+///
+/// This approach significantly reduces memory pressure during index reads.
+
+class BTreeGlobalIndexer : public GlobalIndexer {
+ public:
+    static Result<std::unique_ptr<BTreeGlobalIndexer>> Create(
+        const std::map<std::string, std::string>& options);
+
+    Result<std::shared_ptr<GlobalIndexWriter>> CreateWriter(
+        const std::string& field_name, ::ArrowSchema* arrow_schema,
+        const std::shared_ptr<GlobalIndexFileWriter>& file_writer,
+        const std::shared_ptr<MemoryPool>& pool) const override;
+
+    Result<std::shared_ptr<GlobalIndexReader>> CreateReader(
+        ::ArrowSchema* arrow_schema, const 
std::shared_ptr<GlobalIndexFileReader>& file_reader,
+        const std::vector<GlobalIndexIOMeta>& files,
+        const std::shared_ptr<MemoryPool>& pool) const override;
+
+ private:
+    BTreeGlobalIndexer(const std::shared_ptr<CacheManager>& cache_manager,
+                       const std::map<std::string, std::string>& options)
+        : cache_manager_(cache_manager), options_(options) {}
+
+ private:
+    std::shared_ptr<CacheManager> cache_manager_;
+    std::map<std::string, std::string> options_;
+};
+
+}  // namespace paimon
diff --git 
a/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.cpp 
b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.cpp
new file mode 100644
index 0000000..bc66e17
--- /dev/null
+++ b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.cpp
@@ -0,0 +1,297 @@
+/*
+ * 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/global_index/btree/lazy_filtered_btree_reader.h"
+
+#include <future>
+#include <utility>
+
+#include "paimon/common/executor/future.h"
+#include "paimon/common/global_index/btree/btree_file_footer.h"
+#include "paimon/common/global_index/btree/btree_global_index_reader.h"
+#include "paimon/common/global_index/btree/btree_index_meta.h"
+#include "paimon/common/global_index/btree/key_serializer.h"
+#include "paimon/common/global_index/union_global_index_reader.h"
+#include "paimon/common/memory/memory_slice.h"
+#include "paimon/common/memory/memory_slice_input.h"
+#include "paimon/common/sst/block_cache.h"
+#include "paimon/common/sst/sst_file_reader.h"
+#include "paimon/common/utils/crc32c.h"
+#include "paimon/global_index/bitmap_global_index_result.h"
+#include "paimon/io/buffered_input_stream.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+LazyFilteredBTreeReader::LazyFilteredBTreeReader(
+    std::optional<int32_t> read_buffer_size, const 
std::vector<GlobalIndexIOMeta>& files,
+    const std::shared_ptr<arrow::DataType>& key_type,
+    const std::shared_ptr<GlobalIndexFileReader>& file_reader,
+    const std::shared_ptr<CacheManager>& cache_manager, const 
std::shared_ptr<MemoryPool>& pool,
+    const std::shared_ptr<Executor>& executor)
+    : read_buffer_size_(read_buffer_size),
+      pool_(pool),
+      file_selector_(files, key_type, pool),
+      key_type_(key_type),
+      file_reader_(file_reader),
+      cache_manager_(cache_manager),
+      executor_(executor) {}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitIsNotNull() {
+    return DispatchVisit(
+        [this]() { return file_selector_.VisitIsNotNull(); },
+        [](const std::shared_ptr<GlobalIndexReader>& reader) { return 
reader->VisitIsNotNull(); });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitIsNull() {
+    return DispatchVisit(
+        [this]() { return file_selector_.VisitIsNull(); },
+        [](const std::shared_ptr<GlobalIndexReader>& reader) { return 
reader->VisitIsNull(); });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> LazyFilteredBTreeReader::VisitEqual(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitEqual(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitEqual(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitNotEqual(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitNotEqual(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitNotEqual(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitLessThan(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitLessThan(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitLessThan(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitLessOrEqual(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitLessOrEqual(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitLessOrEqual(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitGreaterThan(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitGreaterThan(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitGreaterThan(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitGreaterOrEqual(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitGreaterOrEqual(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitGreaterOrEqual(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> LazyFilteredBTreeReader::VisitIn(
+    const std::vector<Literal>& literals) {
+    return DispatchVisit([this, &literals]() { return 
file_selector_.VisitIn(literals); },
+                         [&literals](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitIn(literals);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> LazyFilteredBTreeReader::VisitNotIn(
+    const std::vector<Literal>& literals) {
+    return DispatchVisit([this, &literals]() { return 
file_selector_.VisitNotIn(literals); },
+                         [&literals](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitNotIn(literals);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitStartsWith(
+    const Literal& prefix) {
+    return DispatchVisit([this, &prefix]() { return 
file_selector_.VisitStartsWith(prefix); },
+                         [&prefix](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitStartsWith(prefix);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitEndsWith(
+    const Literal& suffix) {
+    return DispatchVisit([this, &suffix]() { return 
file_selector_.VisitEndsWith(suffix); },
+                         [&suffix](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitEndsWith(suffix);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitContains(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitContains(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitContains(literal);
+                         });
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> LazyFilteredBTreeReader::VisitLike(
+    const Literal& literal) {
+    return DispatchVisit([this, &literal]() { return 
file_selector_.VisitLike(literal); },
+                         [&literal](const std::shared_ptr<GlobalIndexReader>& 
reader) {
+                             return reader->VisitLike(literal);
+                         });
+}
+
+Result<std::shared_ptr<ScoredGlobalIndexResult>> 
LazyFilteredBTreeReader::VisitVectorSearch(
+    const std::shared_ptr<VectorSearch>& vector_search) {
+    return Status::Invalid("LazyFilteredBTreeReader does not support vector 
search");
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::VisitFullTextSearch(
+    const std::shared_ptr<FullTextSearch>& full_text_search) {
+    return Status::Invalid("LazyFilteredBTreeReader does not support full text 
search");
+}
+
+Result<std::shared_ptr<GlobalIndexResult>> 
LazyFilteredBTreeReader::DispatchVisit(
+    SelectAction select_files, ReaderAction action) {
+    PAIMON_ASSIGN_OR_RAISE(std::vector<GlobalIndexIOMeta> selected_files, 
select_files());
+    if (selected_files.empty()) {
+        return std::make_shared<BitmapGlobalIndexResult>([]() { return 
RoaringBitmap64(); });
+    }
+
+    // Create a UnionGlobalIndexReader from cached readers for the selected 
files
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexReader> union_reader,
+                           CreateUnionReader(selected_files));
+
+    // Delegate the action to the union reader
+    return action(union_reader);
+}
+
+Result<std::shared_ptr<GlobalIndexReader>> 
LazyFilteredBTreeReader::CreateUnionReader(
+    const std::vector<GlobalIndexIOMeta>& files) {
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers;
+    readers.reserve(files.size());
+    for (const auto& meta : files) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexReader> reader, 
GetOrCreateReader(meta));
+        readers.push_back(std::move(reader));
+    }
+
+    return std::make_shared<UnionGlobalIndexReader>(std::move(readers), 
executor_);
+}
+
+Result<std::shared_ptr<GlobalIndexReader>> 
LazyFilteredBTreeReader::GetOrCreateReader(
+    const GlobalIndexIOMeta& meta) {
+    auto iterator = reader_cache_.find(meta.file_path);
+    if (iterator != reader_cache_.end()) {
+        return iterator->second;
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexReader> reader, 
CreateSingleReader(meta));
+    reader_cache_[meta.file_path] = reader;
+    return reader;
+}
+
+Result<std::shared_ptr<GlobalIndexReader>> 
LazyFilteredBTreeReader::CreateSingleReader(
+    const GlobalIndexIOMeta& meta) {
+    // Create comparator based on field type
+    auto comparator = KeySerializer::CreateComparator(key_type_, pool_);
+
+    // Get min/max key slices from meta data (keep as slices; Create() will 
deserialize)
+    auto index_meta = BTreeIndexMeta::Deserialize(meta.metadata, pool_.get());
+    std::optional<MemorySlice> min_key_slice;
+    std::optional<MemorySlice> max_key_slice;
+    if (index_meta->FirstKey()) {
+        min_key_slice = MemorySlice::Wrap(index_meta->FirstKey());
+    }
+    if (index_meta->LastKey()) {
+        max_key_slice = MemorySlice::Wrap(index_meta->LastKey());
+    }
+
+    // Open input stream and create block cache
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
+                           file_reader_->GetInputStream(meta.file_path));
+    if (read_buffer_size_) {
+        input_stream = std::make_shared<BufferedInputStream>(
+            input_stream, read_buffer_size_.value(), pool_.get());
+    }
+
+    auto block_cache =
+        std::make_shared<BlockCache>(meta.file_path, input_stream, 
cache_manager_, pool_);
+
+    // Read footer
+    PAIMON_ASSIGN_OR_RAISE(MemorySegment footer_segment,
+                           block_cache->GetBlock(meta.file_size - 
BTreeFileFooter::kEncodingLength,
+                                                 
BTreeFileFooter::kEncodingLength, true,
+                                                 /*decompress_func=*/nullptr));
+    auto footer_slice = MemorySlice::Wrap(footer_segment);
+    auto footer_input = footer_slice.ToInput();
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<BTreeFileFooter> footer,
+                           BTreeFileFooter::Read(&footer_input));
+
+    // Read null bitmap
+    PAIMON_ASSIGN_OR_RAISE(RoaringBitmap64 null_bitmap,
+                           ReadNullBitmap(block_cache, 
footer->GetNullBitmapHandle()));
+
+    // Create SST file reader
+    PAIMON_ASSIGN_OR_RAISE(
+        std::shared_ptr<SstFileReader> sst_file_reader,
+        SstFileReader::Create(footer->GetIndexBlockHandle(), 
footer->GetBloomFilterHandle(),
+                              comparator, block_cache, pool_));
+
+    return BTreeGlobalIndexReader::Create(sst_file_reader, 
std::move(null_bitmap), min_key_slice,
+                                          max_key_slice, key_type_, pool_);
+}
+
+Result<RoaringBitmap64> LazyFilteredBTreeReader::ReadNullBitmap(
+    const std::shared_ptr<BlockCache>& cache, const 
std::optional<BlockHandle>& block_handle) {
+    RoaringBitmap64 null_bitmap;
+    if (!block_handle.has_value()) {
+        return null_bitmap;
+    }
+
+    // Read bytes and CRC value
+    PAIMON_ASSIGN_OR_RAISE(
+        MemorySegment segment,
+        cache->GetBlock(block_handle->Offset(), block_handle->Size() + 4, 
/*is_index=*/false,
+                        /*decompress_func=*/nullptr));
+
+    auto slice = MemorySlice::Wrap(segment);
+    auto slice_input = slice.ToInput();
+
+    // Read null bitmap data
+    auto null_bitmap_bytes = 
slice_input.ReadSliceView(block_handle->Size()).CopyBytes(pool_.get());
+
+    // Calculate and verify CRC32C checksum
+    uint32_t calculated_crc =
+        CRC32C::calculate(null_bitmap_bytes->data(), 
null_bitmap_bytes->size());
+    int32_t expected_crc = slice_input.ReadInt();
+    if (calculated_crc != static_cast<uint32_t>(expected_crc)) {
+        return Status::Invalid(fmt::format(
+            "CRC check failure during decoding null bitmap. Expected: {}, 
Calculated: {}",
+            expected_crc, calculated_crc));
+    }
+
+    // Deserialize null bitmap
+    PAIMON_RETURN_NOT_OK(
+        null_bitmap.Deserialize(null_bitmap_bytes->data(), 
null_bitmap_bytes->size()));
+    return null_bitmap;
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.h 
b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.h
new file mode 100644
index 0000000..0603e78
--- /dev/null
+++ b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader.h
@@ -0,0 +1,107 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <functional>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "arrow/api.h"
+#include "paimon/common/global_index/btree/btree_defs.h"
+#include "paimon/common/global_index/btree/btree_file_meta_selector.h"
+#include "paimon/common/io/cache/cache_manager.h"
+#include "paimon/common/sst/block_cache.h"
+#include "paimon/common/sst/block_handle.h"
+#include "paimon/executor.h"
+#include "paimon/global_index/global_index_reader.h"
+#include "paimon/global_index/io/global_index_file_reader.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+class LazyFilteredBTreeReader : public GlobalIndexReader {
+ public:
+    LazyFilteredBTreeReader(std::optional<int32_t> read_buffer_size,
+                            const std::vector<GlobalIndexIOMeta>& files,
+                            const std::shared_ptr<arrow::DataType>& key_type,
+                            const std::shared_ptr<GlobalIndexFileReader>& 
file_reader,
+                            const std::shared_ptr<CacheManager>& cache_manager,
+                            const std::shared_ptr<MemoryPool>& pool,
+                            const std::shared_ptr<Executor>& executor);
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIsNull() override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitEqual(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitNotEqual(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLessThan(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLessOrEqual(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitGreaterThan(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitGreaterOrEqual(const 
Literal& literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitIn(
+        const std::vector<Literal>& literals) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitNotIn(
+        const std::vector<Literal>& literals) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitStartsWith(const Literal& 
prefix) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitEndsWith(const Literal& 
suffix) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitContains(const Literal& 
literal) override;
+    Result<std::shared_ptr<GlobalIndexResult>> VisitLike(const Literal& 
literal) override;
+
+    Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
+        const std::shared_ptr<VectorSearch>& vector_search) override;
+
+    Result<std::shared_ptr<GlobalIndexResult>> VisitFullTextSearch(
+        const std::shared_ptr<FullTextSearch>& full_text_search) override;
+
+    bool IsThreadSafe() const override {
+        return false;
+    }
+
+    std::string GetIndexType() const override {
+        return BtreeDefs::kIdentifier;
+    }
+
+ private:
+    using SelectAction = 
std::function<Result<std::vector<GlobalIndexIOMeta>>()>;
+    using ReaderAction = 
std::function<Result<std::shared_ptr<GlobalIndexResult>>(
+        const std::shared_ptr<GlobalIndexReader>&)>;
+
+    Result<std::shared_ptr<GlobalIndexResult>> DispatchVisit(SelectAction 
select_files,
+                                                             ReaderAction 
action);
+    Result<std::shared_ptr<GlobalIndexReader>> CreateUnionReader(
+        const std::vector<GlobalIndexIOMeta>& files);
+    Result<std::shared_ptr<GlobalIndexReader>> GetOrCreateReader(const 
GlobalIndexIOMeta& meta);
+    Result<std::shared_ptr<GlobalIndexReader>> CreateSingleReader(const 
GlobalIndexIOMeta& meta);
+    Result<RoaringBitmap64> ReadNullBitmap(const std::shared_ptr<BlockCache>& 
cache,
+                                           const std::optional<BlockHandle>& 
block_handle);
+
+ private:
+    std::optional<int32_t> read_buffer_size_;
+    std::shared_ptr<MemoryPool> pool_;
+    BTreeFileMetaSelector file_selector_;
+    std::shared_ptr<arrow::DataType> key_type_;
+    std::shared_ptr<GlobalIndexFileReader> file_reader_;
+    std::shared_ptr<CacheManager> cache_manager_;
+    std::map<std::string, std::shared_ptr<GlobalIndexReader>> reader_cache_;
+    std::shared_ptr<Executor> executor_;
+};
+
+}  // namespace paimon
diff --git 
a/src/paimon/common/global_index/btree/lazy_filtered_btree_reader_test.cpp 
b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader_test.cpp
new file mode 100644
index 0000000..655efa9
--- /dev/null
+++ b/src/paimon/common/global_index/btree/lazy_filtered_btree_reader_test.cpp
@@ -0,0 +1,466 @@
+/*
+ * 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/global_index/btree/lazy_filtered_btree_reader.h"
+
+#include <numeric>
+#include <string>
+#include <vector>
+
+#include "arrow/c/bridge.h"
+#include "arrow/ipc/json_simple.h"
+#include "gtest/gtest.h"
+#include "paimon/common/global_index/btree/btree_global_index_writer.h"
+#include "paimon/common/global_index/btree/btree_global_indexer.h"
+#include "paimon/executor.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/global_index/bitmap_global_index_result.h"
+#include "paimon/global_index/io/global_index_file_reader.h"
+#include "paimon/global_index/io/global_index_file_writer.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+class FakeLazyFileWriter : public GlobalIndexFileWriter {
+ public:
+    FakeLazyFileWriter(const std::shared_ptr<FileSystem>& fs, const 
std::string& base_path)
+        : fs_(fs), base_path_(base_path) {}
+
+    Result<std::string> NewFileName(const std::string& prefix) const override {
+        return prefix + "_" + std::to_string(file_counter_++);
+    }
+
+    Result<std::unique_ptr<OutputStream>> NewOutputStream(
+        const std::string& file_name) const override {
+        return fs_->Create(base_path_ + "/" + file_name, true);
+    }
+
+    Result<int64_t> GetFileSize(const std::string& file_name) const override {
+        PAIMON_ASSIGN_OR_RAISE(auto file_status, fs_->GetFileStatus(base_path_ 
+ "/" + file_name));
+        return static_cast<int64_t>(file_status->GetLen());
+    }
+
+    std::string ToPath(const std::string& file_name) const override {
+        return base_path_ + "/" + file_name;
+    }
+
+ private:
+    std::shared_ptr<FileSystem> fs_;
+    std::string base_path_;
+    mutable int64_t file_counter_ = 0;
+};
+
+class FakeLazyFileReader : public GlobalIndexFileReader {
+ public:
+    FakeLazyFileReader(const std::shared_ptr<FileSystem>& fs, const 
std::string& base_path)
+        : fs_(fs), base_path_(base_path) {}
+
+    Result<std::unique_ptr<InputStream>> GetInputStream(
+        const std::string& file_path) const override {
+        return fs_->Open(file_path);
+    }
+
+ private:
+    std::shared_ptr<FileSystem> fs_;
+    std::string base_path_;
+};
+
+class LazyFilteredBTreeReaderTest : public ::testing::Test {
+ public:
+    void SetUp() override {
+        test_dir_ = UniqueTestDirectory::Create("local");
+        pool_ = GetDefaultPool();
+        fs_ = test_dir_->GetFileSystem();
+        base_path_ = test_dir_->Str();
+        file_writer_ = std::make_shared<FakeLazyFileWriter>(fs_, base_path_);
+
+        // Create 3 btree index files with different key ranges:
+        //
+        // file1: keys [1, 1, null, 2, 2], row_ids [0, 1, 2, 3, 4]
+        //        range [1, 2], has_null=true
+        //
+        // file2: keys [5, 6, 7], row_ids [5, 6, 7]
+        //        range [5, 7], has_null=false
+        //
+        // file3: keys [10, null, 12], row_ids [8, 9, 10]
+        //        range [10, 12], has_null=true
+        all_metas_ = {};
+        WriteSingleFile(R"([[1],[1],[null],[2],[2]])", {0, 1, 2, 3, 4});
+        WriteSingleFile(R"([[5],[6],[7]])", {5, 6, 7});
+        WriteSingleFile(R"([[10],[null],[12]])", {8, 9, 10});
+    }
+
+    void WriteSingleFile(const std::string& json_data, const 
std::vector<int64_t>& row_ids) {
+        auto c_schema = CreateArrowSchema();
+        std::map<std::string, std::string> options = 
{{BtreeDefs::kBtreeIndexBlockSize, "4096"},
+                                                      
{BtreeDefs::kBtreeIndexCompression, "NONE"}};
+        ASSERT_OK_AND_ASSIGN(auto indexer, 
BTreeGlobalIndexer::Create(options));
+        ASSERT_OK_AND_ASSIGN(
+            auto writer, indexer->CreateWriter("int_field", c_schema.get(), 
file_writer_, pool_));
+        auto array = arrow::ipc::internal::json::ArrayFromJSON(
+                         arrow::struct_({arrow::field("int_field", 
arrow::int32())}), json_data)
+                         .ValueOrDie();
+        ArrowArray c_array;
+        ASSERT_TRUE(arrow::ExportArray(*array, &c_array).ok());
+
+        ASSERT_OK(writer->AddBatch(&c_array, std::vector<int64_t>(row_ids)));
+        ASSERT_OK_AND_ASSIGN(auto metas, writer->Finish());
+        ASSERT_EQ(metas.size(), 1);
+        all_metas_.push_back(metas[0]);
+    }
+
+    std::unique_ptr<ArrowSchema> CreateArrowSchema() const {
+        auto schema = arrow::schema({arrow::field("int_field", 
arrow::int32())});
+        auto c_schema = std::make_unique<ArrowSchema>();
+        EXPECT_TRUE(arrow::ExportSchema(*schema, c_schema.get()).ok());
+        return c_schema;
+    }
+
+    std::shared_ptr<LazyFilteredBTreeReader> CreateReader(
+        const std::shared_ptr<Executor>& executor = nullptr) const {
+        auto file_reader = std::make_shared<FakeLazyFileReader>(fs_, 
base_path_);
+        auto cache_manager = std::make_shared<CacheManager>(1024 * 1024, 0.5);
+        return 
std::make_shared<LazyFilteredBTreeReader>(/*read_buffer_size=*/std::nullopt,
+                                                         all_metas_, 
arrow::int32(), file_reader,
+                                                         cache_manager, pool_, 
executor);
+    }
+
+    void CheckResult(const std::shared_ptr<GlobalIndexResult>& result,
+                     const std::vector<int64_t>& expected) const {
+        ASSERT_TRUE(result);
+        auto typed_result = 
std::dynamic_pointer_cast<BitmapGlobalIndexResult>(result);
+        ASSERT_TRUE(typed_result);
+        ASSERT_OK_AND_ASSIGN(const RoaringBitmap64* bitmap, 
typed_result->GetBitmap());
+        ASSERT_TRUE(bitmap);
+        ASSERT_EQ(*bitmap, RoaringBitmap64::From(expected))
+            << "result=" << bitmap->ToString()
+            << ", expected=" << RoaringBitmap64::From(expected).ToString();
+    }
+
+    void CheckEmpty(const std::shared_ptr<GlobalIndexResult>& result) const {
+        ASSERT_TRUE(result);
+        auto typed_result = 
std::dynamic_pointer_cast<BitmapGlobalIndexResult>(result);
+        ASSERT_TRUE(typed_result);
+        ASSERT_OK_AND_ASSIGN(const RoaringBitmap64* bitmap, 
typed_result->GetBitmap());
+        ASSERT_TRUE(bitmap);
+        ASSERT_TRUE(bitmap->IsEmpty());
+    }
+
+ private:
+    std::unique_ptr<UniqueTestDirectory> test_dir_;
+    std::shared_ptr<MemoryPool> pool_;
+    std::shared_ptr<FileSystem> fs_;
+    std::string base_path_;
+    std::shared_ptr<FakeLazyFileWriter> file_writer_;
+    std::vector<GlobalIndexIOMeta> all_metas_;
+};
+
+// --- VisitEqual ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitEqualHitSingleFile) {
+    auto reader = CreateReader();
+    // key=1 is in file1 only -> rows 0, 1
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_1));
+    CheckResult(result, {0, 1});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitEqualHitDifferentFile) {
+    auto reader = CreateReader();
+    // key=6 is in file2 only -> row 6
+    Literal literal_6(6);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_6));
+    CheckResult(result, {6});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitEqualNoMatch) {
+    auto reader = CreateReader();
+    // key=99 is out of all ranges -> empty bitmap
+    Literal literal_99(99);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_99));
+    CheckEmpty(result);
+}
+
+// --- VisitNotEqual ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitNotEqual) {
+    auto reader = CreateReader();
+    // NotEqual 5 -> all non-null rows except row 5
+    // non-null rows: 0,1,3,4 (file1) + 5,6,7 (file2) + 8,10 (file3)
+    // minus row 5 (key=5) -> 0,1,3,4,6,7,8,10
+    Literal literal_5(5);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitNotEqual(literal_5));
+    CheckResult(result, {0, 1, 3, 4, 6, 7, 8, 10});
+}
+
+// --- VisitLessThan ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitLessThan) {
+    auto reader = CreateReader();
+    // LessThan 5 -> keys 1,2 from file1 -> rows 0,1,3,4
+    Literal literal_5(5);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal_5));
+    CheckResult(result, {0, 1, 3, 4});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitLessThanNoMatch) {
+    auto reader = CreateReader();
+    // LessThan 1 -> no key < 1 -> empty
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal_1));
+    CheckEmpty(result);
+}
+
+// --- VisitLessOrEqual ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitLessOrEqual) {
+    auto reader = CreateReader();
+    // LessOrEqual 5 -> keys 1,2 from file1 + key 5 from file2 -> rows 
0,1,3,4,5
+    Literal literal_5(5);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessOrEqual(literal_5));
+    CheckResult(result, {0, 1, 3, 4, 5});
+}
+
+// --- VisitGreaterThan ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitGreaterThan) {
+    auto reader = CreateReader();
+    // GreaterThan 7 -> keys 10,12 from file3 -> rows 8,10
+    Literal literal_7(7);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitGreaterThan(literal_7));
+    CheckResult(result, {8, 10});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitGreaterThanNoMatch) {
+    auto reader = CreateReader();
+    // GreaterThan 12 -> no key > 12 -> empty
+    Literal literal_12(12);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitGreaterThan(literal_12));
+    CheckEmpty(result);
+}
+
+// --- VisitGreaterOrEqual ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitGreaterOrEqual) {
+    auto reader = CreateReader();
+    // GreaterOrEqual 6 -> keys 6,7 from file2 + keys 10,12 from file3 -> rows 
6,7,8,10
+    Literal literal_6(6);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitGreaterOrEqual(literal_6));
+    CheckResult(result, {6, 7, 8, 10});
+}
+
+// --- VisitIn ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitInAcrossFiles) {
+    auto reader = CreateReader();
+    // IN(2, 7) -> file1 has key 2 (rows 3,4), file2 has key 7 (row 7)
+    std::vector<Literal> literals = {Literal(2), Literal(7)};
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(literals));
+    CheckResult(result, {3, 4, 7});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitInNoMatch) {
+    auto reader = CreateReader();
+    // IN(99, 100) -> out of all ranges -> empty
+    std::vector<Literal> literals = {Literal(99), Literal(100)};
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(literals));
+    CheckEmpty(result);
+}
+
+// --- VisitNotIn ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitNotIn) {
+    auto reader = CreateReader();
+    // NotIn(1, 5, 10) -> all non-null rows minus rows with key=1,5,10
+    // non-null rows: 0,1,3,4 + 5,6,7 + 8,10
+    // remove key=1 (rows 0,1), key=5 (row 5), key=10 (row 8)
+    // -> 3,4,6,7,10
+    std::vector<Literal> literals = {Literal(1), Literal(5), Literal(10)};
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitNotIn(literals));
+    CheckResult(result, {3, 4, 6, 7, 10});
+}
+
+// --- VisitIsNull ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitIsNull) {
+    auto reader = CreateReader();
+    // file1 has_null=true (row 2), file3 has_null=true (row 9)
+    // file2 has no nulls
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+    CheckResult(result, {2, 9});
+}
+
+// --- VisitIsNotNull ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitIsNotNull) {
+    auto reader = CreateReader();
+    // All non-null rows across all files: 0,1,3,4 + 5,6,7 + 8,10
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+    CheckResult(result, {0, 1, 3, 4, 5, 6, 7, 8, 10});
+}
+
+// --- VisitVectorSearch / VisitFullTextSearch ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitVectorSearchNotSupported) {
+    auto reader = CreateReader();
+    ASSERT_NOK_WITH_MSG(reader->VisitVectorSearch(nullptr),
+                        "LazyFilteredBTreeReader does not support vector 
search");
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestVisitFullTextSearchNotSupported) {
+    auto reader = CreateReader();
+    ASSERT_NOK_WITH_MSG(reader->VisitFullTextSearch(nullptr),
+                        "LazyFilteredBTreeReader does not support full text 
search");
+}
+
+// --- GetIndexType / IsThreadSafe ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestGetIndexType) {
+    auto reader = CreateReader();
+    ASSERT_EQ(reader->GetIndexType(), BtreeDefs::kIdentifier);
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestIsNotThreadSafe) {
+    auto reader = CreateReader();
+    ASSERT_FALSE(reader->IsThreadSafe());
+}
+
+// --- Reader cache verification ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestReaderCacheReuse) {
+    auto reader = CreateReader();
+    // Call VisitEqual twice on the same file's range to verify reader cache 
works
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result1, reader->VisitEqual(literal_1));
+    CheckResult(result1, {0, 1});
+
+    Literal literal_2(2);
+    ASSERT_OK_AND_ASSIGN(auto result2, reader->VisitEqual(literal_2));
+    CheckResult(result2, {3, 4});
+}
+
+// --- Empty files list ---
+
+TEST_F(LazyFilteredBTreeReaderTest, TestEmptyFilesList) {
+    std::vector<GlobalIndexIOMeta> empty_metas;
+    auto file_reader = std::make_shared<FakeLazyFileReader>(fs_, base_path_);
+    auto cache_manager = std::make_shared<CacheManager>(1024 * 1024, 0.5);
+    auto reader = std::make_shared<LazyFilteredBTreeReader>(
+        /*read_buffer_size=*/std::nullopt, empty_metas, arrow::int32(), 
file_reader, cache_manager,
+        pool_, /*executor=*/nullptr);
+
+    // Any query on empty files should return empty bitmap
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_1));
+    CheckEmpty(result);
+
+    ASSERT_OK_AND_ASSIGN(result, reader->VisitIsNotNull());
+    CheckEmpty(result);
+}
+
+// ============================================================================
+// Parallel execution with Executor
+// ============================================================================
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitEqual) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // key=1 is in file1 -> rows 0, 1
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_1));
+    CheckResult(result, {0, 1});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitNotEqual) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // NotEqual 5 -> all non-null rows except row 5 -> 0,1,3,4,6,7,8,10
+    Literal literal_5(5);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitNotEqual(literal_5));
+    CheckResult(result, {0, 1, 3, 4, 6, 7, 8, 10});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitLessThan) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // LessThan 5 -> keys 1,2 from file1 -> rows 0,1,3,4
+    Literal literal_5(5);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitLessThan(literal_5));
+    CheckResult(result, {0, 1, 3, 4});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitGreaterOrEqual) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // GreaterOrEqual 6 -> keys 6,7 from file2 + keys 10,12 from file3 -> rows 
6,7,8,10
+    Literal literal_6(6);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitGreaterOrEqual(literal_6));
+    CheckResult(result, {6, 7, 8, 10});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitIn) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // IN(2, 7) -> file1 has key 2 (rows 3,4), file2 has key 7 (row 7)
+    std::vector<Literal> literals = {Literal(2), Literal(7)};
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIn(literals));
+    CheckResult(result, {3, 4, 7});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitIsNull) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // file1 has_null=true (row 2), file3 has_null=true (row 9)
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNull());
+    CheckResult(result, {2, 9});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitIsNotNull) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // All non-null rows across all files: 0,1,3,4 + 5,6,7 + 8,10
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitIsNotNull());
+    CheckResult(result, {0, 1, 3, 4, 5, 6, 7, 8, 10});
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelVisitEqualNoMatch) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    auto reader = CreateReader(executor);
+    // key=99 out of range -> empty bitmap
+    Literal literal_99(99);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_99));
+    CheckEmpty(result);
+}
+
+TEST_F(LazyFilteredBTreeReaderTest, TestParallelEmptyFilesList) {
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    std::vector<GlobalIndexIOMeta> empty_metas;
+    auto file_reader = std::make_shared<FakeLazyFileReader>(fs_, base_path_);
+    auto cache_manager = std::make_shared<CacheManager>(1024 * 1024, 0.5);
+    auto reader = std::make_shared<LazyFilteredBTreeReader>(
+        /*read_buffer_size=*/std::nullopt, empty_metas, arrow::int32(), 
file_reader, cache_manager,
+        pool_, executor);
+    Literal literal_1(1);
+    ASSERT_OK_AND_ASSIGN(auto result, reader->VisitEqual(literal_1));
+    CheckEmpty(result);
+}
+
+}  // namespace paimon::test

Reply via email to